//! The column vocabulary every table-definition screen shares. //! //! Two pages describe columns: `add_table` creates a table out of them, and //! `admin/table_definition` appends them to a table that already exists. The //! rules are the same in both — what types exist, what a name may be, when a //! currency is required — so they live here rather than in either page, and //! neither page is allowed its own copy. //! //! The vocabulary itself is not written down here: it is read from the //! backend's `ListColumnTypes` endpoint into a [`ColumnCatalog`], which every //! rule below asks. A type the server adds is therefore offered by both //! screens without a change here, and a type it stops accepting disappears //! from both. //! //! Everything above [`proto_columns`] is proto-free. This module is the piece //! of the web UI that would move into a crate shared with `client` and //! `server`; keeping the generated types out of the rules is what makes that //! move a rename rather than a rewrite. use std::sync::Arc; use crate::definitions::table_definition::{ ColumnDefinition as ProtoColumnDefinition, ColumnTypeSpelling, MoneyRounding, list_column_types_response::ColumnType as ProtoColumnType, }; /// The order the type picker offers the types it knows about in — the common /// ones first, rather than the alphabetical order the endpoint returns. /// /// Anything the server offers that is not named here still appears, after /// these and in the server's own order, so a newly added type is never hidden /// by this list being out of date. const TYPE_DISPLAY_ORDER: &[&str] = &[ "text", "boolean", "money", "accounting", "accounting_transfer", "int", "bigint", "decimal", "numeric", "temporal", "duration", "period", "phone", "iban", "email_address", "credit_card", "gtin", ]; /// One column type as the backend describes it. /// /// A local mirror of the endpoint's message: the rules below are written /// against this rather than the generated type, which is what keeps them /// proto-free. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct ColumnType { pub name: String, /// The PostgreSQL type it maps to; empty for a compound type. pub sql_type: String, pub declarable: bool, /// A definition row rather than a column: it expands into schema-managed /// companions and leaves no column of its own name behind. pub compound: bool, /// The name takes a precision and a scale: `decimal(12,3)`. pub parameterised: bool, pub requires_currency: bool, /// Only choosable while the table is being created. pub creation_only: bool, pub allows_quantity_ledger: bool, /// Groups several types behind one choice in the picker; empty when the /// type stands on its own. pub group: String, } /// Every column type the backend accepts, as one screen's picker reads it. /// /// Cheap to clone: the panel travels with every request, and each of them /// carries the catalog it was rendered from. #[derive(Clone, Debug, Default)] pub(crate) struct ColumnCatalog { types: Arc<[ColumnType]>, } impl ColumnCatalog { pub(crate) fn new(types: Vec) -> Self { Self { types: types.into(), } } /// Whether the catalog has been read from the backend yet. A handler that /// has to validate a draft before it loads the rest of the page fetches /// the catalog itself; the loader asks this so it does not fetch it twice. pub(crate) fn is_loaded(&self) -> bool { !self.types.is_empty() } fn find(&self, name: &str) -> Option<&ColumnType> { self.types .iter() .find(|column_type| column_type.name.eq_ignore_ascii_case(name.trim())) } /// Whether `name` is a group the picker offers instead of the types in it — /// `temporal` for the date and time types, `gtin` for the GTIN lengths. fn is_group(&self, name: &str) -> bool { let name = name.trim(); !name.is_empty() && self .types .iter() .any(|column_type| column_type.group.eq_ignore_ascii_case(name)) } /// The types the picker offers, groups collapsed to their group name. /// /// `creating_table` is false on the append screen, where the server refuses /// the creation-only types: they bring schema-managed companion columns /// that cannot be bolted onto a table that already exists. pub(crate) fn offered_types(&self, creating_table: bool) -> Vec { let mut offered = Vec::new(); for column_type in self.types.iter() { if !column_type.declarable || (column_type.creation_only && !creating_table) { continue; } let offer = if column_type.group.is_empty() { &column_type.name } else { &column_type.group }; if !offered.iter().any(|existing| existing == offer) { offered.push(offer.clone()); } } offered.sort_by_key(|offer| { TYPE_DISPLAY_ORDER .iter() .position(|known| known == offer) .unwrap_or(TYPE_DISPLAY_ORDER.len()) }); offered } /// The members of one group, as the follow-up picker offers them. A GTIN /// length is offered as `13` rather than `gtin_13`, which is what /// [`ColumnDraft::canonical_type_input`] puts back together. pub(crate) fn group_members(&self, group: &str) -> Vec { self.types .iter() .filter(|column_type| column_type.declarable && column_type.group == group) .map(|column_type| { column_type .name .strip_prefix(&format!("{group}_")) .unwrap_or(&column_type.name) .to_string() }) .collect() } /// Whether a column of this type declares a currency. /// /// Both MONEY and ACCOUNTING do, which is why this is asked rather than /// compared inline: written by hand, the ACCOUNTING half is easy to /// forget, and forgetting it is silent — the currency is still stored and /// sent, it just stops being validated or displayed. pub(crate) fn requires_currency(&self, field_type: &str) -> bool { self.find(field_type) .is_some_and(|column_type| column_type.requires_currency) } /// A compound type is a definition row: it expands into schema-managed /// companion columns, so no column of its own name survives. It can /// therefore never be indexed and never identify a row. pub(crate) fn is_compound(&self, field_type: &str) -> bool { self.find(field_type) .is_some_and(|column_type| column_type.compound) } fn is_creation_only(&self, field_type: &str) -> bool { self.find(field_type) .is_some_and(|column_type| column_type.creation_only) } /// Whether the type takes a precision and a scale. fn is_parameterised(&self, field_type: &str) -> bool { self.find(field_type) .is_some_and(|column_type| column_type.parameterised) } /// The PostgreSQL type a column is stored as, for the definitions the /// backend reports back. Empty for a compound type and for any type this /// catalog does not know. pub(crate) fn sql_type(&self, field_type: &str) -> String { let field_type = field_type.trim(); match decimal_arguments(&field_type.to_lowercase()) { // `decimal(12,3)` is stored as its head's SQL type, parameterised. Some((precision, scale)) => match self.find("decimal") { Some(column_type) if !column_type.sql_type.is_empty() => { format!("{}({precision},{scale})", column_type.sql_type) } _ => String::new(), }, None => self .find(field_type) .map(|column_type| column_type.sql_type.clone()) .unwrap_or_default(), } } /// The types a quantity-ledger column may use, spelled for the hint under /// the input. Read from the catalog so the hint cannot claim a set the /// server does not accept. pub(crate) fn quantity_ledger_types(&self) -> String { let mut names = self .types .iter() .filter(|column_type| column_type.declarable && column_type.allows_quantity_ledger) .map(|column_type| column_type.name.to_uppercase()) .collect::>(); names.sort(); names.join(", ") } fn allows_quantity_ledger(&self, field_type: &str) -> bool { match decimal_arguments(&field_type.to_lowercase()) { Some(_) => self .find("decimal") .is_some_and(|column_type| column_type.allows_quantity_ledger), None => self .find(field_type) .is_some_and(|column_type| column_type.allows_quantity_ledger), } } /// Whether the server would accept this as a column's declared type. pub(crate) fn validate_field_type(&self, field_type: &str) -> Option { let field_type = field_type.to_lowercase(); if let Some((precision, scale)) = decimal_arguments(&field_type) { if !self.is_parameterised("decimal") { return Some("`decimal` is not a valid field type.".to_string()); } return validate_decimal_arguments(precision, scale).err(); } match self.find(&field_type) { // A parameterised type spelled bare is missing its arguments. Some(column_type) if column_type.parameterised => Some(format!( "`{field_type}` needs both a precision and a scale." )), Some(column_type) if column_type.declarable => None, Some(_) => Some(format!( "`{field_type}` is a column type the backend generates itself." )), None => Some(format!("`{field_type}` is not a valid field type.")), } } } /// Splits `decimal(p,s)` into its arguments, which is the one spelling that is /// not simply a type name. fn decimal_arguments(field_type: &str) -> Option<(&str, &str)> { let arguments = field_type .strip_prefix("decimal(") .and_then(|rest| rest.strip_suffix(')'))?; Some(match arguments.split_once(',') { Some((precision, scale)) => (precision.trim(), scale.trim()), // No comma at all: the scale is missing, and the emptiness is what // `validate_decimal_arguments` reports. None => (arguments.trim(), ""), }) } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) enum MoneyMode { #[default] Exact, Rounded, } impl MoneyMode { pub(crate) fn label(self) -> &'static str { match self { Self::Exact => "exact", Self::Rounded => "half-up", } } pub(crate) fn from_input(value: &str) -> Self { if value.trim().eq_ignore_ascii_case("half-up") { Self::Rounded } else { Self::Exact } } } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct ColumnDefinition { pub name: String, pub data_type: String, pub indexed: bool, pub quantity_ledger: bool, pub money_mode: MoneyMode, pub currency: String, } impl ColumnDefinition { /// The `option` cell of the preview, mirroring the client's preview table. /// /// The currency is shown whenever there is one, which is the same thing as /// asking the catalog: it is only ever stored for a type that requires it. pub(crate) fn option_label(&self) -> String { let has_currency = !self.currency.is_empty(); match (self.indexed, has_currency) { (true, true) => format!("indexed, {}, {}", self.currency, self.money_mode.label()), (true, false) => "indexed".to_string(), (false, true) => format!("{}, {}", self.currency, self.money_mode.label()), (false, false) => String::new(), } } } /// The column-input panel and the columns it has produced so far. /// /// One pending column is described in the inputs; pressing "add" validates it /// and moves it into `added`. Both screens that describe columns embed one of /// these, which is what keeps their rules identical. #[derive(Clone, Debug, Default)] pub(crate) struct ColumnDraft { pub name_input: String, pub type_input: String, pub temporal_type_input: String, pub gtin_type_input: String, pub decimal_precision_input: String, pub decimal_scale_input: String, pub indexing_input: String, pub quantity_ledger_input: String, pub rounding_input: String, pub currency_input: String, pub added: Vec, /// The vocabulary this panel offers and validates against. Filled in by /// the page's loader from `ListColumnTypes`; empty until then, which /// refuses every type rather than guessing at one. pub catalog: ColumnCatalog, /// False on the append screen: the creation-only types can only be chosen /// while the table is being created. pub creating_table: bool, } impl ColumnDraft { /// A panel for a table that is being created, where every type applies. pub(crate) fn new(catalog: ColumnCatalog) -> Self { Self { creating_table: true, ..Self::empty(catalog) } } /// A panel for appending to an existing table. pub(crate) fn for_append(catalog: ColumnCatalog) -> Self { Self::empty(catalog) } fn empty(catalog: ColumnCatalog) -> Self { Self { indexing_input: "no".to_string(), quantity_ledger_input: "no".to_string(), rounding_input: "none".to_string(), currency_input: "EUR".to_string(), catalog, ..Self::default() } } /// The types this panel offers, which is the only place the creation-only /// rule shows up in the markup. pub(crate) fn offered_types(&self) -> Vec { self.catalog.offered_types(self.creating_table) } pub(crate) fn temporal_types(&self) -> Vec { self.catalog.group_members("temporal") } pub(crate) fn gtin_types(&self) -> Vec { self.catalog.group_members("gtin") } /// The types a quantity-ledger column may use, for the hint under the /// input. pub(crate) fn quantity_ledger_types(&self) -> String { self.catalog.quantity_ledger_types() } // ---- field visibility (the same rules the TUI canvas applies) -------- pub(crate) fn pending_carries_currency(&self) -> bool { self.catalog.requires_currency(&self.type_input) } pub(crate) fn show_temporal_type(&self) -> bool { self.pending_group().is_some_and(|group| group == "temporal") } pub(crate) fn show_gtin_type(&self) -> bool { self.pending_group().is_some_and(|group| group == "gtin") } pub(crate) fn show_decimal_arguments(&self) -> bool { self.catalog.is_parameterised(&self.type_input) } /// Currency and rounding both apply only to a money column. pub(crate) fn show_money_options(&self) -> bool { self.pending_carries_currency() } /// The group the pending choice names, when it names one rather than a /// type — which is what asks for a follow-up field. fn pending_group(&self) -> Option { let group = self.type_input.trim().to_ascii_lowercase(); self.catalog.is_group(&group).then_some(group) } // ---- the pending column --------------------------------------------- /// The storable type the pending inputs describe, resolving a group choice /// and the `decimal` arguments to their canonical form. `None` while the /// choice is still incomplete, `Err` when the follow-up fields are filled /// in but wrong. fn canonical_type_input(&self) -> Result, String> { let column_type = self.type_input.trim().to_ascii_lowercase(); if column_type.is_empty() { return Ok(None); } if self.catalog.is_parameterised(&column_type) { let precision = self.decimal_precision_input.trim(); let scale = self.decimal_scale_input.trim(); if precision.is_empty() && scale.is_empty() { return Ok(None); } validate_decimal_arguments(precision, scale)?; return Ok(Some(format!("{column_type}({precision},{scale})"))); } let Some(group) = self.pending_group() else { return Ok(Some(column_type)); }; // A group is chosen by its member, which is spelled without the group // prefix wherever the catalog carries one. let member = match group.as_str() { "gtin" => self.gtin_type_input.trim(), _ => self.temporal_type_input.trim(), } .to_ascii_lowercase(); Ok(self .catalog .group_members(&group) .contains(&member) .then(|| match self.catalog.find(&member) { Some(column_type) => column_type.name.clone(), None => format!("{group}_{member}"), })) } /// Appends the pending column, then clears the input panel. pub(crate) fn add_from_inputs(&mut self) -> Result { let Some(column_type) = self.canonical_type_input()? else { return Err("Both a column name and a column type are required.".to_string()); }; if !self.creating_table && self.catalog.is_creation_only(&column_type) { return Err(format!( "A {} column can only be chosen while the table is being created.", column_type.to_uppercase() )); } // A compound column expands into schema-managed companions and leaves // no column of its own behind, so its name is its type. let compound = self.catalog.is_compound(&column_type); let column_name = if compound { column_type.clone() } else { self.name_input.trim().to_string() }; if column_name.is_empty() { return Err("Both a column name and a column type are required.".to_string()); } if let Some(error) = validate_identifier(&column_name, "Column name", true) { return Err(error); } if let Some(error) = self.catalog.validate_field_type(&column_type) { return Err(error); } if self.added.iter().any(|column| column.name == column_name) { return Err(format!("A column named `{column_name}` already exists.")); } let quantity_ledger = self.quantity_ledger_input.trim().eq_ignore_ascii_case("yes"); if quantity_ledger && !self.catalog.allows_quantity_ledger(&column_type) { return Err(format!( "Quantity-ledger columns must use {}", self.catalog.quantity_ledger_types() )); } let has_currency = self.catalog.requires_currency(&column_type); let currency = if has_currency { normalize_currency_input(&self.currency_input)? } else { String::new() }; self.added.push(ColumnDefinition { name: column_name.clone(), data_type: column_type, // A compound column is not a column, so there is nothing to index. indexed: !compound && self.indexing_input.trim().eq_ignore_ascii_case("yes"), quantity_ledger, money_mode: if has_currency { MoneyMode::from_input(&self.rounding_input) } else { MoneyMode::Exact }, currency, }); self.clear_inputs(); Ok(format!("Column `{column_name}` added.")) } fn clear_inputs(&mut self) { self.name_input.clear(); self.type_input.clear(); self.temporal_type_input.clear(); self.gtin_type_input.clear(); self.decimal_precision_input.clear(); self.decimal_scale_input.clear(); self.indexing_input = "no".to_string(); self.quantity_ledger_input = "no".to_string(); self.rounding_input = "none".to_string(); self.currency_input = "EUR".to_string(); } // ---- the columns added so far ---------------------------------------- /// Removes one column. The caller is what knows whether anything else /// referenced it — `add_table` drops it from the display columns too. pub(crate) fn remove(&mut self, index: usize) -> Result { if index >= self.added.len() { return Err("That column no longer exists.".to_string()); } Ok(self.added.remove(index)) } /// Whether a column can be indexed or identify a row. A compound column /// leaves no column of its own name behind, so it can do neither. pub(crate) fn is_indexable(&self, index: usize) -> bool { self.added .get(index) .is_some_and(|column| !self.catalog.is_compound(&column.data_type)) } pub(crate) fn toggle_indexed(&mut self, index: usize) { if !self.is_indexable(index) { return; } if let Some(column) = self.added.get_mut(index) { column.indexed = !column.indexed; } } pub(crate) fn selected_index_names(&self) -> Vec { self.added .iter() .filter(|column| column.indexed) .map(|column| column.name.clone()) .collect() } pub(crate) fn is_empty(&self) -> bool { self.added.is_empty() } /// Re-checks the columns themselves. /// /// [`Self::add_from_inputs`] already applies these, but a draft rebuilt /// from a posted form has not been through that path, so this is what a /// tampered-with or truncated post is held to. pub(crate) fn validate(&self) -> Result<(), String> { for column in &self.added { if let Some(error) = validate_identifier(&column.name, "Column name", true) { return Err(error); } if let Some(error) = self.catalog.validate_field_type(&column.data_type) { return Err(format!("Column `{}`: {error}", column.name)); } if !self.creating_table && self.catalog.is_creation_only(&column.data_type) { return Err(format!( "A {} column can only be chosen while the table is being created.", column.data_type.to_uppercase() )); } if column.quantity_ledger && !self.catalog.allows_quantity_ledger(&column.data_type) { return Err(format!( "Column `{}`: quantity-ledger columns must use {}", column.name, self.catalog.quantity_ledger_types() )); } // The same rule the server enforces: required for a money column, // forbidden for every other type. if self.catalog.requires_currency(&column.data_type) { if let Err(error) = normalize_currency_input(&column.currency) { return Err(format!("Column `{}`: {error}", column.name)); } } else if !column.currency.trim().is_empty() { return Err(format!( "Column `{}`: a currency belongs only to a column type that requires one.", column.name )); } } Ok(()) } } pub(crate) fn normalize_currency_input(value: &str) -> Result { let currency = value.trim().to_ascii_uppercase(); if rusty_money::iso::find(¤cy).is_none() { return Err("Currency must be a three-letter ISO-4217 code".to_string()); } Ok(currency) } /// PostgreSQL identifier rules, plus the names this schema reserves. pub(crate) fn validate_identifier( value: &str, label: &str, reject_table_reserved: bool, ) -> Option { if value.is_empty() { return Some(format!("{label} cannot be empty.")); } if value != value.trim() { return Some(format!("{label} cannot start or end with a space.")); } if value.starts_with('_') { return Some(format!("{label} cannot start with an underscore.")); } if value.chars().next().is_some_and(|c| c.is_ascii_digit()) { return Some(format!("{label} cannot start with a number.")); } if value.len() > 63 { return Some(format!("{label} cannot be longer than 63 characters.")); } if value .chars() .any(|c| !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_') { return Some(format!( "{label} may only use lowercase letters, digits and underscores." )); } // Only the system columns are reserved. The `_id` suffix is free: no // column name is derived from a table name any more, so it collides with // nothing. if reject_table_reserved && matches!(value, "id" | "deleted" | "created_at" | "row_revision") { return Some(format!("{label} uses a reserved name.")); } if !reject_table_reserved && (value == "public" || value == "information_schema" || value.starts_with("pg_")) { return Some("That profile name is reserved by PostgreSQL.".to_string()); } None } /// The precision and scale rules the server applies to `decimal(p,s)`: /// whole numbers, no sign, no leading zeros, `1 <= p` and `s <= p`. fn validate_decimal_arguments(precision: &str, scale: &str) -> Result<(), String> { let precision = validate_decimal_number("Precision", precision)?; let scale = validate_decimal_number("Scale", scale)?; if precision < 1 { return Err("Precision must be at least 1.".to_string()); } if scale > precision { return Err("Scale cannot be greater than precision.".to_string()); } Ok(()) } fn validate_decimal_number(label: &str, value: &str) -> Result { if value.is_empty() { return Err(format!("{label} is required for a decimal column.")); } if value.starts_with('+') || value.starts_with('-') { return Err(format!("{label} cannot carry a sign.")); } if value.contains('.') { return Err(format!("{label} must be a whole number.")); } if value.len() > 1 && value.starts_with('0') { return Err(format!("{label} cannot have leading zeros.")); } value .parse::() .map_err(|_| format!("{label} must be a whole number.")) } /// The seam where the rules above meet the generated request types. pub(crate) fn proto_columns(columns: &[ColumnDefinition]) -> Vec { columns .iter() .map(|column| ProtoColumnDefinition { name: column.name.clone(), field_type: column.data_type.clone(), rounding: match column.money_mode { MoneyMode::Rounded => MoneyRounding::HalfUp.into(), MoneyMode::Exact => MoneyRounding::None.into(), }, quantity_ledger: column.quantity_ledger, currency: column.currency.clone(), }) .collect() } /// The other half of that seam: the `ListColumnTypes` response as the rules /// above read it. pub(crate) fn column_catalog(column_types: Vec) -> ColumnCatalog { ColumnCatalog::new( column_types .into_iter() .map(|column_type| ColumnType { parameterised: column_type.spelling() == ColumnTypeSpelling::Decimal, name: column_type.name, sql_type: column_type.sql_type, declarable: column_type.declarable, compound: column_type.compound, requires_currency: column_type.requires_currency, creation_only: column_type.creation_only, allows_quantity_ledger: column_type.allows_quantity_ledger, group: column_type.group, }) .collect(), ) } /// ISO-4217 codes offered as currency suggestions, matching the client's list. pub(crate) const CURRENCY_CODES: &[&str] = &[ "EUR", "CZK", "USD", "AED", "AFN", "ALL", "AMD", "AOA", "ARS", "AUD", "AWG", "AZN", "BAM", "BBD", "BDT", "BGN", "BHD", "BIF", "BMD", "BND", "BOB", "BRL", "BSD", "BTN", "BWP", "BYN", "BZD", "CAD", "CDF", "CHF", "CLF", "CLP", "CNY", "COP", "CRC", "CUP", "CVE", "DJF", "DKK", "DOP", "DZD", "EGP", "ERN", "ETB", "FJD", "FKP", "GBP", "GEL", "GHS", "GIP", "GMD", "GNF", "GTQ", "GYD", "HKD", "HNL", "HTG", "HUF", "IDR", "ILS", "INR", "IQD", "IRR", "ISK", "JMD", "JOD", "JPY", "KES", "KGS", "KHR", "KMF", "KPW", "KRW", "KWD", "KYD", "KZT", "LAK", "LBP", "LKR", "LRD", "LSL", "LYD", "MAD", "MDL", "MGA", "MKD", "MMK", "MNT", "MOP", "MRU", "MUR", "MVR", "MWK", "MXN", "MYR", "MZN", "NAD", "NGN", "NIO", "NOK", "NPR", "NZD", "OMR", "PAB", "PEN", "PGK", "PHP", "PKR", "PLN", "PYG", "QAR", "RON", "RSD", "RUB", "RWF", "SAR", "SBD", "SCR", "SDG", "SEK", "SGD", "SHP", "SLE", "SOS", "SRD", "SSP", "STN", "SVC", "SYP", "SZL", "THB", "TJS", "TMT", "TND", "TOP", "TRY", "TTD", "TWD", "TZS", "UAH", "UGX", "UYU", "UYW", "UZS", "VES", "VED", "VND", "VUV", "WST", "XAF", "XAG", "XAU", "XCD", "XDR", "XOF", "XPD", "XPF", "XPT", "YER", "ZAR", "ZMW", "ZWG", "ANG", "CUC", "HRK", "SKK", "SLL", "STD", "ZMK", "ZWL", ]; /// The wire format of the column panel. /// /// HTTP is stateless, so the whole panel travels with every interaction: the /// pending inputs as scalars, and each already-added column as a set of /// parallel repeated fields. `serde_html_form` decodes the repeats into /// `Vec`s, which [`Self::to_draft`] zips back into a [`ColumnDraft`]. /// /// `add_table` posts these same field names as part of its larger form; see /// [`columns_from_rows`], which is what both paths rebuild the list with. #[derive(Clone, Debug, Default, serde::Deserialize)] pub(crate) struct ColumnForm { /// Which panel button was pressed. #[serde(default)] pub action: String, /// Row the action applies to, for the per-row buttons. #[serde(default)] pub index: Option, #[serde(default)] pub column_name_input: String, #[serde(default)] pub column_type_input: String, #[serde(default)] pub temporal_type_input: String, #[serde(default)] pub gtin_type_input: String, #[serde(default)] pub decimal_precision_input: String, #[serde(default)] pub decimal_scale_input: String, #[serde(default)] pub column_indexing_input: String, #[serde(default)] pub column_quantity_ledger_input: String, #[serde(default)] pub column_rounding_input: String, #[serde(default)] pub column_currency_input: String, #[serde(default)] pub column_names: Vec, #[serde(default)] pub column_types: Vec, #[serde(default)] pub column_indexed: Vec, #[serde(default)] pub column_quantity_ledger: Vec, #[serde(default)] pub column_rounding: Vec, #[serde(default)] pub column_currencies: Vec, } impl ColumnForm { pub(crate) fn to_draft(&self, catalog: ColumnCatalog, creating_table: bool) -> ColumnDraft { ColumnDraft { name_input: self.column_name_input.clone(), type_input: self.column_type_input.clone(), temporal_type_input: self.temporal_type_input.clone(), gtin_type_input: self.gtin_type_input.clone(), decimal_precision_input: self.decimal_precision_input.clone(), decimal_scale_input: self.decimal_scale_input.clone(), indexing_input: self.column_indexing_input.clone(), quantity_ledger_input: self.column_quantity_ledger_input.clone(), rounding_input: self.column_rounding_input.clone(), currency_input: self.column_currency_input.clone(), added: columns_from_rows( &self.column_names, &self.column_types, &self.column_indexed, &self.column_quantity_ledger, &self.column_rounding, &self.column_currencies, ), catalog, creating_table, } } } fn is_yes(value: &str) -> bool { value.trim().eq_ignore_ascii_case("yes") } /// Zips the posted column vectors back into column definitions. /// /// The vectors are parallel, so a short one — a truncated or tampered-with /// post — simply limits how many columns are reconstructed rather than /// mis-pairing them. pub(crate) fn columns_from_rows( names: &[String], types: &[String], indexed: &[String], quantity_ledger: &[String], rounding: &[String], currencies: &[String], ) -> Vec { let count = [ names.len(), types.len(), indexed.len(), quantity_ledger.len(), rounding.len(), currencies.len(), ] .into_iter() .min() .unwrap_or(0); (0..count) .map(|index| ColumnDefinition { name: names[index].clone(), data_type: types[index].clone(), indexed: is_yes(&indexed[index]), quantity_ledger: is_yes(&quantity_ledger[index]), money_mode: MoneyMode::from_input(&rounding[index]), currency: currencies[index].clone(), }) .collect() } #[cfg(test)] pub(crate) mod tests { use super::*; fn declarable(name: &str) -> ColumnType { ColumnType { name: name.to_string(), sql_type: "TEXT".to_string(), declarable: true, compound: false, parameterised: false, requires_currency: false, creation_only: false, allows_quantity_ledger: false, group: String::new(), } } fn grouped(name: &str, group: &str) -> ColumnType { ColumnType { group: group.to_string(), ..declarable(name) } } fn compound(name: &str) -> ColumnType { ColumnType { sql_type: String::new(), compound: true, creation_only: true, ..declarable(name) } } fn numeric(name: &str) -> ColumnType { ColumnType { sql_type: "NUMERIC".to_string(), allows_quantity_ledger: true, ..declarable(name) } } /// The catalog as `ListColumnTypes` reports it: alphabetical, and carrying /// the types a client may not declare as well as the ones it may. pub(crate) fn catalog() -> ColumnCatalog { ColumnCatalog::new(vec![ ColumnType { requires_currency: true, ..compound("accounting") }, compound("accounting_transfer"), ColumnType { sql_type: "TIMESTAMPTZ(0)".to_string(), ..grouped("instant", "temporal") }, numeric("bigint"), declarable("boolean"), declarable("credit_card"), ColumnType { sql_type: "DATE".to_string(), ..grouped("date", "temporal") }, ColumnType { parameterised: true, ..numeric("decimal") }, declarable("duration"), declarable("email_address"), grouped("gtin_8", "gtin"), grouped("gtin_12", "gtin"), grouped("gtin_13", "gtin"), grouped("gtin_14", "gtin"), declarable("iban"), ColumnType { declarable: false, ..declarable("iban_bban") }, numeric("int"), ColumnType { requires_currency: true, ..numeric("money") }, ColumnType { sql_type: "NUMERIC".to_string(), ..declarable("numeric") }, declarable("period"), declarable("phone"), ColumnType { declarable: false, sql_type: "INTEGER".to_string(), ..declarable("phone_calling_code") }, grouped("raw_datetime", "temporal"), declarable("text"), grouped("time", "temporal"), ]) } fn draft() -> ColumnDraft { ColumnDraft::new(catalog()) } /// The picker offers what the server says it accepts — including the types /// this crate never had a list of — with the families collapsed to one /// choice each and the companions left out. #[test] fn the_picker_is_the_servers_vocabulary() { let offered = draft().offered_types(); assert!(offered.contains(&"numeric".to_string())); assert!(offered.contains(&"accounting_transfer".to_string())); assert!(offered.contains(&"decimal".to_string())); // Families are one choice, resolved by a follow-up field. assert!(offered.contains(&"temporal".to_string())); assert!(offered.contains(&"gtin".to_string())); assert!(!offered.contains(&"gtin_13".to_string())); assert!(!offered.contains(&"date".to_string())); // Server-generated companions are never offered. assert!(!offered.contains(&"phone_calling_code".to_string())); assert!(!offered.contains(&"iban_bban".to_string())); // The common types lead, whatever order the endpoint returned. assert_eq!(offered[0], "text"); } /// Every creation-only type disappears from the append panel, which is now /// more than just ACCOUNTING. #[test] fn the_append_panel_offers_no_creation_only_type() { let appendable = ColumnDraft::for_append(catalog()).offered_types(); assert!(!appendable.contains(&"accounting".to_string())); assert!(!appendable.contains(&"accounting_transfer".to_string())); assert!(appendable.contains(&"money".to_string())); } #[test] fn temporal_gtin_and_decimal_pickers_resolve_to_canonical_types() { let mut draft = draft(); draft.name_input = "occurred_at".to_string(); draft.type_input = "temporal".to_string(); // Incomplete while no subtype is chosen. assert_eq!(draft.canonical_type_input().unwrap(), None); assert!(draft.show_temporal_type()); draft.temporal_type_input = "raw_datetime".to_string(); draft.add_from_inputs().unwrap(); assert_eq!(draft.added[0].data_type, "raw_datetime"); // Inputs are cleared for the next column. assert!(draft.temporal_type_input.is_empty()); draft.name_input = "barcode".to_string(); draft.type_input = "gtin".to_string(); draft.gtin_type_input = "13".to_string(); draft.add_from_inputs().unwrap(); assert_eq!(draft.added[1].data_type, "gtin_13"); draft.name_input = "weight".to_string(); draft.type_input = "decimal".to_string(); assert_eq!(draft.canonical_type_input().unwrap(), None); assert!(draft.show_decimal_arguments()); draft.decimal_precision_input = "12".to_string(); draft.decimal_scale_input = "3".to_string(); draft.add_from_inputs().unwrap(); assert_eq!(draft.added[2].data_type, "decimal(12,3)"); } /// The precision and scale rules are the server's, so a draft that would /// be refused there is refused here first. #[test] fn decimal_arguments_follow_the_servers_rules() { let mut draft = draft(); draft.name_input = "weight".to_string(); draft.type_input = "decimal".to_string(); for (precision, scale) in [("0", "0"), ("3", "5"), ("-2", "1"), ("08", "2"), ("4.5", "1")] { draft.decimal_precision_input = precision.to_string(); draft.decimal_scale_input = scale.to_string(); assert!( draft.add_from_inputs().is_err(), "decimal({precision},{scale}) should be refused" ); } draft.decimal_precision_input = "10".to_string(); draft.decimal_scale_input = "0".to_string(); assert!(draft.add_from_inputs().is_ok()); } /// `duration` and `period` are storable types on their own — the picker /// offers them and nothing has to be resolved. #[test] fn duration_and_period_are_columns_of_their_own() { for field_type in ["duration", "period"] { let mut draft = draft(); draft.name_input = "billing_span".to_string(); draft.type_input = field_type.to_string(); draft.add_from_inputs().unwrap(); assert_eq!(draft.added[0].data_type, field_type); } } #[test] fn an_append_panel_refuses_a_creation_only_column() { for field_type in ["accounting", "accounting_transfer"] { let mut draft = ColumnDraft::for_append(catalog()); draft.type_input = field_type.to_string(); assert!(draft.add_from_inputs().is_err()); // And again for a draft rebuilt from a posted form, which never // went through `add_from_inputs`. draft.added.push(ColumnDefinition { name: field_type.to_string(), data_type: field_type.to_string(), indexed: false, quantity_ledger: false, money_mode: MoneyMode::Exact, currency: String::new(), }); assert!(draft.validate().is_err()); } } #[test] fn invalid_identifiers_and_types_are_refused_at_add_time() { let mut draft = draft(); draft.type_input = "text".to_string(); draft.name_input = "Total".to_string(); assert!(draft.add_from_inputs().is_err()); draft.name_input = "created_at".to_string(); assert!(draft.add_from_inputs().is_err()); draft.name_input = "total".to_string(); draft.type_input = "timestamptz".to_string(); assert!(draft.add_from_inputs().is_err()); draft.type_input = "text".to_string(); assert!(draft.add_from_inputs().is_ok()); // Duplicates are refused too. draft.name_input = "total".to_string(); draft.type_input = "text".to_string(); assert!(draft.add_from_inputs().is_err()); } /// A column type the backend generates itself is not one a client may /// declare, even though the catalog describes it. #[test] fn a_generated_companion_type_cannot_be_declared() { let mut draft = draft(); draft.name_input = "country_code".to_string(); draft.type_input = "phone_calling_code".to_string(); assert!(draft.add_from_inputs().is_err()); assert!( catalog() .validate_field_type("phone_calling_code") .is_some() ); } #[test] fn quantity_ledger_follows_the_catalog() { let mut draft = draft(); draft.name_input = "note".to_string(); draft.type_input = "text".to_string(); draft.quantity_ledger_input = "yes".to_string(); assert!(draft.add_from_inputs().is_err()); draft.type_input = "int".to_string(); assert!(draft.add_from_inputs().is_ok()); assert!(draft.added[0].quantity_ledger); // A parameterised decimal counts, through its head. draft.name_input = "quantity".to_string(); draft.type_input = "decimal".to_string(); draft.decimal_precision_input = "12".to_string(); draft.decimal_scale_input = "3".to_string(); draft.quantity_ledger_input = "yes".to_string(); assert!(draft.add_from_inputs().is_ok()); // And the hint under the input names exactly that set. assert_eq!(draft.quantity_ledger_types(), "BIGINT, DECIMAL, INT, MONEY"); } /// A compound column is a definition row, not a column: it takes its /// type's name and there is nothing to index. #[test] fn a_compound_column_is_named_after_its_type_and_never_indexed() { for field_type in ["accounting", "accounting_transfer"] { let mut draft = draft(); draft.name_input = "whatever".to_string(); draft.type_input = field_type.to_string(); draft.indexing_input = "yes".to_string(); if field_type == "accounting" { draft.currency_input = "EUR".to_string(); } draft.add_from_inputs().unwrap(); assert_eq!(draft.added[0].name, field_type); assert!(!draft.added[0].indexed); assert!(!draft.is_indexable(0)); draft.toggle_indexed(0); assert!(!draft.added[0].indexed); } } #[test] fn money_columns_require_a_valid_currency() { let mut draft = draft(); draft.name_input = "total".to_string(); draft.type_input = "money".to_string(); draft.currency_input = "EU".to_string(); assert!(draft.add_from_inputs().is_err()); draft.currency_input = "eur".to_string(); draft.add_from_inputs().unwrap(); assert_eq!(draft.added[0].currency, "EUR"); } /// ACCOUNTING carries a currency and ACCOUNTING_TRANSFER does not, which /// is the catalog's answer rather than this crate's. #[test] fn currency_follows_the_catalog_rather_than_the_type_name() { let mut draft = draft(); draft.type_input = "accounting".to_string(); draft.currency_input = "czk".to_string(); draft.add_from_inputs().unwrap(); assert_eq!(draft.added[0].currency, "CZK"); assert_eq!(draft.added[0].option_label(), "CZK, exact"); draft.type_input = "accounting_transfer".to_string(); draft.currency_input = "czk".to_string(); draft.add_from_inputs().unwrap(); assert_eq!(draft.added[1].currency, ""); } /// `add_from_inputs` enforces this, but a draft rebuilt from a posted form /// skips that path, so `validate` has to enforce it too. #[test] fn a_rebuilt_draft_is_still_held_to_the_currency_rule() { let mut draft = draft(); draft.added.push(ColumnDefinition { name: "total".to_string(), data_type: "money".to_string(), indexed: false, quantity_ledger: false, money_mode: MoneyMode::Exact, currency: String::new(), }); assert!(draft.validate().is_err()); draft.added[0].currency = "XYZ".to_string(); assert!(draft.validate().is_err()); draft.added[0].currency = "EUR".to_string(); assert!(draft.validate().is_ok()); // Forbidden on everything else, exactly as the server has it. draft.added[0].data_type = "text".to_string(); assert!(draft.validate().is_err()); } /// And to the quantity-ledger rule, which the panel applies at add time. #[test] fn a_rebuilt_draft_is_still_held_to_the_quantity_ledger_rule() { let mut draft = draft(); draft.added.push(ColumnDefinition { name: "note".to_string(), data_type: "text".to_string(), indexed: false, quantity_ledger: true, money_mode: MoneyMode::Exact, currency: String::new(), }); assert!(draft.validate().is_err()); } /// With no catalog there is no vocabulary, so nothing is accepted rather /// than a guess being made at what the server takes. #[test] fn an_unloaded_catalog_refuses_every_type() { let mut draft = ColumnDraft::new(ColumnCatalog::default()); draft.name_input = "number".to_string(); draft.type_input = "text".to_string(); assert!(!draft.catalog.is_loaded()); assert!(draft.offered_types().is_empty()); assert!(draft.add_from_inputs().is_err()); } /// The catalog also explains the types `GetProfileDetails` reports back, /// including the companions a client may not declare. #[test] fn the_catalog_names_the_sql_type_behind_a_column() { let catalog = catalog(); assert_eq!(catalog.sql_type("instant"), "TIMESTAMPTZ(0)"); assert_eq!(catalog.sql_type("phone_calling_code"), "INTEGER"); assert_eq!(catalog.sql_type("decimal(12,3)"), "NUMERIC(12,3)"); // A compound type has no column, so it has no SQL type of its own. assert_eq!(catalog.sql_type("accounting"), ""); assert_eq!(catalog.sql_type("nonsense"), ""); } #[test] fn mismatched_column_vectors_never_mis_pair() { let columns = columns_from_rows( &["number".to_string(), "total".to_string()], &["text".to_string()], &["yes".to_string(), "no".to_string()], &["no".to_string(), "no".to_string()], &["exact".to_string(), "half-up".to_string()], &[String::new(), "EUR".to_string()], ); assert_eq!(columns.len(), 1); assert_eq!(columns[0].name, "number"); assert_eq!(columns[0].data_type, "text"); assert!(columns[0].indexed); } #[test] fn indexed_columns_become_the_index_list() { let mut draft = draft(); draft.name_input = "number".to_string(); draft.type_input = "text".to_string(); draft.add_from_inputs().unwrap(); draft.toggle_indexed(0); assert_eq!(draft.selected_index_names(), vec!["number"]); } } #[cfg(test)] mod link_alias_tests { use super::validate_identifier; /// The `_id` suffix is an ordinary part of a column's name: nothing is /// derived from a table name any more. #[test] fn a_column_name_may_end_in_id() { assert_eq!(validate_identifier("external_id", "Column name", true), None); } /// The system columns stay reserved, since they share one namespace with /// user columns in a data request. #[test] fn the_system_columns_stay_reserved() { for name in ["id", "deleted", "created_at", "row_revision"] { assert!( validate_identifier(name, "Column name", true).is_some(), "`{name}` must stay reserved" ); } } }