//! The table data model: column types inferred from a sampled grid, plus
//! synthetic-row generation. This is the "generate similar synthetic data"
//! half of the roadmap and deliberately lives in wasm rather than JS.
use crate::wasm::rng::Rng;
use crate::wasm::wire::{RS, US};
/// AG Grid's row-model types. Determines how data is read and re-injected.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum RowModel {
ClientSide,
ServerSide,
Infinite,
Viewport,
}
impl RowModel {
pub fn from_str(s: &str) -> Self {
match s {
"serverSide" => RowModel::ServerSide,
"infinite" => RowModel::Infinite,
"viewport" => RowModel::Viewport,
_ => RowModel::ClientSide,
}
}
pub fn as_str(&self) -> &'static str {
match self {
RowModel::ClientSide => "clientSide",
RowModel::ServerSide => "serverSide",
RowModel::Infinite => "infinite",
RowModel::Viewport => "viewport",
}
}
/// Human label for the UI badge.
pub fn label(&self) -> &'static str {
match self {
RowModel::ClientSide => "client-side",
RowModel::ServerSide => "server-side",
RowModel::Infinite => "infinite",
RowModel::Viewport => "viewport",
}
}
}
/// Inferred type + parameters for one column, driving both generation and the
/// JS-side value coercion on injection.
pub enum ColKind {
Bool,
Int {
min: i64,
max: i64,
},
Float {
min: f64,
max: f64,
},
/// Epoch-millis range; emitted as millis, coerced back to `Date` in JS.
Date {
min: i64,
max: i64,
},
/// Small set of repeated values.
Enum {
values: Vec<String>,
},
/// Free text; generated from a sampled pool or random characters.
Str {
pool: Vec<String>,
min_len: usize,
max_len: usize,
},
}
impl ColKind {
/// Token telling the JS side how to coerce generated cell strings back into
/// native values before handing them to AG Grid.
pub fn coerce(&self) -> &'static str {
match self {
ColKind::Bool => "bool",
ColKind::Int { .. } | ColKind::Float { .. } => "number",
ColKind::Date { .. } => "date",
ColKind::Enum { .. } | ColKind::Str { .. } => "string",
}
}
/// Produce one synthetic cell value as a string.
fn gen_cell(&self, rng: &mut Rng) -> String {
match self {
ColKind::Bool => if rng.bool() { "true" } else { "false" }.to_string(),
ColKind::Int { min, max } => rng.range_i64(*min, *max).to_string(),
ColKind::Float { min, max } => fmt_f64(rng.range_f64(*min, *max)),
ColKind::Date { min, max } => rng.range_i64(*min, *max).to_string(),
ColKind::Enum { values } => rng.pick(values).cloned().unwrap_or_default(),
ColKind::Str {
pool,
min_len,
max_len,
} => match rng.pick(pool) {
Some(s) => s.clone(),
None => {
let lo = (*min_len).max(1);
let hi = (*max_len).max(lo);
let len = rng.range_i64(lo as i64, hi as i64) as usize;
rand_alnum(rng, len)
}
},
}
}
}
pub struct Column {
pub field: String,
pub header: String,
pub kind: ColKind,
}
pub struct Table {
pub id: String,
pub row_model: RowModel,
/// Rows observed when sampling the live grid.
pub row_count: usize,
/// Desired number of rows to generate (config-driven; defaults to observed).
pub gen_count: usize,
pub columns: Vec<Column>,
}
impl Table {
/// Parse the schema payload produced by the JS `ag_grid_schema` import.
///
/// Record 0 is meta `rowModel US rowCount US gridId`; each following record
/// is a column `field US header US typeofHint US sample US sample ...`.
pub fn from_schema(text: &str) -> Table {
let mut records = text.split(RS);
let meta = records.next().unwrap_or("");
let mut meta_fields = meta.split(US);
let row_model = RowModel::from_str(meta_fields.next().unwrap_or(""));
let row_count = meta_fields.next().unwrap_or("0").parse().unwrap_or(0);
let id = meta_fields.next().unwrap_or("").to_string();
let mut columns = Vec::new();
for record in records {
if record.is_empty() {
continue;
}
let mut fields = record.split(US);
let field = fields.next().unwrap_or("").to_string();
if field.is_empty() {
continue;
}
let header = fields.next().unwrap_or(&field).to_string();
let hint = fields.next().unwrap_or("string");
let samples: Vec<String> = fields.map(|s| s.to_string()).collect();
let kind = infer_kind(hint, &samples);
columns.push(Column {
field,
header,
kind,
});
}
Table {
id,
row_model,
row_count,
gen_count: row_count,
columns,
}
}
/// Build the injection payload: a header record of `field US coerce`
/// pairs, followed by one record per generated row (cells in column order).
pub fn generate(&self, count: usize, rng: &mut Rng) -> String {
let mut header = String::new();
for (i, col) in self.columns.iter().enumerate() {
if i > 0 {
header.push(US);
}
header.push_str(&col.field);
header.push(US);
header.push_str(col.kind.coerce());
}
let mut out = header;
for _ in 0..count {
out.push(RS);
for (i, col) in self.columns.iter().enumerate() {
if i > 0 {
out.push(US);
}
out.push_str(&col.kind.gen_cell(rng));
}
}
out
}
}
/// Infer a column type from a JS `typeof` hint plus sampled string values.
fn infer_kind(hint: &str, samples: &[String]) -> ColKind {
match hint {
"boolean" => ColKind::Bool,
"number" => {
let nums: Vec<f64> = samples
.iter()
.filter_map(|s| s.parse::<f64>().ok())
.collect();
if nums.is_empty() {
return ColKind::Int { min: 0, max: 1000 };
}
let min = nums.iter().cloned().fold(f64::INFINITY, f64::min);
let max = nums.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let all_int = nums.iter().all(|v| v.fract() == 0.0 && v.abs() < 9e18);
if all_int {
ColKind::Int {
min: min as i64,
max: max as i64,
}
} else {
ColKind::Float { min, max }
}
}
"date" => {
let ms: Vec<i64> = samples
.iter()
.filter_map(|s| s.parse::<i64>().ok())
.collect();
if ms.is_empty() {
// 2020-01-01 .. 2024-01-01
ColKind::Date {
min: 1_577_836_800_000,
max: 1_704_067_200_000,
}
} else {
let min = *ms.iter().min().unwrap();
let max = *ms.iter().max().unwrap();
ColKind::Date { min, max }
}
}
_ => str_kind(samples),
}
}
/// Decide between a small enum and free text for string columns.
fn str_kind(samples: &[String]) -> ColKind {
let mut distinct: Vec<String> = Vec::new();
for s in samples {
if !distinct.iter().any(|d| d == s) {
distinct.push(s.clone());
}
}
if !distinct.is_empty() && distinct.len() <= 12 {
ColKind::Enum { values: distinct }
} else {
let min_len = samples.iter().map(|s| s.chars().count()).min().unwrap_or(3);
let max_len = samples
.iter()
.map(|s| s.chars().count())
.max()
.unwrap_or(10);
ColKind::Str {
pool: distinct,
min_len,
max_len,
}
}
}
/// Format a float with up to two decimals, trimming trailing zeros.
fn fmt_f64(v: f64) -> String {
let s = format!("{:.2}", v);
let trimmed = s.trim_end_matches('0').trim_end_matches('.');
if trimmed.is_empty() {
"0".to_string()
} else {
trimmed.to_string()
}
}
fn rand_alnum(rng: &mut Rng, len: usize) -> String {
const CH: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
(0..len.max(1))
.map(|_| CH[rng.below(CH.len() as u64) as usize] as char)
.collect()
}