//! The Add-table draft and its rules. //! //! This is a port of the TUI client's `pages/add_table/data.rs` core mechanics //! with the terminal-specific parts removed (`ratatui` cursors, the canvas //! `DataProvider` projection, and the `tr!` i18n macro). Every rule below — //! canonicalisation, field visibility and validation — matches the client so //! the two frontends accept and reject exactly the same table definitions. //! //! What a *column* may be is not here: that is [`crate::schema`], which the //! append screen in `admin/table_definition` shares. This module is only what //! is true of a table being created — its profile, its name, its columns, and //! what identifies one of its rows. use crate::{ definitions::table_definition::{GeneratedColumnAlias, PostTableDefinitionRequest}, schema::{ColumnCatalog, ColumnDraft, proto_columns, validate_identifier}, }; /// The compound type that also brings a system column with it. Which columns /// it generates is the catalog's answer; the physical `account_id` is not one /// of them, and [`TableDraft::preview_rows`] is where that is explained. pub(crate) const ACCOUNTING_FIELD_TYPE: &str = "accounting"; /// The compound type whose connectors remain tied to their backend names. pub(crate) const ACCOUNTING_TRANSFER_FIELD_TYPE: &str = "accounting_transfer"; /// The virtual field the ACCOUNTING foreign key is written as. It is a column /// like any other once created, so it can be aliased too. pub(crate) const ACCOUNT_API_COLUMN: &str = "account"; /// An alias asked for one of the columns a definition row generates. /// /// A generated column cannot be named in the column list — the definition row /// is named after its own type, and what it expands into is the backend's to /// decide. The name is only ever a display name over a physical column, though, /// so the request carries the name to use and the column is created under it. #[derive(Clone, Debug, Default)] pub(crate) struct GeneratedAlias { /// The generated column's own name, which is what the rename asks for. pub source: String, /// What the user wants to see instead. Empty means "leave it alone". pub alias: String, } /// One row of the "Table definition preview" — the schema as it will exist. pub(crate) struct PreviewRow { pub mark: String, pub column: String, pub data_type: String, pub option: String, pub source: String, } #[derive(Clone, Debug)] pub(crate) struct RelationTableOption { pub name: String, pub global: bool, pub system: bool, } /// The whole Add-table page state, minus presentation. #[derive(Clone, Debug, Default)] pub(crate) struct TableDraft { /// Profile this table belongs to when an existing one was picked. pub profile_name: String, /// Profile name typed in when creating a new profile. pub profile_name_input: String, pub creating_new_profile: bool, pub global: bool, pub accounting_currency: String, pub table_name: String, /// The column panel: the pending column and the ones already described. pub columns: ColumnDraft, /// Tables in the target profile, offered as `link(...)` targets by the /// column picker. pub relation_tables: Vec, pub relation_table_options: Vec, /// Columns identifying a row to users, in the order they are shown. /// Empty means rows are identified by their id alone. pub row_display_columns: Vec, /// Tables already defined in the target profile — a new table may not /// reuse one of these names. pub existing_profile_tables: Vec, /// Names asked for the generated columns, sent with the request that /// creates them. pub generated_aliases: Vec, } impl TableDraft { /// A draft for a brand-new page load, matching the client's defaults. The /// column panel's vocabulary is filled in by the loader, which is the only /// thing that knows it. pub(crate) fn new() -> Self { Self { accounting_currency: "EUR".to_string(), columns: ColumnDraft::new(ColumnCatalog::default()), ..Self::default() } } // ---- field visibility (the same rules the TUI canvas applies) -------- pub(crate) fn show_profile_name_input(&self) -> bool { self.creating_new_profile && !self.global } pub(crate) fn show_accounting_currency(&self) -> bool { self.creating_new_profile && !self.global } // ---- mutations ------------------------------------------------------- /// Removes one column, and drops it from the display columns with it. pub(crate) fn remove_column(&mut self, index: usize) -> Result { let removed = self.columns.remove(index)?; self.row_display_columns .retain(|display| display != &removed.name); Ok(format!("Column `{}` removed.", removed.name)) } /// Moves one column one place up or down the list. /// /// The display columns keep their own order, which is the order they were /// chosen in rather than the order the columns are declared in, so nothing /// here touches them. pub(crate) fn move_column(&mut self, index: usize, offset: isize) -> Option { self.columns.move_column(index, offset) } /// Adds or removes one display-column candidate. /// /// Index 0 is `id`, which is not a display column of its own: choosing it /// clears the list, since an empty list already means "identified by id". /// Any other index toggles that column, appending so the order columns were /// chosen in is the order they are shown in. pub(crate) fn toggle_row_display_candidate(&mut self, index: usize) { if index == 0 { self.row_display_columns.clear(); return; } // A compound column leaves no column of its own name behind, so it can // never identify a row; `row_display_candidates` does not offer one. if !self.columns.is_indexable(index - 1) { return; } let Some(column) = self .columns .added .get(index - 1) .map(|column| column.name.clone()) else { return; }; match self .row_display_columns .iter() .position(|display| *display == column) { Some(position) => { self.row_display_columns.remove(position); } None => self.row_display_columns.push(column), } } /// Records the tables the target profile offers as link targets. A table /// cannot link to itself, so its own name is never among them. #[cfg(test)] pub(crate) fn set_available_relation_tables(&mut self, table_names: Vec) { self.set_available_relation_table_options( table_names .into_iter() .map(|name| RelationTableOption { name, global: false, system: false, }) .collect(), ); } pub(crate) fn set_available_relation_table_options( &mut self, options: Vec, ) { self.relation_table_options = options .into_iter() .filter(|option| option.name != self.table_name) .collect(); self.relation_tables = self .relation_table_options .iter() .map(|option| option.name.clone()) .collect(); } pub(crate) fn global_relation_tables(&self) -> Vec<&str> { self.relation_table_options .iter() .filter(|option| option.global) .map(|option| option.name.as_str()) .collect() } pub(crate) fn user_relation_tables(&self) -> Vec<&str> { self.relation_table_options .iter() .filter(|option| !option.global && !option.system) .map(|option| option.name.as_str()) .collect() } pub(crate) fn system_relation_tables(&self) -> Vec<&str> { self.relation_table_options .iter() .filter(|option| !option.global && option.system) .map(|option| option.name.as_str()) .collect() } // ---- generated-column aliases ---------------------------------------- /// The generated columns this draft would let the user rename, in the order /// they appear in the column list. /// /// Every generated column whose relationship is recorded independently of /// its display name. ACCOUNTING_TRANSFER's connectors are the exception: /// the backend still resolves those by their fixed names. pub(crate) fn aliasable_generated_columns(&self) -> Vec { let mut names = Vec::new(); for (index, column) in self.columns.added.iter().enumerate() { if column.data_type == ACCOUNTING_TRANSFER_FIELD_TYPE { continue; } let generated: Vec = self .columns .generated_columns_of(index) .iter() .map(|generated| generated.name.clone()) .collect(); // A backend that reports `account` with the rest of ACCOUNTING's // companions has already named it here; offering it a second time // for the foreign key would put two fields on one column, and only // the first of the two would be read. let account_reported = generated.iter().any(|name| name == ACCOUNT_API_COLUMN); names.extend(generated); if column.data_type == ACCOUNTING_FIELD_TYPE && !account_reported { names.push(ACCOUNT_API_COLUMN.to_string()); } } names } /// The alias typed for one generated column, empty when none was. pub(crate) fn alias_for(&self, source: &str) -> &str { self.generated_aliases .iter() .find(|entry| entry.source == source) .map(|entry| entry.alias.trim()) .unwrap_or_default() } /// The name a generated column will be known by: its alias when one was /// asked for, otherwise the name the backend gives it. pub(crate) fn generated_display_name(&self, source: &str) -> String { match self.alias_for(source) { "" => source.to_string(), alias => alias.to_string(), } } /// The aliases the request carries: only the generated columns still in the /// draft, and only where an alias was actually asked for. /// /// Sending an alias for a column the request does not generate is an error /// on the backend rather than a no-op, so a stale entry -- one left behind /// by a column since removed -- is dropped here. pub(crate) fn generated_alias_requests(&self) -> Vec { self.aliased_generated_columns() .into_iter() .map(|(generated_name, alias)| GeneratedColumnAlias { generated_name, alias, }) .collect() } pub(crate) fn aliased_generated_columns(&self) -> Vec<(String, String)> { self.aliasable_generated_columns() .into_iter() .filter_map(|source| { let alias = self.alias_for(&source); (!alias.is_empty() && alias != source).then(|| (source, alias.to_string())) }) .collect() } /// Every alias has to be a legal column name, and has to be free: the table /// is about to hold the declared columns, the generated ones and the system /// ones, and two columns cannot share a name. fn validate_generated_aliases(&self) -> Result<(), String> { let generated = self.aliasable_generated_columns(); let renames = self.aliased_generated_columns(); // What the table would hold with every alias applied. let mut taken: Vec = vec![ "id".to_string(), "deleted".to_string(), "created_at".to_string(), ]; taken.extend(self.columns.added.iter().map(|column| column.name.clone())); taken.extend( generated .iter() .map(|source| self.generated_display_name(source)), ); for (source, alias) in &renames { if let Some(error) = validate_identifier(alias, "Column alias", true) { return Err(error); } if taken.iter().filter(|name| *name == alias).count() > 1 { return Err(format!( "Alias `{alias}` for generated column `{source}` is already taken by another column." )); } } Ok(()) } // ---- derived state --------------------------------------------------- /// The profile name to validate and persist: the typed input while creating /// a new profile, otherwise the profile that was picked. pub(crate) fn effective_profile_name(&self) -> String { if self.creating_new_profile { self.profile_name_input.trim().to_string() } else { self.profile_name.clone() } } pub(crate) fn table_name_conflicts(&self) -> bool { !self.table_name.is_empty() && self .existing_profile_tables .iter() .any(|name| name == &self.table_name) } /// Position of `column` among the display columns, counting from 1. pub(crate) fn row_display_position(&self, column: &str) -> Option { self.row_display_columns .iter() .position(|display| display == column) .map(|index| index + 1) } /// The schema as it will exist: system columns, relation columns, then the /// user's own. Mirrors the client's preview pane. pub(crate) fn preview_rows(&self) -> Vec { let mut rows = vec![ PreviewRow { mark: if self.row_display_columns.is_empty() { "[x]".to_string() } else { "[ ]".to_string() }, column: "id".to_string(), data_type: "BIGSERIAL".to_string(), option: "primary key".to_string(), source: "system".to_string(), }, PreviewRow { mark: String::new(), column: "deleted".to_string(), data_type: "BOOLEAN".to_string(), option: "default false".to_string(), source: "system".to_string(), }, ]; for (index, column) in self.columns.added.iter().enumerate() { rows.push(PreviewRow { mark: self .row_display_position(&column.name) .map(|position| format!("[{position}]")) .unwrap_or_else(|| "[ ]".to_string()), column: column.name.clone(), data_type: column.data_type.clone(), option: column.option_label(), source: "user".to_string(), }); // A compound column is a definition row: the columns the table // really gets are the ones it expands into, created where the // definition row sits. They are listed here so what a compound // choice does is visible before the table exists. for generated in self.columns.generated_columns_of(index) { rows.push(PreviewRow { mark: String::new(), column: self.generated_display_name(&generated.name), data_type: generated.data_type.clone(), option: if generated.inherits_currency { format!("{}, {}", column.currency, column.money_mode.label()) } else { String::new() }, source: "generated".to_string(), }); } // The one companion the catalog does not report, because it is a // system column rather than a user one: ACCOUNTING's foreign key to // the profile's accounts, which the data API exposes as the virtual // `account` field. if column.data_type == ACCOUNTING_FIELD_TYPE { rows.push(PreviewRow { mark: String::new(), column: "account_id".to_string(), data_type: "BIGINT".to_string(), option: format!( "not null, → accounts, written as {}", self.generated_display_name(ACCOUNT_API_COLUMN) ), source: "system".to_string(), }); } } rows.push(PreviewRow { mark: String::new(), column: "created_at".to_string(), data_type: "TIMESTAMPTZ".to_string(), option: "current time".to_string(), source: "system".to_string(), }); rows } // ---- validation and submission --------------------------------------- /// Every check the client runs before it will save. pub(crate) fn validate(&self) -> Result<(), String> { let profile_name = self.effective_profile_name(); if !self.global && self.creating_new_profile && profile_name.is_empty() { return Err("Enter a name for the new profile.".to_string()); } if !self.global && let Some(error) = validate_identifier(&profile_name, "Profile name", false) { return Err(error); } if let Some(error) = validate_accounting_currency(self) { return Err(error); } if let Some(error) = validate_identifier(self.table_name.trim(), "Table name", true) { return Err(error); } if self.table_name_conflicts() { return Err(format!( "A table named `{}` already exists in profile `{}`.", self.table_name, profile_name )); } if self.columns.is_empty() { return Err("Add at least one column before saving.".to_string()); } self.validate_generated_aliases()?; self.columns.validate() } pub(crate) fn into_request(mut self) -> Result { self.table_name = self.table_name.trim().to_string(); self.validate()?; Ok(PostTableDefinitionRequest { table_name: self.table_name.clone(), profile_name: self.effective_profile_name(), columns: proto_columns(&self.columns.added), indexes: self.columns.selected_index_names(), accounting_currency: if self.creating_new_profile && !self.global { self.accounting_currency.trim().to_ascii_uppercase() } else { String::new() }, row_display_columns: self.row_display_columns.clone(), global: self.global, // The generated columns are named in the same request that creates // them, so the table is never briefly live under names the user did // not ask for. generated_aliases: self.generated_alias_requests(), }) } } pub(crate) fn validate_accounting_currency(draft: &TableDraft) -> Option { if !draft.creating_new_profile || draft.global { return None; } let currency = draft.accounting_currency.to_ascii_uppercase(); if rusty_money::iso::find(¤cy).is_none() { return Some("Accounting currency must be a three-letter ISO-4217 code".to_string()); } None } #[cfg(test)] mod tests { use super::*; use crate::schema::{ColumnDefinition, MoneyMode}; fn draft_with_column(name: &str, data_type: &str) -> TableDraft { let mut draft = TableDraft::new(); // The loader fills this in from the backend; a draft under test gets // the same vocabulary directly. draft.columns.catalog = crate::schema::tests::catalog(); draft.profile_name = "billing".to_string(); draft.table_name = "invoice".to_string(); draft.columns.added.push(ColumnDefinition { name: name.to_string(), data_type: data_type.to_string(), indexed: false, quantity_ledger: false, money_mode: MoneyMode::Exact, currency: if matches!(data_type, "money" | "accounting") { "EUR".to_string() } else { String::new() }, }); draft } #[test] fn new_profile_accounting_currency_must_exist_in_the_iso_registry() { let mut draft = draft_with_column("total", "int"); draft.creating_new_profile = true; draft.profile_name_input = "billing".to_string(); draft.accounting_currency = "AAA".to_string(); assert!(draft.validate().is_err()); draft.accounting_currency = "eur".to_string(); assert!(draft.validate().is_ok()); assert_eq!(draft.into_request().unwrap().accounting_currency, "EUR"); } /// Every column an ACCOUNTING row generates may be aliased, including the /// account foreign key the catalog does not report. #[test] fn accounting_generates_columns_that_can_all_be_aliased() { let draft = draft_with_column("accounting", "accounting"); assert_eq!( draft.aliasable_generated_columns(), ["name", "tax_point_date", "debit", "credit", "account"] ); } /// ACCOUNTING, PHONE and IBAN companions may be aliased. Transfer /// connectors remain fixed because the posting engine resolves their names. #[test] fn every_recorded_generated_column_is_aliasable() { let phone = draft_with_column("work_phone", "phone"); assert_eq!( phone.aliasable_generated_columns(), [ "work_phone_ext", "work_phone_type", "work_phone_country", "work_phone_calling_code", ] ); let iban = draft_with_column("bank_account", "iban"); assert_eq!( iban.aliasable_generated_columns(), [ "bank_account_country", "bank_account_bban", "bank_account_bank_identifier", "bank_account_branch_identifier", ] ); let transfer = draft_with_column("accounting_transfer", "accounting_transfer"); assert!(transfer.aliasable_generated_columns().is_empty()); } #[test] fn an_alias_is_sent_only_when_it_changes_the_name() { let mut draft = draft_with_column("accounting", "accounting"); draft.generated_aliases = vec![ GeneratedAlias { source: "debit".to_string(), alias: "md".to_string(), }, GeneratedAlias { source: "credit".to_string(), alias: "credit".to_string(), }, GeneratedAlias { source: "name".to_string(), alias: String::new(), }, // A stale pair for a column that is no longer in the draft. GeneratedAlias { source: "source_period_id".to_string(), alias: "start".to_string(), }, ]; assert_eq!( draft.aliased_generated_columns(), [("debit".to_string(), "md".to_string())] ); assert_eq!(draft.generated_display_name("debit"), "md"); assert_eq!(draft.generated_display_name("credit"), "credit"); assert!(draft.validate().is_ok()); assert!( draft .preview_rows() .iter() .any(|row| row.column == "md" && row.source == "generated") ); // The request that creates the columns is the request that names them. let request = draft.into_request().unwrap(); assert_eq!(request.generated_aliases.len(), 1); assert_eq!(request.generated_aliases[0].generated_name, "debit"); assert_eq!(request.generated_aliases[0].alias, "md"); } #[test] fn an_alias_must_be_a_legal_and_free_column_name() { let mut draft = draft_with_column("accounting", "accounting"); draft.columns.added.push(ColumnDefinition { name: "note".to_string(), data_type: "text".to_string(), indexed: false, quantity_ledger: false, money_mode: MoneyMode::Exact, currency: String::new(), }); draft.generated_aliases = vec![GeneratedAlias { source: "debit".to_string(), alias: "Md".to_string(), }]; assert!(draft.validate().is_err(), "an alias is a column name"); draft.generated_aliases[0].alias = "note".to_string(); assert!(draft.validate().is_err(), "a declared column holds the name"); draft.generated_aliases[0].alias = "credit".to_string(); assert!(draft.validate().is_err(), "another generated column does"); draft.generated_aliases[0].alias = "id".to_string(); assert!(draft.validate().is_err(), "a system column does"); draft.generated_aliases[0].alias = "md".to_string(); assert!(draft.validate().is_ok()); } #[test] fn existing_profile_sends_no_accounting_currency() { let draft = draft_with_column("total", "int"); let request = draft.into_request().unwrap(); assert_eq!(request.accounting_currency, ""); assert_eq!(request.profile_name, "billing"); } #[test] fn duplicate_table_names_in_the_profile_are_refused() { let mut draft = draft_with_column("total", "int"); draft.existing_profile_tables = vec!["invoice".to_string()]; assert!(draft.validate().is_err()); } #[test] fn reserved_profile_names_are_refused() { let mut draft = draft_with_column("total", "int"); draft.profile_name = "pg_catalog".to_string(); assert!(draft.validate().is_err()); } #[test] fn a_table_with_no_columns_is_refused() { let mut draft = draft_with_column("total", "int"); draft.remove_column(0).unwrap(); assert!(draft.validate().is_err()); } #[test] fn a_table_never_offers_itself_as_a_link_target() { let mut draft = TableDraft::new(); draft.table_name = "invoice".into(); draft.set_available_relation_tables(vec!["invoice".into(), "customer".into()]); assert_eq!(draft.relation_tables, vec!["customer".to_string()]); } #[test] fn row_display_columns_toggle_in_the_order_they_were_chosen() { let mut draft = draft_with_column("number", "text"); draft.columns.added.push(ColumnDefinition { name: "issued_on".to_string(), data_type: "date".to_string(), indexed: false, quantity_ledger: false, money_mode: MoneyMode::Exact, currency: String::new(), }); draft.toggle_row_display_candidate(2); // issued_on draft.toggle_row_display_candidate(1); // number assert_eq!(draft.row_display_columns, vec!["issued_on", "number"]); assert_eq!(draft.row_display_position("number"), Some(2)); // Index 0 is `id`: choosing it clears the list. draft.toggle_row_display_candidate(0); assert!(draft.row_display_columns.is_empty()); } /// A compound column expands into schema-managed companions, so there is /// no column of that name for a row to be identified by — the builder does /// not offer it, and a crafted post cannot choose it either. #[test] fn a_compound_column_never_identifies_a_row() { let mut draft = draft_with_column("number", "text"); draft.columns.added.push(ColumnDefinition { name: "accounting".to_string(), data_type: "accounting".to_string(), indexed: false, quantity_ledger: false, money_mode: MoneyMode::Exact, currency: "EUR".to_string(), }); draft.toggle_row_display_candidate(2); assert!(draft.row_display_columns.is_empty()); } #[test] fn removing_a_column_drops_it_from_the_display_columns() { let mut draft = draft_with_column("number", "text"); draft.toggle_row_display_candidate(1); assert_eq!(draft.row_display_columns, vec!["number"]); draft.remove_column(0).unwrap(); assert!(draft.row_display_columns.is_empty()); } #[test] fn the_preview_shows_system_and_user_columns() { let draft = draft_with_column("number", "text"); let rows = draft.preview_rows(); let columns = rows .iter() .map(|row| row.column.as_str()) .collect::>(); assert_eq!(columns, vec!["id", "deleted", "number", "created_at"]); // No display column chosen, so `id` identifies the row. assert_eq!(rows[0].mark, "[x]"); } /// Moving a column changes the order the table is declared in and nothing /// else — the display columns are shown in the order they were chosen in, /// which is not this order. #[test] fn moving_a_column_leaves_the_display_columns_alone() { let mut draft = draft_with_column("number", "text"); draft.columns.added.push(ColumnDefinition { name: "issued_on".to_string(), data_type: "date".to_string(), indexed: false, quantity_ledger: false, money_mode: MoneyMode::Exact, currency: String::new(), }); draft.toggle_row_display_candidate(1); // number draft.toggle_row_display_candidate(2); // issued_on draft.move_column(0, 1).unwrap(); assert_eq!( draft .columns .added .iter() .map(|column| column.name.as_str()) .collect::>(), ["issued_on", "number"] ); assert_eq!(draft.row_display_columns, vec!["number", "issued_on"]); } /// The point of the preview: a definition row is not one column, and the /// columns it stands for are the ones the table will really have. #[test] fn the_preview_expands_an_accounting_column() { let mut draft = draft_with_column("number", "text"); draft.columns.added.push(ColumnDefinition { name: "accounting".to_string(), data_type: "accounting".to_string(), indexed: false, quantity_ledger: false, money_mode: MoneyMode::Rounded, currency: "CZK".to_string(), }); let rows = draft.preview_rows(); let columns = rows .iter() .map(|row| row.column.as_str()) .collect::>(); assert_eq!( columns, vec![ "id", "deleted", "number", "accounting", "name", "tax_point_date", "debit", "credit", // The foreign key to the profile's accounts, once as the // column the backend reports and once as the physical column // it is stored in. "account", "account_id", "created_at", ] ); let row = |name: &str| { rows.iter() .find(|row| row.column == name) .unwrap_or_else(|| panic!("`{name}` is missing from the preview")) }; assert_eq!(row("name").source, "generated"); assert_eq!(row("tax_point_date").data_type, "date"); // DEBIT and CREDIT are kept in the definition row's own currency. assert_eq!(row("debit").option, "CZK, half-up"); assert_eq!(row("name").option, ""); assert_eq!(row("account_id").source, "system"); } #[test] fn indexed_columns_become_the_index_list() { let mut draft = draft_with_column("number", "text"); draft.columns.toggle_indexed(0); assert_eq!(draft.into_request().unwrap().indexes, vec!["number"]); } }