//! 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. /// The compound type that posts a row to the profile's books. pub(crate) const ACCOUNTING_FIELD_TYPE: &str = "accounting"; /// The compound type that moves a balance between two accounting periods. pub(crate) const ACCOUNTING_TRANSFER_FIELD_TYPE: &str = "accounting_transfer"; /// The types that only make sense inside one profile. /// /// Both post to a profile's books, and a global table belongs to every profile /// at once — there is no one set of books for it to post to, so the server /// refuses the pair outright rather than picking a profile for them. const PROFILE_ONLY_TYPES: [&str; 2] = [ACCOUNTING_FIELD_TYPE, ACCOUNTING_TRANSFER_FIELD_TYPE]; /// The profile's chart of accounts. A row reaches it through an ACCOUNTING /// definition row, never through a link declared by hand. pub(crate) const LEDGER_ACCOUNTS_TABLE: &str = "ledger_accounts"; 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", "link", ]; /// 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, /// The name takes the target table: `link(customer)`. pub link: 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, /// What a compound type expands into, in the order the server creates the /// columns. Empty for every other type. pub generated_columns: Vec, } /// One column a compound type expands into, as the backend describes it. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct GeneratedColumn { pub name: String, pub data_type: String, /// The column is kept in the currency and rounding declared on the /// definition row. pub inherits_currency: bool, } /// 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. /// /// `global` drops the two types that post to a profile's books, which a /// table shared by every profile has no single one of. pub(crate) fn offered_types(&self, creating_table: bool, global: 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; } if global && PROFILE_ONLY_TYPES.contains(&column_type.name.as_str()) { 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) } /// The columns a compound type expands into, so a screen can show what /// choosing it will really add. The names are the server's, not this /// crate's: it reports them with the type, and they are fixed. pub(crate) fn generated_columns(&self, field_type: &str) -> &[GeneratedColumn] { self.find(field_type) .map(|column_type| column_type.generated_columns.as_slice()) .unwrap_or_default() } 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) } fn is_link(&self, field_type: &str) -> bool { self.find(field_type) .is_some_and(|column_type| column_type.link) } /// 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(if link_argument(&field_type.to_lowercase()).is_some() { "link" } else { 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, locale: crate::i18n::Locale, 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(crate::tr!( locale, "schema-err-decimal-not-valid" )); } return validate_decimal_arguments(locale, precision, scale).err(); } if let Some(target) = link_argument(&field_type) { if !self.is_link("link") { return Some(crate::tr!(locale, "schema-err-link-not-valid")); } return validate_identifier(locale, target, "label-linked-table", true); } match self.find(&field_type) { // A parameterised type spelled bare is missing its arguments. Some(column_type) if column_type.parameterised => Some(crate::tr!( locale, "schema-err-decimal-args-needed", "type" => field_type.clone(), )), Some(column_type) if column_type.link => { Some(crate::tr!(locale, "schema-err-link-target-needed")) } Some(column_type) if column_type.declarable => None, Some(_) => Some(crate::tr!( locale, "schema-err-generated-type", "type" => field_type.clone(), )), None => Some(crate::tr!( locale, "schema-err-invalid-type", "type" => field_type.clone(), )), } } } /// 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(), ""), }) } fn link_argument(field_type: &str) -> Option<&str> { field_type .strip_prefix("link(") .and_then(|rest| rest.strip_suffix(')')) .map(str::trim) } /// The table a stored column type points at, when it is a link. /// /// A link is a foreign key, and the server builds an index for every one of /// them as it creates the table — so this is also the question "is this column /// already indexed", which is why it is asked in more than one place. pub(crate) fn link_target(data_type: &str) -> Option<&str> { link_argument(data_type.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", } } /// The display name shown in badges and the preview, in the request's /// language. `label` stays the wire value; this is what a reader sees. pub(crate) fn display_label(self, locale: &crate::i18n::Locale) -> String { match self { Self::Exact => crate::tr!(*locale, "td-money-exact"), Self::Rounded => crate::tr!(*locale, "td-money-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, /// Whether a row has to carry a value for it. The server records this with /// the column and refuses a row that leaves it out. pub required: bool, } impl ColumnDefinition { /// Whether this column is a link, and so already has an index of its own. pub(crate) fn is_link(&self) -> bool { link_target(&self.data_type).is_some() } /// Whether the table really gets an index on this column — which a link /// does whether or not anyone asked, because the server indexes every /// foreign key as it creates the table. pub(crate) fn is_indexed(&self) -> bool { self.indexed || self.is_link() } /// 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, locale: &crate::i18n::Locale) -> String { let mut options = Vec::new(); if self.required { options.push(crate::tr!(*locale, "column-flag-required")); } if self.is_indexed() { options.push(crate::tr!(*locale, "builder-option-indexed")); } if !self.currency.is_empty() { options.push(format!( "{}, {}", self.currency, self.money_mode.display_label(locale), )); } options.join(", ") } } /// 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 link_table_input: String, pub decimal_precision_input: String, pub decimal_scale_input: String, pub indexing_input: String, pub quantity_ledger_input: String, pub required_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, /// Whether the table these columns belong to is shared by every profile. /// A shared table has no books of its own, so the types that post to a /// profile's books are neither offered nor accepted on one. pub global: bool, /// The table these columns belong to, which a link among them may not /// point at. Set by the page: the builder knows the name being typed, and /// the append screen knows the table it is appending to. pub table_name: String, /// Set when the post that rebuilt `added` had ragged column vectors, so /// validation can say that rather than describe the truncated result. pub ragged: Option, } 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(), required_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, self.global) } 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) } pub(crate) fn show_link_target(&self) -> bool { self.catalog.is_link(&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() } /// A compound column is named after its type, so there is nothing to type /// in: the panel shows what it will generate instead of a name field. pub(crate) fn pending_is_compound(&self) -> bool { self.catalog.is_compound(&self.type_input) } /// Whether the pending column is one the user chooses an index for. /// /// Neither a definition row nor a link is: the first leaves no column of /// its own name behind, and the second is a foreign key, which the server /// indexes as it creates the table. Asking for an index on a link is not /// merely redundant — the server refuses the whole table for it, saying /// the link is indexed automatically. pub(crate) fn show_indexing(&self) -> bool { !self.pending_is_compound() && !self.show_link_target() } /// A link's index is the server's to make, and the panel says so where the /// choice would otherwise be. pub(crate) fn pending_is_auto_indexed(&self) -> bool { self.show_link_target() } /// Whether the pending column may keep a quantity ledger. A shared table /// has no profile whose ledger it would be kept in. pub(crate) fn show_quantity_ledger(&self) -> bool { !self.pending_is_compound() && !self.global } /// What the pending choice would add to the table. Empty unless the choice /// is a compound type. pub(crate) fn pending_generated_columns(&self) -> &[GeneratedColumn] { self.catalog.generated_columns(&self.type_input) } /// The name a compound definition row takes, which is its own type. pub(crate) fn pending_compound_name(&self) -> String { self.type_input.trim().to_ascii_lowercase() } /// 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, locale: crate::i18n::Locale) -> 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(locale, precision, scale)?; return Ok(Some(format!("{column_type}({precision},{scale})"))); } if self.catalog.is_link(&column_type) { let target = self.link_table_input.trim().to_ascii_lowercase(); return Ok((!target.is_empty()).then(|| format!("{column_type}({target})"))); } 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, locale: crate::i18n::Locale, ) -> Result { let Some(column_type) = self.canonical_type_input(locale)? else { return Err(crate::tr!( locale, "schema-err-both-name-type" )); }; if !self.creating_table && self.catalog.is_creation_only(&column_type) { return Err(crate::tr!( locale, "schema-err-creation-only", "type" => 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(crate::tr!( locale, "schema-err-both-name-type" )); } if let Some(error) = validate_identifier(locale, &column_name, "label-column-name", true) { return Err(error); } if let Some(error) = self.catalog.validate_field_type(locale, &column_type) { return Err(error); } if let Some(error) = self.profile_only_type_error(locale, &column_type) { return Err(error); } if let Some(error) = self.link_target_error(locale, &column_name, &column_type) { return Err(error); } // Against the names the table will really hold, not just the declared // ones: a definition row's companions are columns too, and the server // refuses a table where a declared name collides with one of them. if self.claimed_names().iter().any(|name| name == &column_name) { return Err(crate::tr!( locale, "schema-err-column-exists", "name" => column_name.clone(), )); } for generated in self.catalog.generated_columns(&column_type) { let generated = self.companion_name(&column_name, &generated.name); if self.claimed_names().iter().any(|name| name == &generated) { return Err(crate::tr!( locale, "schema-err-generated-exists", "type" => column_type.clone(), "generated" => generated, )); } } 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(crate::tr!( locale, "schema-err-ql-types", "types" => self.catalog.quantity_ledger_types(), )); } if let Some(error) = self.global_quantity_ledger_error(locale, quantity_ledger, &column_name) { return Err(error); } let has_currency = self.catalog.requires_currency(&column_type); let currency = if has_currency { normalize_currency_input(locale, &self.currency_input)? } else { String::new() }; self.added.push(ColumnDefinition { // A compound column is not a column, so there is nothing to index; // a link already has an index the server made, and asking for a // second one is what the server refuses the table for. indexed: !compound && link_target(&column_type).is_none() && self.indexing_input.trim().eq_ignore_ascii_case("yes"), name: column_name.clone(), data_type: column_type, quantity_ledger, required: !compound && self.required_input.trim().eq_ignore_ascii_case("yes"), money_mode: if has_currency { MoneyMode::from_input(&self.rounding_input) } else { MoneyMode::Exact }, currency, }); self.clear_inputs(); Ok(crate::tr!( locale, "schema-status-column-added", "name" => column_name, )) } fn clear_inputs(&mut self) { self.name_input.clear(); self.type_input.clear(); self.temporal_type_input.clear(); self.gtin_type_input.clear(); self.link_table_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.required_input = "no".to_string(); self.rounding_input = "none".to_string(); self.currency_input = "EUR".to_string(); } // ---- the rules a column is held to, wherever it came from ------------ /// Every column name the table will hold: the declared ones and everything /// the definition rows among them expand into. /// /// The server checks a name against this set rather than against the /// declared columns alone, so declaring `debit` beside an ACCOUNTING row is /// a conflict there. It has to be a conflict here too, or the builder /// accepts a table the server will refuse. pub(crate) fn claimed_names(&self) -> Vec { let mut names = Vec::new(); for (index, column) in self.added.iter().enumerate() { names.push(column.name.clone()); names.extend( self.generated_columns_of(index) .into_iter() .map(|generated| generated.name), ); } names } /// What a companion is called on a column of this name — the catalog /// reports it under the type's own prefix, and the column's name replaces /// that prefix. The same rule [`Self::generated_columns_of`] applies. fn companion_name(&self, column_name: &str, generated_name: &str) -> String { let column_type = self .added .iter() .find(|column| column.name == column_name) .map(|column| column.data_type.clone()); let prefix = match column_type { Some(data_type) => format!("{data_type}_"), None => format!("{}_", self.type_input.trim().to_ascii_lowercase()), }; match generated_name.strip_prefix(&prefix) { Some(suffix) => format!("{column_name}_{suffix}"), None => generated_name.to_string(), } } /// The books a definition row posts to belong to one profile, and a shared /// table belongs to all of them. fn profile_only_type_error( &self, locale: crate::i18n::Locale, column_type: &str, ) -> Option { (self.global && PROFILE_ONLY_TYPES.contains(&column_type)).then(|| { crate::tr!( locale, "schema-err-profile-only-type", "type" => column_type.to_uppercase(), ) }) } fn global_quantity_ledger_error( &self, locale: crate::i18n::Locale, quantity_ledger: bool, name: &str, ) -> Option { (self.global && quantity_ledger).then(|| { crate::tr!( locale, "schema-err-shared-ql", "name" => name.to_string(), ) }) } /// The two link targets the server refuses: the table being created, which /// does not exist yet, and the chart of accounts, which is reached through /// an ACCOUNTING row instead. /// /// `table_name` is empty on the append screen's panel, where the page /// filters its own table out of the picker and there is nothing to compare /// against here. fn link_target_error( &self, locale: crate::i18n::Locale, column_name: &str, column_type: &str, ) -> Option { let target = link_target(column_type)?; if target == LEDGER_ACCOUNTS_TABLE { return Some(crate::tr!(locale, "schema-err-account-link")); } (!self.table_name.is_empty() && target == self.table_name).then(|| { crate::tr!( locale, "schema-err-link-self", "column" => column_name.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, locale: crate::i18n::Locale, index: usize, ) -> Result { if index >= self.added.len() { return Err(crate::tr!(locale, "schema-err-column-gone")); } Ok(self.added.remove(index)) } /// Moves one column one place towards the front or the back of the list. /// /// The order is the order the columns are declared in, which is the order /// the table gets them in — including a compound column, whose generated /// companions are created where its definition row sits. Moving the /// definition row therefore moves the whole block it expands into. /// /// A move off either end is not an error: the buttons for it are not /// rendered, and a crafted post asking for one leaves the order alone. pub(crate) fn move_column( &mut self, locale: crate::i18n::Locale, index: usize, offset: isize, ) -> Option { let target = index.checked_add_signed(offset)?; if index >= self.added.len() || target >= self.added.len() { return None; } self.added.swap(index, target); Some(if offset < 0 { crate::tr!( locale, "schema-status-column-moved-up", "name" => self.added[target].name.clone(), ) } else { crate::tr!( locale, "schema-status-column-moved-down", "name" => self.added[target].name.clone(), ) }) } /// What an already-added column expands into — empty unless it is a /// compound one. This is what the column list shows underneath it, so the /// columns a definition row brings are visible while the table is still /// being described. pub(crate) fn generated_columns_of(&self, index: usize) -> Vec { let Some(column) = self.added.get(index) else { return Vec::new(); }; let generated = self.catalog.generated_columns(&column.data_type); let default_prefix = format!("{}_", column.data_type); generated .iter() .cloned() .map(|mut companion| { if let Some(suffix) = companion.name.strip_prefix(&default_prefix) { companion.name = format!("{}_{}", column.name, suffix); } companion }) .collect() } /// Whether a column is one the user chooses an index for. /// /// A compound column leaves no column of its own name behind, and a link /// already has one: the server builds an index for every foreign key and /// refuses a definition that asks for a second. pub(crate) fn is_indexable(&self, index: usize) -> bool { self.added.get(index).is_some_and(|column| { !self.catalog.is_compound(&column.data_type) && !column.is_link() }) } /// Whether a column can identify a row. A compound column leaves no column /// of its own name behind, so it cannot; a link can. pub(crate) fn can_identify_row(&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; } } /// The indexes the request asks for. /// /// A link is never among them however the draft was built: the server /// makes that index itself, and naming it here is refused rather than /// ignored. pub(crate) fn selected_index_names(&self) -> Vec { self.added .iter() .enumerate() .filter(|(index, column)| column.indexed && self.is_indexable(*index)) .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, locale: crate::i18n::Locale) -> Result<(), String> { // Before anything about the columns themselves: if the post was // ragged, `added` is not what was sent, and every message below would // be describing a list the caller never posted. if let Some(ragged) = self.ragged { return Err(ragged.message(locale)); } let claimed = self.claimed_names(); for column in &self.added { if let Some(error) = validate_identifier(locale, &column.name, "label-column-name", true) { return Err(error); } if let Some(error) = self.catalog.validate_field_type(locale, &column.data_type) { return Err(crate::tr!( locale, "schema-err-column-prefix", "name" => column.name.clone(), "error" => error, )); } if let Some(error) = self.profile_only_type_error(locale, &column.data_type) { return Err(error); } if let Some(error) = self.link_target_error(locale, &column.name, &column.data_type) { return Err(error); } if let Some(error) = self.global_quantity_ledger_error(locale, column.quantity_ledger, &column.name) { return Err(error); } if column.indexed && column.is_link() { return Err(crate::tr!( locale, "schema-err-link-indexed", "name" => column.name.clone(), )); } if column.indexed && self.catalog.is_compound(&column.data_type) { return Err(crate::tr!( locale, "schema-err-definition-indexed", "name" => column.name.clone(), )); } if !self.creating_table && self.catalog.is_creation_only(&column.data_type) { return Err(crate::tr!( locale, "schema-err-creation-only", "type" => column.data_type.to_uppercase(), )); } if column.quantity_ledger && !self.catalog.allows_quantity_ledger(&column.data_type) { return Err(crate::tr!( locale, "schema-err-column-ql-types", "name" => column.name.clone(), "types" => 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(locale, &column.currency) { return Err(crate::tr!( locale, "schema-err-column-prefix", "name" => column.name.clone(), "error" => error, )); } } else if !column.currency.trim().is_empty() { return Err(crate::tr!( locale, "schema-err-currency-only", "name" => column.name.clone(), )); } } // Every name the table will hold has to be its own, counting what the // definition rows generate — which is the set the server checks. for (position, name) in claimed.iter().enumerate() { if claimed[position + 1..].contains(name) { return Err(crate::tr!( locale, "schema-err-name-twice", "name" => name.clone(), )); } } Ok(()) } } pub(crate) fn normalize_currency_input( locale: crate::i18n::Locale, value: &str, ) -> Result { let currency = value.trim().to_ascii_uppercase(); if rusty_money::iso::find(¤cy).is_none() { return Err(crate::tr!(locale, "schema-err-currency-code")); } Ok(currency) } /// PostgreSQL identifier rules, plus the names this schema reserves. pub(crate) fn validate_identifier( locale: crate::i18n::Locale, value: &str, label_key: &str, reject_table_reserved: bool, ) -> Option { let label = crate::tr!(locale, label_key); if value.is_empty() { return Some(crate::tr!( locale, "error-identifier-empty", "label" => label, )); } if value != value.trim() { return Some(crate::tr!( locale, "error-identifier-whitespace", "label" => label, )); } if value.starts_with('_') { return Some(crate::tr!( locale, "error-identifier-underscore", "label" => label, )); } if value.chars().next().is_some_and(|c| c.is_ascii_digit()) { return Some(crate::tr!( locale, "error-identifier-number", "label" => label, )); } if value.len() > MAX_IDENTIFIER_LENGTH { return Some(crate::tr!( locale, "error-identifier-too-long", "label" => label, "limit" => MAX_IDENTIFIER_LENGTH as i64, )); } if value .chars() .any(|c| !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_') { return Some(crate::tr!( locale, "error-identifier-charset", "label" => label, )); } // 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(crate::tr!( locale, "error-identifier-reserved", "label" => label, )); } if !reject_table_reserved && (value == "public" || value == "information_schema" || value.starts_with("pg_")) { return Some(crate::tr!(locale, "schema-err-profile-reserved")); } None } /// Postgres's `NAMEDATALEN - 1`: a longer identifier is truncated, not refused. const MAX_IDENTIFIER_LENGTH: usize = 63; /// How long a table name may be. /// /// Shorter than the 63 an identifier gets, because a table name is not only an /// identifier: the server names every index on the table after it, and /// `idx___fk` has to fit in 63 as well. What is subtracted is /// that wrapping plus the longest physical column name there can be. /// /// This is the server's own arithmetic — `catalog::object_naming` — down to the /// term it reads out of `common`, so adding a system column there moves the /// limit here too instead of leaving this behind at a number that was once /// right. pub(crate) const MAX_TABLE_NAME_LENGTH: usize = MAX_IDENTIFIER_LENGTH - "idx_".len() - "_".len() - "_fk".len() - crate::system_column::LONGEST_SYSTEM_COLUMN_NAME; /// The tables the server provisions for a profile itself. A profile gets these /// when it is created, so a new table may not claim one of their names. /// /// Written down here because the backend does not report them: they are /// constants of three server modules rather than anything the catalog carries. /// A name the server adds to that set is a name this list will not know about /// until it is added here as well. pub(crate) const RESERVED_TABLE_NAMES: [&str; 5] = [ "general_ledger", "journal_lines", "quantity_ledger", LEDGER_ACCOUNTS_TABLE, "custom_exchange_rates", ]; /// The identifier rules, plus the two a table name alone is held to. pub(crate) fn validate_table_name( locale: crate::i18n::Locale, value: &str, ) -> Option { if let Some(error) = validate_identifier(locale, value, "label-table-name", true) { return Some(error); } if value.len() > MAX_TABLE_NAME_LENGTH { // The limit is arithmetic, not a literal: it moves when a system column // is added. The message reads it rather than restating it. return Some(crate::tr!( locale, "schema-err-table-name-too-long", "limit" => MAX_TABLE_NAME_LENGTH as i64, )); } if RESERVED_TABLE_NAMES.contains(&value) { return Some(crate::tr!( locale, "schema-err-table-name-reserved", "name" => value.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( locale: crate::i18n::Locale, precision: &str, scale: &str, ) -> Result<(), String> { let precision = validate_decimal_number(locale, "label-precision", precision)?; let scale = validate_decimal_number(locale, "label-scale", scale)?; if precision < 1 { return Err(crate::tr!(locale, "error-precision-min")); } if scale > precision { return Err(crate::tr!(locale, "error-scale-gt-precision")); } Ok(()) } fn validate_decimal_number( locale: crate::i18n::Locale, label_key: &str, value: &str, ) -> Result { let label = crate::tr!(locale, label_key); if value.is_empty() { return Err(crate::tr!( locale, "error-decimal-required", "label" => label, )); } if value.starts_with('+') || value.starts_with('-') { return Err(crate::tr!( locale, "error-decimal-sign", "label" => label, )); } if value.contains('.') { return Err(crate::tr!( locale, "error-decimal-whole", "label" => label, )); } if value.len() > 1 && value.starts_with('0') { return Err(crate::tr!( locale, "error-decimal-leading-zeros", "label" => label, )); } value.parse::().map_err(|_| { crate::tr!( locale, "error-decimal-whole", "label" => label, ) }) } /// 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(), required: column.required, }) .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, link: column_type.spelling() == ColumnTypeSpelling::Link, 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, generated_columns: column_type .generated_columns .into_iter() .map(|generated| GeneratedColumn { name: generated.name, data_type: generated.field_type, inherits_currency: generated.inherits_currency, }) .collect(), }) .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 link_table_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_required_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_required: 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 { let (added, ragged) = columns_from_rows( &self.column_names, &self.column_types, &self.column_indexed, &self.column_quantity_ledger, &self.column_required, &self.column_rounding, &self.column_currencies, ); 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(), link_table_input: self.link_table_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(), required_input: self.column_required_input.clone(), rounding_input: self.column_rounding_input.clone(), currency_input: self.column_currency_input.clone(), added, ragged, catalog, creating_table, // Filled in by the page, which is what knows the table these // columns are for: the panel's own form carries neither. global: false, table_name: String::new(), } } } fn is_yes(value: &str) -> bool { value.trim().eq_ignore_ascii_case("yes") } /// A post whose parallel column vectors were not all the same length: `kept` /// columns could be rebuilt, while the longest vector carried `posted`. /// /// The page itself always writes all seven values per staged column, so this /// only happens to something posting the form by hand — and it used to be /// invisible, surfacing as "Add at least one column before saving" when the /// short vector was empty, or as silently dropped columns when it was not. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct RaggedColumns { pub kept: usize, pub posted: usize, } impl RaggedColumns { pub(crate) fn message(self, locale: crate::i18n::Locale) -> String { crate::tr!( locale, "draft-err-ragged-columns", "posted" => self.posted as i64, "kept" => self.kept as i64, ) } } /// 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. The mismatch is reported alongside, so the draft can say /// so instead of quietly holding fewer columns than were sent. pub(crate) fn columns_from_rows( names: &[String], types: &[String], indexed: &[String], quantity_ledger: &[String], required: &[String], rounding: &[String], currencies: &[String], ) -> (Vec, Option) { let lengths = [ names.len(), types.len(), indexed.len(), quantity_ledger.len(), required.len(), rounding.len(), currencies.len(), ]; let count = lengths.into_iter().min().unwrap_or(0); let longest = lengths.into_iter().max().unwrap_or(0); let ragged = (longest > count).then_some(RaggedColumns { kept: count, posted: longest, }); let columns = (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]), required: is_yes(&required[index]), money_mode: MoneyMode::from_input(&rounding[index]), currency: currencies[index].clone(), }) .collect(); (columns, ragged) } #[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, link: false, requires_currency: false, creation_only: false, allows_quantity_ledger: false, group: String::new(), generated_columns: Vec::new(), } } fn generated_column(name: &str, data_type: &str, inherits_currency: bool) -> GeneratedColumn { GeneratedColumn { name: name.to_string(), data_type: data_type.to_string(), inherits_currency, } } 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, // The companions the server reports with the type, in the // order it creates them. generated_columns: vec![ generated_column("name", "text", false), generated_column("tax_point_date", "date", false), generated_column("debit", "money", true), generated_column("credit", "money", true), // The foreign key to the profile's accounts, which the // backend reports with the rest of them. generated_column("account", "link(ledger_accounts)", false), ], ..compound("accounting") }, ColumnType { generated_columns: vec![ generated_column("source_period_id", "bigint", false), generated_column("target_period_id", "bigint", false), ], ..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"), ColumnType { generated_columns: vec![ generated_column("iban_country", "iban_country", false), generated_column("iban_bban", "iban_bban", false), generated_column( "iban_bank_identifier", "iban_bank_identifier", false, ), generated_column( "iban_branch_identifier", "iban_branch_identifier", false, ), ], ..declarable("iban") }, ColumnType { declarable: false, ..declarable("iban_bban") }, numeric("int"), ColumnType { link: true, sql_type: "BIGINT".to_string(), ..declarable("link") }, ColumnType { requires_currency: true, ..numeric("money") }, ColumnType { sql_type: "NUMERIC".to_string(), ..declarable("numeric") }, declarable("period"), ColumnType { generated_columns: vec![ generated_column("phone_ext", "phone_extension", false), generated_column("phone_type", "phone_type", false), generated_column("phone_country", "phone_country", false), generated_column( "phone_calling_code", "phone_calling_code", false, ), ], ..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(crate::i18n::Locale::default()).unwrap(), None); assert!(draft.show_temporal_type()); draft.temporal_type_input = "raw_datetime".to_string(); draft.add_from_inputs(crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).unwrap(); assert_eq!(draft.added[2].data_type, "decimal(12,3)"); } #[test] fn link_picker_combines_the_alias_with_its_target_table() { let mut draft = draft(); draft.name_input = "billing_customer".to_string(); draft.type_input = "link".to_string(); assert!(draft.show_link_target()); assert_eq!(draft.canonical_type_input(crate::i18n::Locale::default()).unwrap(), None); draft.link_table_input = "customer".to_string(); draft.add_from_inputs(crate::i18n::Locale::default()).unwrap(); assert_eq!(draft.added[0].name, "billing_customer"); assert_eq!(draft.added[0].data_type, "link(customer)"); assert_eq!(draft.catalog.sql_type("link(customer)"), "BIGINT"); } #[test] fn bare_link_type_is_rejected_without_a_target() { assert_eq!( draft().catalog.validate_field_type(crate::i18n::Locale::default(), "link"), Some("`link` needs a referenced table.".to_string()) ); } /// 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(crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).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, required: false, money_mode: MoneyMode::Exact, currency: String::new(), }); assert!(draft.validate(crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).is_err()); draft.name_input = "created_at".to_string(); assert!(draft.add_from_inputs(crate::i18n::Locale::default()).is_err()); draft.name_input = "total".to_string(); draft.type_input = "timestamptz".to_string(); assert!(draft.add_from_inputs(crate::i18n::Locale::default()).is_err()); draft.type_input = "text".to_string(); assert!(draft.add_from_inputs(crate::i18n::Locale::default()).is_ok()); // Duplicates are refused too. draft.name_input = "total".to_string(); draft.type_input = "text".to_string(); assert!(draft.add_from_inputs(crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).is_err()); assert!( catalog() .validate_field_type(crate::i18n::Locale::default(), "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(crate::i18n::Locale::default()).is_err()); draft.type_input = "int".to_string(); assert!(draft.add_from_inputs(crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).is_err()); draft.currency_input = "eur".to_string(); draft.add_from_inputs(crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).unwrap(); assert_eq!(draft.added[0].currency, "CZK"); assert_eq!(draft.added[0].option_label(&crate::i18n::Locale::default()), "CZK, exact"); draft.type_input = "accounting_transfer".to_string(); draft.currency_input = "czk".to_string(); draft.add_from_inputs(crate::i18n::Locale::default()).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, required: false, money_mode: MoneyMode::Exact, currency: String::new(), }); assert!(draft.validate(crate::i18n::Locale::default()).is_err()); draft.added[0].currency = "XYZ".to_string(); assert!(draft.validate(crate::i18n::Locale::default()).is_err()); draft.added[0].currency = "EUR".to_string(); assert!(draft.validate(crate::i18n::Locale::default()).is_ok()); // Forbidden on everything else, exactly as the server has it. draft.added[0].data_type = "text".to_string(); assert!(draft.validate(crate::i18n::Locale::default()).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, required: false, money_mode: MoneyMode::Exact, currency: String::new(), }); assert!(draft.validate(crate::i18n::Locale::default()).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(crate::i18n::Locale::default()).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, ragged) = columns_from_rows( &["number".to_string(), "total".to_string()], &["text".to_string()], &["yes".to_string(), "no".to_string()], &["no".to_string(), "no".to_string()], &["no".to_string(), "yes".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); // And the truncation is reported rather than passed off as the draft // the caller posted: two columns were sent, one survived the zip. assert_eq!(ragged, Some(RaggedColumns { kept: 1, posted: 2 })); } /// The message a ragged post gets. It used to be "Add at least one column /// before saving", which describes the *result* of dropping the caller's /// columns instead of the mismatch that dropped them. #[test] fn a_ragged_post_says_how_many_columns_went_missing() { let (columns, ragged) = columns_from_rows( &["number".to_string(), "total".to_string()], &[], &[], &[], &[], &[], &[], ); assert!(columns.is_empty()); let ragged = ragged.expect("two names and no other vector is ragged"); let message = ragged.message(crate::i18n::Locale::default()); assert!(message.contains('2'), "{message}"); assert!(!message.contains("at least one"), "{message}"); } /// The declared order is the order the table gets its columns in, so it is /// the user's to change — and a move off either end changes nothing rather /// than wrapping around or panicking. #[test] fn a_column_moves_one_place_and_stops_at_the_ends() { let mut draft = draft(); for name in ["number", "issued_on", "total"] { draft.name_input = name.to_string(); draft.type_input = "text".to_string(); draft.add_from_inputs(crate::i18n::Locale::default()).unwrap(); } let names = |draft: &ColumnDraft| { draft .added .iter() .map(|column| column.name.clone()) .collect::>() }; assert_eq!( draft.move_column(crate::i18n::Locale::default(), 2, -1), Some("Column `total` moved up.".to_string()) ); assert_eq!(names(&draft), ["number", "total", "issued_on"]); draft.move_column(crate::i18n::Locale::default(), 0, 1).unwrap(); assert_eq!(names(&draft), ["total", "number", "issued_on"]); // Off either end, and past the end of the list entirely. assert_eq!(draft.move_column(crate::i18n::Locale::default(), 0, -1), None); assert_eq!(draft.move_column(crate::i18n::Locale::default(), 2, 1), None); assert_eq!(draft.move_column(crate::i18n::Locale::default(), 9, -1), None); assert_eq!(names(&draft), ["total", "number", "issued_on"]); } /// The columns a definition row expands into are the server's answer, /// carried by the catalog rather than written down here. #[test] fn a_compound_column_reports_what_it_expands_into() { let mut draft = draft(); draft.type_input = "accounting".to_string(); assert!(draft.pending_is_compound()); assert_eq!(draft.pending_compound_name(), "accounting"); assert_eq!( draft .pending_generated_columns() .iter() .map(|generated| generated.name.as_str()) .collect::>(), ["name", "tax_point_date", "debit", "credit", "account"] ); draft.add_from_inputs(crate::i18n::Locale::default()).unwrap(); assert_eq!(draft.generated_columns_of(0).len(), 5); // DEBIT and CREDIT are the two kept in the declared currency. assert_eq!( draft .generated_columns_of(0) .iter() .filter(|generated| generated.inherits_currency) .count(), 2 ); // An ordinary column expands into nothing, and its name is its own. draft.name_input = "number".to_string(); draft.type_input = "text".to_string(); assert!(!draft.pending_is_compound()); draft.add_from_inputs(crate::i18n::Locale::default()).unwrap(); assert!(draft.generated_columns_of(1).is_empty()); } #[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(crate::i18n::Locale::default()).unwrap(); draft.toggle_indexed(0); assert_eq!(draft.selected_index_names(), vec!["number"]); } /// A link is a foreign key, and the server indexes every one of them as it /// creates the table. Asking for one on top is not redundant — the server /// refuses the whole definition, saying the link is indexed automatically — /// so there is no choice to offer and no way to end up having made one. #[test] fn a_link_is_indexed_by_the_server_and_never_by_the_user() { let mut draft = draft(); draft.name_input = "billing_customer".to_string(); draft.type_input = "link".to_string(); draft.link_table_input = "customer".to_string(); draft.indexing_input = "yes".to_string(); // The panel does not offer the choice, and says why instead. assert!(!draft.show_indexing()); assert!(draft.pending_is_auto_indexed()); draft.add_from_inputs(crate::i18n::Locale::default()).unwrap(); // Asking anyway leaves the column unindexed, so nothing names it. assert!(!draft.added[0].indexed); assert!(!draft.is_indexable(0)); draft.toggle_indexed(0); assert!(!draft.added[0].indexed); assert!(draft.selected_index_names().is_empty()); // And it is still reported as indexed, because it is. assert!(draft.added[0].is_indexed()); assert!(draft.added[0].option_label(&crate::i18n::Locale::default()).contains("indexed")); // A draft rebuilt from a post that says otherwise is refused rather // than sent on to be refused by the server. draft.added[0].indexed = true; let error = draft.validate(crate::i18n::Locale::default()).unwrap_err(); assert!(error.contains("indexed automatically"), "{error}"); assert!(draft.selected_index_names().is_empty()); } /// A definition row is never indexed: it leaves no column of its own name /// behind. A crafted post that marks one is refused rather than silently /// dropped from the request, matching how a crafted link is refused. #[test] fn a_compound_column_marked_indexed_is_refused_not_dropped() { let mut draft = draft(); draft.type_input = "accounting".to_string(); draft.currency_input = "EUR".to_string(); draft.add_from_inputs(crate::i18n::Locale::default()).unwrap(); assert!(!draft.added[0].indexed); draft.added[0].indexed = true; let error = draft.validate(crate::i18n::Locale::default()).unwrap_err(); assert!(error.contains("cannot be indexed"), "{error}"); assert!(draft.selected_index_names().is_empty()); } /// An ordinary column is still the user's to index. #[test] fn every_other_column_still_chooses_its_own_index() { let mut draft = draft(); draft.name_input = "number".to_string(); draft.type_input = "text".to_string(); assert!(draft.show_indexing()); assert!(!draft.pending_is_auto_indexed()); draft.indexing_input = "yes".to_string(); draft.add_from_inputs(crate::i18n::Locale::default()).unwrap(); assert!(draft.is_indexable(0)); assert_eq!(draft.selected_index_names(), vec!["number"]); assert!(draft.validate(crate::i18n::Locale::default()).is_ok()); } /// Both types post to one profile's books, and a shared table belongs to /// every profile at once. The server refuses the pair outright, so they are /// not offered on a shared table. #[test] fn a_shared_table_is_offered_no_column_that_posts_to_a_profiles_books() { let mut draft = ColumnDraft::new(catalog()); draft.global = true; let offered = draft.offered_types(); assert!(!offered.contains(&"accounting".to_string())); assert!(!offered.contains(&"accounting_transfer".to_string())); assert!(offered.contains(&"money".to_string())); for field_type in ["accounting", "accounting_transfer"] { let mut draft = draft.clone(); draft.type_input = field_type.to_string(); let error = draft.add_from_inputs(crate::i18n::Locale::default()).unwrap_err(); assert!(error.contains("shared table"), "{error}"); } // And a draft rebuilt from a post that carries one anyway. draft.added.push(ColumnDefinition { name: "accounting".to_string(), data_type: "accounting".to_string(), indexed: false, quantity_ledger: false, required: false, money_mode: MoneyMode::Exact, currency: "EUR".to_string(), }); assert!(draft.validate(crate::i18n::Locale::default()).is_err()); } /// A quantity ledger is one profile's, for the same reason. #[test] fn a_shared_table_keeps_no_quantity_ledger() { let mut draft = ColumnDraft::new(catalog()); draft.global = true; assert!(!draft.show_quantity_ledger()); draft.name_input = "quantity".to_string(); draft.type_input = "int".to_string(); draft.quantity_ledger_input = "yes".to_string(); let error = draft.add_from_inputs(crate::i18n::Locale::default()).unwrap_err(); assert!(error.contains("quantity ledger"), "{error}"); draft.added.push(ColumnDefinition { name: "quantity".to_string(), data_type: "int".to_string(), indexed: false, quantity_ledger: true, required: false, money_mode: MoneyMode::Exact, currency: String::new(), }); assert!(draft.validate(crate::i18n::Locale::default()).is_err()); // The same column on a profile's own table is fine. draft.global = false; assert!(draft.show_quantity_ledger()); assert!(draft.validate(crate::i18n::Locale::default()).is_ok()); } /// The table does not exist yet, so it cannot be pointed at — the picker /// leaves it out, and the rule holds even when the name is typed after the /// link was added. #[test] fn a_link_cannot_point_at_the_table_being_created() { let mut draft = draft(); draft.table_name = "invoice".to_string(); draft.name_input = "parent".to_string(); draft.type_input = "link".to_string(); draft.link_table_input = "invoice".to_string(); let error = draft.add_from_inputs(crate::i18n::Locale::default()).unwrap_err(); assert!(error.contains("cannot point at the table"), "{error}"); // Added while the table had another name, then renamed to the target. draft.table_name = "order".to_string(); draft.add_from_inputs(crate::i18n::Locale::default()).unwrap(); assert!(draft.validate(crate::i18n::Locale::default()).is_ok()); draft.table_name = "invoice".to_string(); assert!(draft.validate(crate::i18n::Locale::default()).is_err()); } /// A row reaches the chart of accounts through an ACCOUNTING definition /// row. Declaring the link by hand is refused by the server, so it is /// refused here. #[test] fn the_chart_of_accounts_is_not_a_link_target() { let mut draft = draft(); draft.name_input = "posted_to".to_string(); draft.type_input = "link".to_string(); draft.link_table_input = LEDGER_ACCOUNTS_TABLE.to_string(); let error = draft.add_from_inputs(crate::i18n::Locale::default()).unwrap_err(); assert!(error.contains("built into ACCOUNTING"), "{error}"); } /// The names a definition row generates are the table's columns too, so a /// declared column may not take one — in either order. #[test] fn a_declared_column_cannot_take_a_generated_columns_name() { let mut draft = draft(); draft.type_input = "accounting".to_string(); draft.currency_input = "EUR".to_string(); draft.add_from_inputs(crate::i18n::Locale::default()).unwrap(); assert!(draft.claimed_names().contains(&"debit".to_string())); draft.name_input = "debit".to_string(); draft.type_input = "text".to_string(); assert!(draft.add_from_inputs(crate::i18n::Locale::default()).is_err(), "ACCOUNTING generates it"); // And the other way round: the declared column first, then the row // whose expansion would collide with it. let mut reversed = ColumnDraft::new(catalog()); reversed.name_input = "credit".to_string(); reversed.type_input = "text".to_string(); reversed.add_from_inputs(crate::i18n::Locale::default()).unwrap(); reversed.type_input = "accounting".to_string(); reversed.currency_input = "EUR".to_string(); let error = reversed.add_from_inputs(crate::i18n::Locale::default()).unwrap_err(); assert!(error.contains("credit"), "{error}"); // A companion named after its own column follows that column's name, // so two PHONE columns never collide. let mut phones = ColumnDraft::new(catalog()); for name in ["home_phone", "work_phone"] { phones.name_input = name.to_string(); phones.type_input = "phone".to_string(); phones.add_from_inputs(crate::i18n::Locale::default()).unwrap(); } assert!(phones.claimed_names().contains(&"work_phone_ext".to_string())); assert!(phones.validate(crate::i18n::Locale::default()).is_ok()); } /// `required` is a column property the server records and enforces on every /// row written, so it travels with the column like any other. #[test] fn a_column_can_be_required() { let mut draft = draft(); draft.name_input = "number".to_string(); draft.type_input = "text".to_string(); draft.required_input = "yes".to_string(); draft.add_from_inputs(crate::i18n::Locale::default()).unwrap(); assert!(draft.added[0].required); assert!(draft.added[0].option_label(&crate::i18n::Locale::default()).contains("required")); assert!(proto_columns(&draft.added)[0].required); // And the panel is cleared for the next column, which is not required // just because the last one was. assert_eq!(draft.required_input, "no"); } /// A table name is not simply an identifier: the server names every index /// on the table after it, so it has less room than a column does. #[test] fn a_table_name_is_shorter_than_a_column_name() { assert_eq!(MAX_TABLE_NAME_LENGTH, 38); let longest = "t".repeat(MAX_TABLE_NAME_LENGTH); assert_eq!(validate_table_name(crate::i18n::Locale::default(), &longest), None); assert_eq!(validate_identifier(crate::i18n::Locale::default(), &longest, "Column name", true), None); let too_long = "t".repeat(MAX_TABLE_NAME_LENGTH + 1); assert!(validate_table_name(crate::i18n::Locale::default(), &too_long).is_some()); // Still a perfectly good column name, which is why the two differ. assert_eq!(validate_identifier(crate::i18n::Locale::default(), &too_long, "Column name", true), None); } /// Every profile is given these tables when it is created, so a new table /// cannot be named after one of them. #[test] fn the_tables_every_profile_is_given_keep_their_names() { for name in RESERVED_TABLE_NAMES { let error = validate_table_name(crate::i18n::Locale::default(), name) .unwrap_or_else(|| panic!("`{name}` should be reserved")); assert!(error.contains(name), "{error}"); } assert_eq!(validate_table_name(crate::i18n::Locale::default(), "invoice"), None); } } #[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(crate::i18n::Locale::default(), "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(crate::i18n::Locale::default(), name, "Column name", true).is_some(), "`{name}` must stay reserved" ); } } }