From 5f4b0266248fa81f87bc9c4042821f9d59afe98f Mon Sep 17 00:00:00 2001 From: Priec Date: Thu, 6 Aug 2026 17:47:53 +0200 Subject: [PATCH] talbe definition web --- server | 2 +- web/CHANGELOG.md | 31 + web/src/pages/add_table/draft.rs | 36 +- web/src/pages/add_table/loader.rs | 25 +- web/src/pages/add_table/state.rs | 13 +- web/src/pages/add_table/ui.rs | 25 +- .../pages/admin/table_definition/loader.rs | 41 +- web/src/pages/admin/table_definition/logic.rs | 25 +- web/src/pages/admin/table_definition/state.rs | 11 +- web/src/pages/admin/table_definition/ui.rs | 35 +- web/src/schema/mod.rs | 859 +++++++++++++----- web/templates/pages/add_table/builder.html | 18 +- .../admin/table_definition/column_panel.html | 20 +- .../admin/table_definition/workspace.html | 2 +- 14 files changed, 870 insertions(+), 273 deletions(-) diff --git a/server b/server index cb9906ac..35e52da8 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit cb9906ac3e8a06b023ba8869ef502e81e90ea46c +Subproject commit 35e52da81595447db46dd4ee83d3ff9ba4770604 diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index 333ccdc1..ac265325 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -14,6 +14,37 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- **`TableDefinition.ListColumnTypes`** — called by the add-table and + table-definition loaders. The whole response is consumed: `name`, `group`, + `declarable`, `compound`, `spelling`, `requires_currency`, `creation_only`, + `allows_quantity_ledger` and `sql_type` are each read by a rule that used to + be hardcoded in `crate::schema`. The column vocabulary is now the backend's, + so a type it adds is offered by both screens without a change here. + +### Changed + +- **The column-type picker is the server's list** — it no longer carries its + own. Types the web crate never offered are now reachable: `numeric` and the + `ACCOUNTING_TRANSFER` compound column. Server-generated companion types + (`phone_country`, `iban_bban`, the transfer connectors) are listed by the + endpoint but never offered, and are refused if one is posted anyway. +- **Creation-only types are refused on the append panel by rule, not by name** — + `POST /admin/table-definition/columns` used to exclude `accounting` alone; + it now excludes every type the response marks `creation_only`, which is what + `AddTableColumns` rejects. +- **Compound columns are staged as the backend expands them** — a compound + column takes its type's name, cannot be indexed, and is not offered as a row + display column, since no column of that name survives the expansion. +- **Currency and quantity-ledger rules come from the response** — + `requires_currency` decides which types carry a currency (previously MONEY + and ACCOUNTING by name), and `allows_quantity_ledger` both validates the + choice and writes the hint under the input. +- **`GetProfileDetails` columns show the SQL type behind them** — the + workspace's column list renders `sql_type` from the catalog beside each + logical type, including for the companion columns the backend generates. + ## [v0.8.38] — 2026-08-05 The web crate wires up seven gRPC services and calls the following endpoints. diff --git a/web/src/pages/add_table/draft.rs b/web/src/pages/add_table/draft.rs index 0cc0999c..94b3ae2d 100644 --- a/web/src/pages/add_table/draft.rs +++ b/web/src/pages/add_table/draft.rs @@ -15,7 +15,7 @@ use crate::{ definitions::table_definition::{ PostTableDefinitionRequest, TableLink as ProtoTableLink, }, - schema::{ColumnDraft, proto_columns, validate_identifier}, + schema::{ColumnCatalog, ColumnDraft, proto_columns, validate_identifier}, }; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -102,11 +102,13 @@ pub(crate) struct TableDraft { } impl TableDraft { - /// A draft for a brand-new page load, matching the client's defaults. + /// 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(), + columns: ColumnDraft::new(ColumnCatalog::default()), ..Self::default() } } @@ -148,6 +150,11 @@ impl TableDraft { 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 @@ -354,6 +361,9 @@ mod tests { 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 { @@ -471,6 +481,26 @@ mod tests { 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"); diff --git a/web/src/pages/add_table/loader.rs b/web/src/pages/add_table/loader.rs index 9463ebc7..aeed21b6 100644 --- a/web/src/pages/add_table/loader.rs +++ b/web/src/pages/add_table/loader.rs @@ -7,12 +7,14 @@ use crate::{ use super::{draft::TableDraft, state::AddTablePageState}; -/// Loads everything the builder needs around the draft: the profiles that can -/// be picked, and — for whichever profile the table will belong to — the tables -/// that are link targets and the names the new table may not reuse. +/// Loads everything the builder needs around the draft: the column-type +/// vocabulary, the profiles that can be picked, and — for whichever profile the +/// table will belong to — the tables that are link targets and the names the +/// new table may not reuse. /// -/// The link targets and reserved names always come from the live profile tree, -/// never from the posted form, so they cannot be spoofed by a crafted request. +/// The vocabulary, link targets and reserved names always come from the live +/// backend, never from the posted form, so they cannot be spoofed by a crafted +/// request. pub(crate) async fn load_page( state: AppState, headers: &HeaderMap, @@ -36,6 +38,19 @@ pub(crate) async fn load_page( } let mut definitions = state.definitions; + // What a column may be is the backend's to say; the picker and every rule + // the draft applies are read from this. + draft.columns.catalog = crate::schema::column_catalog( + definitions + .list_column_types( + authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?, + ) + .await + .map_err(|error| LoadError::Backend(error.message().to_string()))? + .into_inner() + .column_types, + ); + let tree = definitions .get_profile_tree( authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?, diff --git a/web/src/pages/add_table/state.rs b/web/src/pages/add_table/state.rs index a44484d7..99a43692 100644 --- a/web/src/pages/add_table/state.rs +++ b/web/src/pages/add_table/state.rs @@ -11,7 +11,7 @@ //! by [`crate::schema::columns_from_rows`], so the two screens that describe //! columns cannot drift apart. -use crate::schema::{ColumnDraft, columns_from_rows}; +use crate::schema::{ColumnCatalog, ColumnDraft, columns_from_rows}; use super::draft::{LinkDefinition, LinkMode, TableDraft}; @@ -110,8 +110,12 @@ impl BuilderForm { &self.column_rounding, &self.column_currencies, ), - // The table is being created here, so ACCOUNTING is on the table. - accounting_allowed: true, + // Filled in by the loader from `ListColumnTypes`, never by the + // form: it is the vocabulary the draft is validated against. + catalog: ColumnCatalog::default(), + // The table is being created here, so the creation-only types are + // on the table. + creating_table: true, }; let link_count = self.link_tables.len().min(self.link_modes.len()); @@ -189,6 +193,9 @@ impl AddTablePageState { .added .iter() .enumerate() + // A compound column expands into schema-managed companions, so + // there is no column of that name for a row to be shown by. + .filter(|(index, _)| self.draft.columns.is_indexable(*index)) .map(|(index, column)| RowDisplayCandidate { index: index + 1, name: column.name.clone(), diff --git a/web/src/pages/add_table/ui.rs b/web/src/pages/add_table/ui.rs index e83c8554..2eed8373 100644 --- a/web/src/pages/add_table/ui.rs +++ b/web/src/pages/add_table/ui.rs @@ -1,7 +1,7 @@ use askama::Template; use crate::{ - schema::{CURRENCY_CODES, GTIN_TYPES, TEMPORAL_TYPES}, + schema::CURRENCY_CODES, ui::{Alert, Nav, render}, }; @@ -13,9 +13,9 @@ use super::state::AddTablePageState; struct AddTablePage<'a> { nav: Nav, page: &'a AddTablePageState, - column_types: &'static [&'static str], - temporal_types: &'static [&'static str], - gtin_types: &'static [&'static str], + column_types: Vec, + temporal_types: Vec, + gtin_types: Vec, currency_codes: &'static [&'static str], } @@ -25,18 +25,20 @@ struct AddTablePage<'a> { #[template(path = "pages/add_table/builder.html")] struct BuilderFragment<'a> { page: &'a AddTablePageState, - column_types: &'static [&'static str], - temporal_types: &'static [&'static str], - gtin_types: &'static [&'static str], + column_types: Vec, + temporal_types: Vec, + gtin_types: Vec, } pub(crate) fn render_page(page: &AddTablePageState) -> String { render(&AddTablePage { nav: page.nav.clone(), page, + // Every picker is the backend's own vocabulary, asked through the + // draft so it can only offer what the draft would accept. column_types: page.draft.columns.offered_types(), - temporal_types: TEMPORAL_TYPES, - gtin_types: GTIN_TYPES, + temporal_types: page.draft.columns.temporal_types(), + gtin_types: page.draft.columns.gtin_types(), currency_codes: CURRENCY_CODES, }) } @@ -45,8 +47,8 @@ pub(crate) fn render_builder(page: &AddTablePageState) -> String { render(&BuilderFragment { page, column_types: page.draft.columns.offered_types(), - temporal_types: TEMPORAL_TYPES, - gtin_types: GTIN_TYPES, + temporal_types: page.draft.columns.temporal_types(), + gtin_types: page.draft.columns.gtin_types(), }) } @@ -67,6 +69,7 @@ mod tests { fn page() -> AddTablePageState { let mut draft = TableDraft::new(); + draft.columns.catalog = crate::schema::tests::catalog(); draft.profile_name = "billing".to_string(); draft.table_name = "invoice".to_string(); draft.columns.added.push(ColumnDefinition { diff --git a/web/src/pages/admin/table_definition/loader.rs b/web/src/pages/admin/table_definition/loader.rs index b6640f09..f10da2f7 100644 --- a/web/src/pages/admin/table_definition/loader.rs +++ b/web/src/pages/admin/table_definition/loader.rs @@ -1,13 +1,15 @@ //! Reads everything the workspace shows. //! -//! Three calls, in this order: the profile tree names the profiles and their -//! tables, the profile details describe the selected table's columns and -//! scripts, and the rename history explains how those columns got their names. +//! Four calls, in this order: the column-type catalog is the vocabulary the +//! append panel offers, the profile tree names the profiles and their tables, +//! the profile details describe the selected table's columns and scripts, and +//! the rename history explains how those columns got their names. //! Nothing here trusts the posted selection — a profile or table that is gone //! is dropped from the selection rather than reported as an error, because the //! commonest way to get here with a stale one is having just deleted it. use axum::http::HeaderMap; +use tonic::transport::Channel; use crate::{ AppState, @@ -16,8 +18,10 @@ use crate::{ common::Empty, table_definition::{ GetColumnAliasRenameHistoryRequest, GetProfileDetailsRequest, MoneyRounding, + table_definition_client::TableDefinitionClient, }, }, + schema::{ColumnCatalog, column_catalog}, services::authenticated_request, }; @@ -26,6 +30,27 @@ use super::state::{ TableDetailView, TableSummary, }; +/// Reads the column-type vocabulary on its own. +/// +/// The panel's handlers stage and validate a column before the rest of the +/// workspace is read, and they cannot do either without the vocabulary, so +/// they fetch it with this and hand it to [`load_page`] on the draft. +pub(crate) async fn load_column_catalog( + definitions: &mut TableDefinitionClient, + headers: &HeaderMap, +) -> Result { + let request = + authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?; + let response = definitions + .list_column_types(request) + .await + .map_err(|error| match error.code() { + tonic::Code::Unauthenticated => LoadError::Unauthenticated, + _ => LoadError::Backend(error.message().to_string()), + })?; + Ok(column_catalog(response.into_inner().column_types)) +} + pub(crate) async fn load_page( state: AppState, headers: &HeaderMap, @@ -47,6 +72,15 @@ pub(crate) async fn load_page( } let mut definitions = state.definitions; + // A handler that had to stage or validate a column before it got here has + // already read the vocabulary; anything else reads it now. + if !inputs.columns.catalog.is_loaded() { + inputs.columns.catalog = load_column_catalog(&mut definitions, headers).await?; + } + // Also what the definition below is read through: the catalog describes + // the server-generated companion types as well as the declarable ones. + let catalog = inputs.columns.catalog.clone(); + let tree = definitions .get_profile_tree( authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?, @@ -119,6 +153,7 @@ pub(crate) async fn load_page( let behavior = table.column_behaviors.get(&column.name); DetailColumn { name: column.name.clone(), + sql_type: catalog.sql_type(&column.field_type), field_type: column.field_type.clone(), currency: column.currency.clone(), quantity_ledger: column.quantity_ledger, diff --git a/web/src/pages/admin/table_definition/logic.rs b/web/src/pages/admin/table_definition/logic.rs index 5703aafc..9281bace 100644 --- a/web/src/pages/admin/table_definition/logic.rs +++ b/web/src/pages/admin/table_definition/logic.rs @@ -27,7 +27,7 @@ use crate::{ }; use super::{ - loader::load_page, + loader::{self, load_page}, state::{ CopyForm, DeleteForm, GeneratedTableView, InvoiceTemplateForm, LoadError, PageInputs, RenameForm, Selection, @@ -73,8 +73,16 @@ pub(crate) async fn update_columns( return rejection; } + // Staging a column applies the vocabulary's rules, so it is read before + // the panel is rebuilt; `load_page` reuses what is read here. + let mut definitions = state.definitions.clone(); + let catalog = match loader::load_column_catalog(&mut definitions, &headers).await { + Ok(catalog) => catalog, + Err(error) => return load_error_response(error), + }; + let mut inputs = PageInputs::for_selection(selection); - inputs.columns = form.to_draft(false); + inputs.columns = form.to_draft(catalog, false); let index = form.index.unwrap_or(0); match form.action.as_str() { @@ -108,8 +116,16 @@ pub(crate) async fn add_columns( return rejection; } + // The draft is validated before the write, so the vocabulary it is held to + // is read first; `load_page` reuses what is read here. + let mut definitions = state.definitions.clone(); + let catalog = match loader::load_column_catalog(&mut definitions, &headers).await { + Ok(catalog) => catalog, + Err(error) => return load_error_response(error), + }; + let mut inputs = PageInputs::for_selection(selection); - inputs.columns = form.to_draft(false); + inputs.columns = form.to_draft(catalog.clone(), false); if !inputs.selection.has_table() { return refuse(state, headers, inputs, "Select a table first.".to_string()).await; @@ -139,7 +155,6 @@ pub(crate) async fn add_columns( return Redirect::to("/login").into_response(); }; - let mut definitions = state.definitions.clone(); match definitions.add_table_columns(request).await { Ok(response) if response.get_ref().success => { let added = inputs.columns.added.len(); @@ -150,7 +165,7 @@ pub(crate) async fn add_columns( inputs.selection.table )); // The columns are the table's now, so the panel starts empty. - inputs.columns = crate::schema::ColumnDraft::for_append(); + inputs.columns = crate::schema::ColumnDraft::for_append(catalog); respond(state, headers, inputs, StatusCode::OK).await } Ok(response) => { diff --git a/web/src/pages/admin/table_definition/state.rs b/web/src/pages/admin/table_definition/state.rs index 8b29f900..8e994504 100644 --- a/web/src/pages/admin/table_definition/state.rs +++ b/web/src/pages/admin/table_definition/state.rs @@ -5,7 +5,7 @@ //! write: the response is rebuilt from the live profile tree rather than from //! whatever the browser still had on screen. -use crate::schema::ColumnDraft; +use crate::schema::{ColumnCatalog, ColumnDraft}; /// The profile and table the workspace is pointed at. Arrives as a query /// string on the selector and on the column-panel endpoints, and as hidden @@ -82,6 +82,9 @@ impl TableDetailView { pub(crate) struct DetailColumn { pub name: String, pub field_type: String, + /// The PostgreSQL type the column is stored as, from the column-type + /// catalog. Empty for a type the catalog does not describe. + pub sql_type: String, pub currency: String, pub quantity_ledger: bool, pub rounded: bool, @@ -225,10 +228,12 @@ pub(crate) struct PageInputs { } impl PageInputs { + /// A page with an empty append panel. The panel's vocabulary is filled in + /// by the loader, which is the only thing that knows it. pub(crate) fn for_selection(selection: Selection) -> Self { Self { selection, - columns: ColumnDraft::for_append(), + columns: ColumnDraft::for_append(ColumnCatalog::default()), ..Self::default() } } @@ -315,6 +320,7 @@ mod tests { DetailColumn { name: "work_phone".to_string(), field_type: "phone".to_string(), + sql_type: "TEXT".to_string(), currency: String::new(), quantity_ledger: false, rounded: false, @@ -325,6 +331,7 @@ mod tests { DetailColumn { name: "work_phone_country".to_string(), field_type: "phone_country".to_string(), + sql_type: "TEXT".to_string(), currency: String::new(), quantity_ledger: false, rounded: false, diff --git a/web/src/pages/admin/table_definition/ui.rs b/web/src/pages/admin/table_definition/ui.rs index ad03d256..0a0a5aee 100644 --- a/web/src/pages/admin/table_definition/ui.rs +++ b/web/src/pages/admin/table_definition/ui.rs @@ -1,7 +1,7 @@ use askama::Template; use crate::{ - schema::{CURRENCY_CODES, GTIN_TYPES, TEMPORAL_TYPES}, + schema::CURRENCY_CODES, ui::{Alert, Nav, render}, }; @@ -13,9 +13,9 @@ use super::state::TableDefinitionPageState; struct TableDefinitionPage<'a> { nav: Nav, page: &'a TableDefinitionPageState, - column_types: &'static [&'static str], - temporal_types: &'static [&'static str], - gtin_types: &'static [&'static str], + column_types: Vec, + temporal_types: Vec, + gtin_types: Vec, currency_codes: &'static [&'static str], /// False, as on the workspace fragment: the page embeds both, and the /// outcome is reported once, at the top. @@ -28,9 +28,9 @@ struct TableDefinitionPage<'a> { #[template(path = "pages/admin/table_definition/workspace.html")] struct WorkspaceFragment<'a> { page: &'a TableDefinitionPageState, - column_types: &'static [&'static str], - temporal_types: &'static [&'static str], - gtin_types: &'static [&'static str], + column_types: Vec, + temporal_types: Vec, + gtin_types: Vec, /// False: the workspace shows the outcome of the last action itself, at /// the top, so the panel it embeds must not repeat it. standalone_column_panel: bool, @@ -41,9 +41,9 @@ struct WorkspaceFragment<'a> { #[template(path = "pages/admin/table_definition/column_panel.html")] struct ColumnPanelFragment<'a> { page: &'a TableDefinitionPageState, - column_types: &'static [&'static str], - temporal_types: &'static [&'static str], - gtin_types: &'static [&'static str], + column_types: Vec, + temporal_types: Vec, + gtin_types: Vec, /// True: this is the whole response, so a refused column has nowhere else /// to be reported. standalone_column_panel: bool, @@ -56,8 +56,8 @@ pub(crate) fn render_page(page: &TableDefinitionPageState) -> String { // Asking the draft, so the picker can only ever offer what the draft // would accept — the accounting rule is stated once. column_types: page.columns.offered_types(), - temporal_types: TEMPORAL_TYPES, - gtin_types: GTIN_TYPES, + temporal_types: page.columns.temporal_types(), + gtin_types: page.columns.gtin_types(), currency_codes: CURRENCY_CODES, standalone_column_panel: false, }) @@ -69,8 +69,8 @@ pub(crate) fn render_workspace(page: &TableDefinitionPageState) -> String { // Asking the draft, so the picker can only ever offer what the draft // would accept — the accounting rule is stated once. column_types: page.columns.offered_types(), - temporal_types: TEMPORAL_TYPES, - gtin_types: GTIN_TYPES, + temporal_types: page.columns.temporal_types(), + gtin_types: page.columns.gtin_types(), standalone_column_panel: false, }) } @@ -81,8 +81,8 @@ pub(crate) fn render_column_panel(page: &TableDefinitionPageState) -> String { // Asking the draft, so the picker can only ever offer what the draft // would accept — the accounting rule is stated once. column_types: page.columns.offered_types(), - temporal_types: TEMPORAL_TYPES, - gtin_types: GTIN_TYPES, + temporal_types: page.columns.temporal_types(), + gtin_types: page.columns.gtin_types(), standalone_column_panel: true, }) } @@ -131,6 +131,7 @@ mod tests { columns: vec![DetailColumn { name: "number".to_string(), field_type: "text".to_string(), + sql_type: "TEXT".to_string(), currency: String::new(), quantity_ledger: false, rounded: false, @@ -140,7 +141,7 @@ mod tests { }], }), history: Vec::new(), - columns: ColumnDraft::for_append(), + columns: ColumnDraft::for_append(crate::schema::tests::catalog()), rename: RenameForm::default(), copy: CopyForm::default(), invoice: InvoiceTemplateForm::default(), diff --git a/web/src/schema/mod.rs b/web/src/schema/mod.rs index ab3b9a5e..662491b9 100644 --- a/web/src/schema/mod.rs +++ b/web/src/schema/mod.rs @@ -6,98 +6,272 @@ //! 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, MoneyRounding, + ColumnDefinition as ProtoColumnDefinition, ColumnTypeSpelling, MoneyRounding, + list_column_types_response::ColumnType as ProtoColumnType, }; -/// Column types offered when a table is created. `temporal`, `gtin` and -/// `decimal` are pickers of their own: none is a storable type, each resolves -/// to a canonical type below once its follow-up fields are filled in. -pub(crate) const COLUMN_TYPES: &[&str] = &[ - "text", - "boolean", - "money", - "accounting", - "int", - "bigint", - "decimal", - "temporal", - "duration", - "period", - "phone", - "iban", - "email_address", - "credit_card", - "gtin", -]; - -/// The same list without `accounting`, which the server only accepts while the -/// table is being created — an accounting column brings schema-managed -/// companions with it, so it cannot be bolted on afterwards. -pub(crate) const APPENDABLE_COLUMN_TYPES: &[&str] = &[ - "text", - "boolean", - "money", - "int", - "bigint", - "decimal", - "temporal", - "duration", - "period", - "phone", - "iban", - "email_address", - "credit_card", - "gtin", -]; - -pub(crate) const TEMPORAL_TYPES: &[&str] = &["date", "time", "instant", "raw_datetime"]; -pub(crate) const GTIN_TYPES: &[&str] = &["8", "12", "13", "14"]; - -/// Every fixed type the server accepts. `decimal(p,s)` is not here because it -/// is parameterised; [`validate_field_type`] checks it separately. -const CANONICAL_TYPES: &[&str] = &[ - "text", - "boolean", - "date", - "time", - "instant", - "raw_datetime", - "duration", - "period", - "phone", - "iban", - "email_address", - "credit_card", - "gtin_8", - "gtin_12", - "gtin_13", - "gtin_14", - "money", - "accounting", - "int", - "bigint", -]; - -/// Whether a column of this type declares a currency. +/// The order the type picker offers the types it knows about in — the common +/// ones first, rather than the alphabetical order the endpoint returns. /// -/// Both MONEY and ACCOUNTING do, which is why this is a named predicate rather -/// than an inline comparison: 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 carries_currency(field_type: &str) -> bool { - field_type.eq_ignore_ascii_case("money") || field_type.eq_ignore_ascii_case("accounting") +/// Anything the server offers that is not named here still appears, after +/// these and in the server's own order, so a newly added type is never hidden +/// by this list being out of date. +const TYPE_DISPLAY_ORDER: &[&str] = &[ + "text", + "boolean", + "money", + "accounting", + "accounting_transfer", + "int", + "bigint", + "decimal", + "numeric", + "temporal", + "duration", + "period", + "phone", + "iban", + "email_address", + "credit_card", + "gtin", +]; + +/// One column type as the backend describes it. +/// +/// A local mirror of the endpoint's message: the rules below are written +/// against this rather than the generated type, which is what keeps them +/// proto-free. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ColumnType { + pub name: String, + /// The PostgreSQL type it maps to; empty for a compound type. + pub sql_type: String, + pub declarable: bool, + /// A definition row rather than a column: it expands into schema-managed + /// companions and leaves no column of its own name behind. + pub compound: bool, + /// The name takes a precision and a scale: `decimal(12,3)`. + pub parameterised: bool, + pub requires_currency: bool, + /// Only choosable while the table is being created. + pub creation_only: bool, + pub allows_quantity_ledger: bool, + /// Groups several types behind one choice in the picker; empty when the + /// type stands on its own. + pub group: String, } -/// Types a quantity-ledger column may use. -fn quantity_ledger_type_allowed(field_type: &str) -> bool { - matches!(field_type, "int" | "bigint" | "money") - || (field_type.starts_with("decimal(") && field_type.ends_with(')')) +/// Every column type the backend accepts, as one screen's picker reads it. +/// +/// Cheap to clone: the panel travels with every request, and each of them +/// carries the catalog it was rendered from. +#[derive(Clone, Debug, Default)] +pub(crate) struct ColumnCatalog { + types: Arc<[ColumnType]>, +} + +impl ColumnCatalog { + pub(crate) fn new(types: Vec) -> Self { + Self { + types: types.into(), + } + } + + /// Whether the catalog has been read from the backend yet. A handler that + /// has to validate a draft before it loads the rest of the page fetches + /// the catalog itself; the loader asks this so it does not fetch it twice. + pub(crate) fn is_loaded(&self) -> bool { + !self.types.is_empty() + } + + fn find(&self, name: &str) -> Option<&ColumnType> { + self.types + .iter() + .find(|column_type| column_type.name.eq_ignore_ascii_case(name.trim())) + } + + /// Whether `name` is a group the picker offers instead of the types in it — + /// `temporal` for the date and time types, `gtin` for the GTIN lengths. + fn is_group(&self, name: &str) -> bool { + let name = name.trim(); + !name.is_empty() + && self + .types + .iter() + .any(|column_type| column_type.group.eq_ignore_ascii_case(name)) + } + + /// The types the picker offers, groups collapsed to their group name. + /// + /// `creating_table` is false on the append screen, where the server refuses + /// the creation-only types: they bring schema-managed companion columns + /// that cannot be bolted onto a table that already exists. + pub(crate) fn offered_types(&self, creating_table: bool) -> Vec { + let mut offered = Vec::new(); + for column_type in self.types.iter() { + if !column_type.declarable || (column_type.creation_only && !creating_table) { + continue; + } + let offer = if column_type.group.is_empty() { + &column_type.name + } else { + &column_type.group + }; + if !offered.iter().any(|existing| existing == offer) { + offered.push(offer.clone()); + } + } + + offered.sort_by_key(|offer| { + TYPE_DISPLAY_ORDER + .iter() + .position(|known| known == offer) + .unwrap_or(TYPE_DISPLAY_ORDER.len()) + }); + offered + } + + /// The members of one group, as the follow-up picker offers them. A GTIN + /// length is offered as `13` rather than `gtin_13`, which is what + /// [`ColumnDraft::canonical_type_input`] puts back together. + pub(crate) fn group_members(&self, group: &str) -> Vec { + self.types + .iter() + .filter(|column_type| column_type.declarable && column_type.group == group) + .map(|column_type| { + column_type + .name + .strip_prefix(&format!("{group}_")) + .unwrap_or(&column_type.name) + .to_string() + }) + .collect() + } + + /// Whether a column of this type declares a currency. + /// + /// Both MONEY and ACCOUNTING do, which is why this is asked rather than + /// compared inline: written by hand, the ACCOUNTING half is easy to + /// forget, and forgetting it is silent — the currency is still stored and + /// sent, it just stops being validated or displayed. + pub(crate) fn requires_currency(&self, field_type: &str) -> bool { + self.find(field_type) + .is_some_and(|column_type| column_type.requires_currency) + } + + /// A compound type is a definition row: it expands into schema-managed + /// companion columns, so no column of its own name survives. It can + /// therefore never be indexed and never identify a row. + pub(crate) fn is_compound(&self, field_type: &str) -> bool { + self.find(field_type) + .is_some_and(|column_type| column_type.compound) + } + + fn is_creation_only(&self, field_type: &str) -> bool { + self.find(field_type) + .is_some_and(|column_type| column_type.creation_only) + } + + /// Whether the type takes a precision and a scale. + fn is_parameterised(&self, field_type: &str) -> bool { + self.find(field_type) + .is_some_and(|column_type| column_type.parameterised) + } + + /// The PostgreSQL type a column is stored as, for the definitions the + /// backend reports back. Empty for a compound type and for any type this + /// catalog does not know. + pub(crate) fn sql_type(&self, field_type: &str) -> String { + let field_type = field_type.trim(); + match decimal_arguments(&field_type.to_lowercase()) { + // `decimal(12,3)` is stored as its head's SQL type, parameterised. + Some((precision, scale)) => match self.find("decimal") { + Some(column_type) if !column_type.sql_type.is_empty() => { + format!("{}({precision},{scale})", column_type.sql_type) + } + _ => String::new(), + }, + None => self + .find(field_type) + .map(|column_type| column_type.sql_type.clone()) + .unwrap_or_default(), + } + } + + /// The types a quantity-ledger column may use, spelled for the hint under + /// the input. Read from the catalog so the hint cannot claim a set the + /// server does not accept. + pub(crate) fn quantity_ledger_types(&self) -> String { + let mut names = self + .types + .iter() + .filter(|column_type| column_type.declarable && column_type.allows_quantity_ledger) + .map(|column_type| column_type.name.to_uppercase()) + .collect::>(); + names.sort(); + names.join(", ") + } + + fn allows_quantity_ledger(&self, field_type: &str) -> bool { + match decimal_arguments(&field_type.to_lowercase()) { + Some(_) => self + .find("decimal") + .is_some_and(|column_type| column_type.allows_quantity_ledger), + None => self + .find(field_type) + .is_some_and(|column_type| column_type.allows_quantity_ledger), + } + } + + /// Whether the server would accept this as a column's declared type. + pub(crate) fn validate_field_type(&self, field_type: &str) -> Option { + let field_type = field_type.to_lowercase(); + if let Some((precision, scale)) = decimal_arguments(&field_type) { + if !self.is_parameterised("decimal") { + return Some("`decimal` is not a valid field type.".to_string()); + } + return validate_decimal_arguments(precision, scale).err(); + } + match self.find(&field_type) { + // A parameterised type spelled bare is missing its arguments. + Some(column_type) if column_type.parameterised => Some(format!( + "`{field_type}` needs both a precision and a scale." + )), + Some(column_type) if column_type.declarable => None, + Some(_) => Some(format!( + "`{field_type}` is a column type the backend generates itself." + )), + None => Some(format!("`{field_type}` is not a valid field type.")), + } + } +} + +/// Splits `decimal(p,s)` into its arguments, which is the one spelling that is +/// not simply a type name. +fn decimal_arguments(field_type: &str) -> Option<(&str, &str)> { + let arguments = field_type + .strip_prefix("decimal(") + .and_then(|rest| rest.strip_suffix(')'))?; + Some(match arguments.split_once(',') { + Some((precision, scale)) => (precision.trim(), scale.trim()), + // No comma at all: the scale is missing, and the emptiness is what + // `validate_decimal_arguments` reports. + None => (arguments.trim(), ""), + }) } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -136,8 +310,11 @@ pub(crate) struct ColumnDefinition { impl ColumnDefinition { /// The `option` cell of the preview, mirroring the client's preview table. + /// + /// The currency is shown whenever there is one, which is the same thing as + /// asking the catalog: it is only ever stored for a type that requires it. pub(crate) fn option_label(&self) -> String { - let has_currency = carries_currency(&self.data_type); + let has_currency = !self.currency.is_empty(); match (self.indexed, has_currency) { (true, true) => format!("indexed, {}, {}", self.currency, self.money_mode.label()), (true, false) => "indexed".to_string(), @@ -167,64 +344,77 @@ pub(crate) struct ColumnDraft { pub added: Vec, - /// False on the append screen: an ACCOUNTING column can only be chosen + /// 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 accounting_allowed: bool, + pub creating_table: bool, } impl ColumnDraft { /// A panel for a table that is being created, where every type applies. - pub(crate) fn new() -> Self { + pub(crate) fn new(catalog: ColumnCatalog) -> Self { Self { - accounting_allowed: true, - ..Self::empty() + creating_table: true, + ..Self::empty(catalog) } } /// A panel for appending to an existing table. - pub(crate) fn for_append() -> Self { - Self { - accounting_allowed: false, - ..Self::empty() - } + pub(crate) fn for_append(catalog: ColumnCatalog) -> Self { + Self::empty(catalog) } - fn empty() -> Self { + fn empty(catalog: ColumnCatalog) -> Self { Self { indexing_input: "no".to_string(), quantity_ledger_input: "no".to_string(), rounding_input: "none".to_string(), currency_input: "EUR".to_string(), + catalog, ..Self::default() } } - /// The types this panel offers, which is the only place the accounting + /// 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) -> &'static [&'static str] { - if self.accounting_allowed { - COLUMN_TYPES - } else { - APPENDABLE_COLUMN_TYPES - } + pub(crate) fn offered_types(&self) -> Vec { + self.catalog.offered_types(self.creating_table) + } + + pub(crate) fn temporal_types(&self) -> Vec { + self.catalog.group_members("temporal") + } + + pub(crate) fn gtin_types(&self) -> Vec { + self.catalog.group_members("gtin") + } + + /// The types a quantity-ledger column may use, for the hint under the + /// input. + pub(crate) fn quantity_ledger_types(&self) -> String { + self.catalog.quantity_ledger_types() } // ---- field visibility (the same rules the TUI canvas applies) -------- pub(crate) fn pending_carries_currency(&self) -> bool { - carries_currency(self.type_input.trim()) + self.catalog.requires_currency(&self.type_input) } pub(crate) fn show_temporal_type(&self) -> bool { - self.type_input.trim().eq_ignore_ascii_case("temporal") + self.pending_group().is_some_and(|group| group == "temporal") } pub(crate) fn show_gtin_type(&self) -> bool { - self.type_input.trim().eq_ignore_ascii_case("gtin") + self.pending_group().is_some_and(|group| group == "gtin") } pub(crate) fn show_decimal_arguments(&self) -> bool { - self.type_input.trim().eq_ignore_ascii_case("decimal") + self.catalog.is_parameterised(&self.type_input) } /// Currency and rounding both apply only to a money column. @@ -232,39 +422,52 @@ impl ColumnDraft { self.pending_carries_currency() } + /// The group the pending choice names, when it names one rather than a + /// type — which is what asks for a follow-up field. + fn pending_group(&self) -> Option { + let group = self.type_input.trim().to_ascii_lowercase(); + self.catalog.is_group(&group).then_some(group) + } + // ---- the pending column --------------------------------------------- - /// The storable type the pending inputs describe, resolving the `temporal`, - /// `gtin` and `decimal` pickers to their canonical form. `None` while the + /// The storable type the pending inputs describe, resolving a group choice + /// and the `decimal` arguments to their canonical form. `None` while the /// choice is still incomplete, `Err` when the follow-up fields are filled /// in but wrong. fn canonical_type_input(&self) -> Result, String> { let column_type = self.type_input.trim().to_ascii_lowercase(); - match column_type.as_str() { - "temporal" => { - let temporal_type = self.temporal_type_input.trim().to_ascii_lowercase(); - Ok(TEMPORAL_TYPES - .contains(&temporal_type.as_str()) - .then_some(temporal_type)) - } - "gtin" => { - let gtin_type = self.gtin_type_input.trim(); - Ok(GTIN_TYPES - .contains(>in_type) - .then(|| format!("gtin_{gtin_type}"))) - } - "decimal" => { - let precision = self.decimal_precision_input.trim(); - let scale = self.decimal_scale_input.trim(); - if precision.is_empty() && scale.is_empty() { - return Ok(None); - } - validate_decimal_arguments(precision, scale)?; - Ok(Some(format!("decimal({precision},{scale})"))) - } - "" => Ok(None), - _ => Ok(Some(column_type)), + if column_type.is_empty() { + return Ok(None); } + if self.catalog.is_parameterised(&column_type) { + let precision = self.decimal_precision_input.trim(); + let scale = self.decimal_scale_input.trim(); + if precision.is_empty() && scale.is_empty() { + return Ok(None); + } + validate_decimal_arguments(precision, scale)?; + return Ok(Some(format!("{column_type}({precision},{scale})"))); + } + let Some(group) = self.pending_group() else { + return Ok(Some(column_type)); + }; + + // A group is chosen by its member, which is spelled without the group + // prefix wherever the catalog carries one. + let member = match group.as_str() { + "gtin" => self.gtin_type_input.trim(), + _ => self.temporal_type_input.trim(), + } + .to_ascii_lowercase(); + Ok(self + .catalog + .group_members(&group) + .contains(&member) + .then(|| match self.catalog.find(&member) { + Some(column_type) => column_type.name.clone(), + None => format!("{group}_{member}"), + })) } /// Appends the pending column, then clears the input panel. @@ -273,16 +476,18 @@ impl ColumnDraft { return Err("Both a column name and a column type are required.".to_string()); }; - if column_type.eq_ignore_ascii_case("accounting") && !self.accounting_allowed { - return Err( - "An ACCOUNTING column can only be chosen while the table is being created." - .to_string(), - ); + if !self.creating_table && self.catalog.is_creation_only(&column_type) { + return Err(format!( + "A {} column can only be chosen while the table is being created.", + column_type.to_uppercase() + )); } - // An accounting column is always named `accounting`. - let column_name = if column_type.eq_ignore_ascii_case("accounting") { - "accounting".to_string() + // 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() }; @@ -293,7 +498,7 @@ impl ColumnDraft { if let Some(error) = validate_identifier(&column_name, "Column name", true) { return Err(error); } - if let Some(error) = validate_field_type(&column_type) { + if let Some(error) = self.catalog.validate_field_type(&column_type) { return Err(error); } if self.added.iter().any(|column| column.name == column_name) { @@ -301,13 +506,14 @@ impl ColumnDraft { } let quantity_ledger = self.quantity_ledger_input.trim().eq_ignore_ascii_case("yes"); - if quantity_ledger && !quantity_ledger_type_allowed(&column_type) { - return Err( - "Quantity-ledger columns must use INT, BIGINT, DECIMAL, or MONEY".to_string(), - ); + if quantity_ledger && !self.catalog.allows_quantity_ledger(&column_type) { + return Err(format!( + "Quantity-ledger columns must use {}", + self.catalog.quantity_ledger_types() + )); } - let has_currency = carries_currency(&column_type); + let has_currency = self.catalog.requires_currency(&column_type); let currency = if has_currency { normalize_currency_input(&self.currency_input)? } else { @@ -316,7 +522,8 @@ impl ColumnDraft { self.added.push(ColumnDefinition { name: column_name.clone(), data_type: column_type, - indexed: self.indexing_input.trim().eq_ignore_ascii_case("yes"), + // A compound column is not a column, so there is nothing to index. + indexed: !compound && self.indexing_input.trim().eq_ignore_ascii_case("yes"), quantity_ledger, money_mode: if has_currency { MoneyMode::from_input(&self.rounding_input) @@ -354,7 +561,18 @@ impl ColumnDraft { Ok(self.added.remove(index)) } + /// Whether a column can be indexed or identify a row. A compound column + /// leaves no column of its own name behind, so it can do neither. + pub(crate) fn is_indexable(&self, index: usize) -> bool { + self.added + .get(index) + .is_some_and(|column| !self.catalog.is_compound(&column.data_type)) + } + pub(crate) fn toggle_indexed(&mut self, index: usize) { + if !self.is_indexable(index) { + return; + } if let Some(column) = self.added.get_mut(index) { column.indexed = !column.indexed; } @@ -382,24 +600,31 @@ impl ColumnDraft { if let Some(error) = validate_identifier(&column.name, "Column name", true) { return Err(error); } - if let Some(error) = validate_field_type(&column.data_type) { + if let Some(error) = self.catalog.validate_field_type(&column.data_type) { return Err(format!("Column `{}`: {error}", column.name)); } - if !self.accounting_allowed && column.data_type.eq_ignore_ascii_case("accounting") { - return Err( - "An ACCOUNTING column can only be chosen while the table is being created." - .to_string(), - ); + if !self.creating_table && self.catalog.is_creation_only(&column.data_type) { + return Err(format!( + "A {} column can only be chosen while the table is being created.", + column.data_type.to_uppercase() + )); + } + if column.quantity_ledger && !self.catalog.allows_quantity_ledger(&column.data_type) { + return Err(format!( + "Column `{}`: quantity-ledger columns must use {}", + column.name, + self.catalog.quantity_ledger_types() + )); } // The same rule the server enforces: required for a money column, // forbidden for every other type. - if carries_currency(&column.data_type) { + if self.catalog.requires_currency(&column.data_type) { if let Err(error) = normalize_currency_input(&column.currency) { return Err(format!("Column `{}`: {error}", column.name)); } } else if !column.currency.trim().is_empty() { return Err(format!( - "Column `{}`: only MONEY and ACCOUNTING columns may declare a currency.", + "Column `{}`: a currency belongs only to a column type that requires one.", column.name )); } @@ -462,23 +687,6 @@ pub(crate) fn validate_identifier( None } -pub(crate) fn validate_field_type(field_type: &str) -> Option { - let field_type = field_type.to_lowercase(); - if CANONICAL_TYPES.contains(&field_type.as_str()) { - return None; - } - if let Some(arguments) = field_type - .strip_prefix("decimal(") - .and_then(|rest| rest.strip_suffix(')')) - { - let Some((precision, scale)) = arguments.split_once(',') else { - return Some("`decimal` needs both a precision and a scale.".to_string()); - }; - return validate_decimal_arguments(precision.trim(), scale.trim()).err(); - } - Some(format!("`{field_type}` is not a valid field type.")) -} - /// The precision and scale rules the server applies to `decimal(p,s)`: /// whole numbers, no sign, no leading zeros, `1 <= p` and `s <= p`. fn validate_decimal_arguments(precision: &str, scale: &str) -> Result<(), String> { @@ -528,6 +736,27 @@ pub(crate) fn proto_columns(columns: &[ColumnDefinition]) -> Vec) -> ColumnCatalog { + ColumnCatalog::new( + column_types + .into_iter() + .map(|column_type| ColumnType { + parameterised: column_type.spelling() == ColumnTypeSpelling::Decimal, + name: column_type.name, + sql_type: column_type.sql_type, + declarable: column_type.declarable, + compound: column_type.compound, + requires_currency: column_type.requires_currency, + creation_only: column_type.creation_only, + allows_quantity_ledger: column_type.allows_quantity_ledger, + group: column_type.group, + }) + .collect(), + ) +} + /// ISO-4217 codes offered as currency suggestions, matching the client's list. pub(crate) const CURRENCY_CODES: &[&str] = &[ "EUR", "CZK", "USD", "AED", "AFN", "ALL", "AMD", "AOA", "ARS", "AUD", "AWG", "AZN", "BAM", @@ -600,7 +829,7 @@ pub(crate) struct ColumnForm { } impl ColumnForm { - pub(crate) fn to_draft(&self, accounting_allowed: bool) -> ColumnDraft { + pub(crate) fn to_draft(&self, catalog: ColumnCatalog, creating_table: bool) -> ColumnDraft { ColumnDraft { name_input: self.column_name_input.clone(), type_input: self.column_type_input.clone(), @@ -620,7 +849,8 @@ impl ColumnForm { &self.column_rounding, &self.column_currencies, ), - accounting_allowed, + catalog, + creating_table, } } } @@ -667,12 +897,144 @@ pub(crate) fn columns_from_rows( } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; + fn declarable(name: &str) -> ColumnType { + ColumnType { + name: name.to_string(), + sql_type: "TEXT".to_string(), + declarable: true, + compound: false, + parameterised: false, + requires_currency: false, + creation_only: false, + allows_quantity_ledger: false, + group: String::new(), + } + } + + fn grouped(name: &str, group: &str) -> ColumnType { + ColumnType { + group: group.to_string(), + ..declarable(name) + } + } + + fn compound(name: &str) -> ColumnType { + ColumnType { + sql_type: String::new(), + compound: true, + creation_only: true, + ..declarable(name) + } + } + + fn numeric(name: &str) -> ColumnType { + ColumnType { + sql_type: "NUMERIC".to_string(), + allows_quantity_ledger: true, + ..declarable(name) + } + } + + /// The catalog as `ListColumnTypes` reports it: alphabetical, and carrying + /// the types a client may not declare as well as the ones it may. + pub(crate) fn catalog() -> ColumnCatalog { + ColumnCatalog::new(vec![ + ColumnType { + requires_currency: true, + ..compound("accounting") + }, + compound("accounting_transfer"), + ColumnType { + sql_type: "TIMESTAMPTZ(0)".to_string(), + ..grouped("instant", "temporal") + }, + numeric("bigint"), + declarable("boolean"), + declarable("credit_card"), + ColumnType { + sql_type: "DATE".to_string(), + ..grouped("date", "temporal") + }, + ColumnType { + parameterised: true, + ..numeric("decimal") + }, + declarable("duration"), + declarable("email_address"), + grouped("gtin_8", "gtin"), + grouped("gtin_12", "gtin"), + grouped("gtin_13", "gtin"), + grouped("gtin_14", "gtin"), + declarable("iban"), + ColumnType { + declarable: false, + ..declarable("iban_bban") + }, + numeric("int"), + ColumnType { + requires_currency: true, + ..numeric("money") + }, + ColumnType { + sql_type: "NUMERIC".to_string(), + ..declarable("numeric") + }, + declarable("period"), + declarable("phone"), + ColumnType { + declarable: false, + sql_type: "INTEGER".to_string(), + ..declarable("phone_calling_code") + }, + grouped("raw_datetime", "temporal"), + declarable("text"), + grouped("time", "temporal"), + ]) + } + + fn draft() -> ColumnDraft { + ColumnDraft::new(catalog()) + } + + /// The picker offers what the server says it accepts — including the types + /// this crate never had a list of — with the families collapsed to one + /// choice each and the companions left out. + #[test] + fn the_picker_is_the_servers_vocabulary() { + let offered = draft().offered_types(); + + assert!(offered.contains(&"numeric".to_string())); + assert!(offered.contains(&"accounting_transfer".to_string())); + assert!(offered.contains(&"decimal".to_string())); + // Families are one choice, resolved by a follow-up field. + assert!(offered.contains(&"temporal".to_string())); + assert!(offered.contains(&"gtin".to_string())); + assert!(!offered.contains(&"gtin_13".to_string())); + assert!(!offered.contains(&"date".to_string())); + // Server-generated companions are never offered. + assert!(!offered.contains(&"phone_calling_code".to_string())); + assert!(!offered.contains(&"iban_bban".to_string())); + // The common types lead, whatever order the endpoint returned. + assert_eq!(offered[0], "text"); + } + + /// Every creation-only type disappears from the append panel, which is now + /// more than just ACCOUNTING. + #[test] + fn the_append_panel_offers_no_creation_only_type() { + let appendable = ColumnDraft::for_append(catalog()).offered_types(); + + assert!(!appendable.contains(&"accounting".to_string())); + assert!(!appendable.contains(&"accounting_transfer".to_string())); + assert!(appendable.contains(&"money".to_string())); + } + #[test] fn temporal_gtin_and_decimal_pickers_resolve_to_canonical_types() { - let mut draft = ColumnDraft::new(); + let mut draft = draft(); draft.name_input = "occurred_at".to_string(); draft.type_input = "temporal".to_string(); @@ -706,7 +1068,7 @@ mod tests { /// be refused there is refused here first. #[test] fn decimal_arguments_follow_the_servers_rules() { - let mut draft = ColumnDraft::new(); + let mut draft = draft(); draft.name_input = "weight".to_string(); draft.type_input = "decimal".to_string(); @@ -729,7 +1091,7 @@ mod tests { #[test] fn duration_and_period_are_columns_of_their_own() { for field_type in ["duration", "period"] { - let mut draft = ColumnDraft::new(); + let mut draft = draft(); draft.name_input = "billing_span".to_string(); draft.type_input = field_type.to_string(); draft.add_from_inputs().unwrap(); @@ -738,28 +1100,29 @@ mod tests { } #[test] - fn an_append_panel_refuses_an_accounting_column() { - let mut draft = ColumnDraft::for_append(); - draft.type_input = "accounting".to_string(); - assert!(draft.add_from_inputs().is_err()); - assert!(!draft.offered_types().contains(&"accounting")); + fn an_append_panel_refuses_a_creation_only_column() { + for field_type in ["accounting", "accounting_transfer"] { + let mut draft = ColumnDraft::for_append(catalog()); + draft.type_input = field_type.to_string(); + assert!(draft.add_from_inputs().is_err()); - // And again for a draft rebuilt from a posted form, which never went - // through `add_from_inputs`. - draft.added.push(ColumnDefinition { - name: "accounting".to_string(), - data_type: "accounting".to_string(), - indexed: false, - quantity_ledger: false, - money_mode: MoneyMode::Exact, - currency: "EUR".to_string(), - }); - assert!(draft.validate().is_err()); + // And again for a draft rebuilt from a posted form, which never + // went through `add_from_inputs`. + draft.added.push(ColumnDefinition { + name: field_type.to_string(), + data_type: field_type.to_string(), + indexed: false, + quantity_ledger: false, + money_mode: MoneyMode::Exact, + currency: String::new(), + }); + assert!(draft.validate().is_err()); + } } #[test] fn invalid_identifiers_and_types_are_refused_at_add_time() { - let mut draft = ColumnDraft::new(); + let mut draft = draft(); draft.type_input = "text".to_string(); draft.name_input = "Total".to_string(); @@ -781,9 +1144,25 @@ mod tests { assert!(draft.add_from_inputs().is_err()); } + /// A column type the backend generates itself is not one a client may + /// declare, even though the catalog describes it. #[test] - fn quantity_ledger_requires_a_numeric_type() { - let mut draft = ColumnDraft::new(); + fn a_generated_companion_type_cannot_be_declared() { + let mut draft = draft(); + draft.name_input = "country_code".to_string(); + draft.type_input = "phone_calling_code".to_string(); + + assert!(draft.add_from_inputs().is_err()); + assert!( + catalog() + .validate_field_type("phone_calling_code") + .is_some() + ); + } + + #[test] + fn quantity_ledger_follows_the_catalog() { + let mut draft = draft(); draft.name_input = "note".to_string(); draft.type_input = "text".to_string(); draft.quantity_ledger_input = "yes".to_string(); @@ -793,28 +1172,43 @@ mod tests { assert!(draft.add_from_inputs().is_ok()); assert!(draft.added[0].quantity_ledger); - // A parameterised decimal counts as numeric. + // A parameterised decimal counts, through its head. draft.name_input = "quantity".to_string(); draft.type_input = "decimal".to_string(); draft.decimal_precision_input = "12".to_string(); draft.decimal_scale_input = "3".to_string(); draft.quantity_ledger_input = "yes".to_string(); assert!(draft.add_from_inputs().is_ok()); + + // And the hint under the input names exactly that set. + assert_eq!(draft.quantity_ledger_types(), "BIGINT, DECIMAL, INT, MONEY"); } + /// A compound column is a definition row, not a column: it takes its + /// type's name and there is nothing to index. #[test] - fn accounting_column_is_always_named_accounting() { - let mut draft = ColumnDraft::new(); - draft.name_input = "whatever".to_string(); - draft.type_input = "accounting".to_string(); - draft.add_from_inputs().unwrap(); + fn a_compound_column_is_named_after_its_type_and_never_indexed() { + for field_type in ["accounting", "accounting_transfer"] { + let mut draft = draft(); + draft.name_input = "whatever".to_string(); + draft.type_input = field_type.to_string(); + draft.indexing_input = "yes".to_string(); + if field_type == "accounting" { + draft.currency_input = "EUR".to_string(); + } + draft.add_from_inputs().unwrap(); - assert_eq!(draft.added[0].name, "accounting"); + 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 = ColumnDraft::new(); + let mut draft = draft(); draft.name_input = "total".to_string(); draft.type_input = "money".to_string(); draft.currency_input = "EU".to_string(); @@ -825,9 +1219,11 @@ mod tests { 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 an_accounting_column_shows_its_currency_too() { - let mut draft = ColumnDraft::new(); + fn currency_follows_the_catalog_rather_than_the_type_name() { + let mut draft = draft(); draft.type_input = "accounting".to_string(); draft.currency_input = "czk".to_string(); draft.add_from_inputs().unwrap(); @@ -835,15 +1231,17 @@ mod tests { assert_eq!(draft.added[0].currency, "CZK"); assert_eq!(draft.added[0].option_label(), "CZK, exact"); - draft.toggle_indexed(0); - assert_eq!(draft.added[0].option_label(), "indexed, CZK, exact"); + draft.type_input = "accounting_transfer".to_string(); + draft.currency_input = "czk".to_string(); + draft.add_from_inputs().unwrap(); + assert_eq!(draft.added[1].currency, ""); } /// `add_from_inputs` enforces this, but a draft rebuilt from a posted form /// skips that path, so `validate` has to enforce it too. #[test] fn a_rebuilt_draft_is_still_held_to_the_currency_rule() { - let mut draft = ColumnDraft::new(); + let mut draft = draft(); draft.added.push(ColumnDefinition { name: "total".to_string(), data_type: "money".to_string(), @@ -865,6 +1263,49 @@ mod tests { assert!(draft.validate().is_err()); } + /// And to the quantity-ledger rule, which the panel applies at add time. + #[test] + fn a_rebuilt_draft_is_still_held_to_the_quantity_ledger_rule() { + let mut draft = draft(); + draft.added.push(ColumnDefinition { + name: "note".to_string(), + data_type: "text".to_string(), + indexed: false, + quantity_ledger: true, + money_mode: MoneyMode::Exact, + currency: String::new(), + }); + + assert!(draft.validate().is_err()); + } + + /// With no catalog there is no vocabulary, so nothing is accepted rather + /// than a guess being made at what the server takes. + #[test] + fn an_unloaded_catalog_refuses_every_type() { + let mut draft = ColumnDraft::new(ColumnCatalog::default()); + draft.name_input = "number".to_string(); + draft.type_input = "text".to_string(); + + assert!(!draft.catalog.is_loaded()); + assert!(draft.offered_types().is_empty()); + assert!(draft.add_from_inputs().is_err()); + } + + /// The catalog also explains the types `GetProfileDetails` reports back, + /// including the companions a client may not declare. + #[test] + fn the_catalog_names_the_sql_type_behind_a_column() { + let catalog = catalog(); + + assert_eq!(catalog.sql_type("instant"), "TIMESTAMPTZ(0)"); + assert_eq!(catalog.sql_type("phone_calling_code"), "INTEGER"); + assert_eq!(catalog.sql_type("decimal(12,3)"), "NUMERIC(12,3)"); + // A compound type has no column, so it has no SQL type of its own. + assert_eq!(catalog.sql_type("accounting"), ""); + assert_eq!(catalog.sql_type("nonsense"), ""); + } + #[test] fn mismatched_column_vectors_never_mis_pair() { let columns = columns_from_rows( @@ -884,7 +1325,7 @@ mod tests { #[test] fn indexed_columns_become_the_index_list() { - let mut draft = ColumnDraft::new(); + let mut draft = draft(); draft.name_input = "number".to_string(); draft.type_input = "text".to_string(); draft.add_from_inputs().unwrap(); diff --git a/web/templates/pages/add_table/builder.html b/web/templates/pages/add_table/builder.html index 372384d8..9e3c56e9 100644 --- a/web/templates/pages/add_table/builder.html +++ b/web/templates/pages/add_table/builder.html @@ -135,7 +135,7 @@ - INT, BIGINT, DECIMAL or MONEY only. + {{ page.draft.columns.quantity_ledger_types() }} only. + {# A compound column expands into schema-managed companions, so + there is no column of its own name to index. #} + {% if page.draft.columns.is_indexable(*loop.index0) %} + + {% else %} + + {% endif %} {% if column.quantity_ledger %}quantity ledger{% endif %} diff --git a/web/templates/pages/admin/table_definition/column_panel.html b/web/templates/pages/admin/table_definition/column_panel.html index 6c7217e8..65b86733 100644 --- a/web/templates/pages/admin/table_definition/column_panel.html +++ b/web/templates/pages/admin/table_definition/column_panel.html @@ -97,7 +97,7 @@ - INT, BIGINT, DECIMAL or MONEY only. + {{ page.columns.quantity_ledger_types() }} only. @@ -117,12 +117,18 @@ {{ column.name }} {{ column.data_type }} - + {# A compound column expands into schema-managed companions, so + there is no column of its own name to index. #} + {% if page.columns.is_indexable(*loop.index0) %} + + {% else %} + + {% endif %} {% if column.quantity_ledger %}quantity ledger{% endif %} diff --git a/web/templates/pages/admin/table_definition/workspace.html b/web/templates/pages/admin/table_definition/workspace.html index 084071aa..642164c1 100644 --- a/web/templates/pages/admin/table_definition/workspace.html +++ b/web/templates/pages/admin/table_definition/workspace.html @@ -120,7 +120,7 @@ {% for column in detail.columns %} {{ column.name }} - {{ column.field_type }} + {{ column.field_type }}{% if !column.sql_type.is_empty() %} {{ column.sql_type }}{% endif %} {%- for flag in column.flags() %}{{ flag }}{% endfor -%}