diff --git a/server b/server index 52ca7446..20f226f5 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit 52ca7446cce0c15095d46519995e7478e2b6eca5 +Subproject commit 20f226f5d85e5df93691b8a834588bac54969e8c diff --git a/web/src/pages/add_table/state.rs b/web/src/pages/add_table/state.rs index 1ec2e174..ebb8fdb7 100644 --- a/web/src/pages/add_table/state.rs +++ b/web/src/pages/add_table/state.rs @@ -48,6 +48,8 @@ pub(crate) struct BuilderForm { #[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, @@ -96,6 +98,7 @@ impl BuilderForm { 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(), diff --git a/web/src/pages/add_table/ui.rs b/web/src/pages/add_table/ui.rs index 4abc639c..e3f374d0 100644 --- a/web/src/pages/add_table/ui.rs +++ b/web/src/pages/add_table/ui.rs @@ -169,6 +169,12 @@ mod tests { let html = render_builder(&state); assert!(html.contains(r#"name="column_rounding_input""#)); assert!(html.contains(r#"list="currency-codes""#)); + + state.draft.columns.type_input = "link".to_string(); + let html = render_builder(&state); + assert!(html.contains("Link alias")); + assert!(html.contains(r#"name="link_table_input""#)); + assert!(html.contains(r#" Vec<&str> { + self.tables + .iter() + .filter(|table| table.name != self.selection.table && table.name != "accounts") + .map(|table| table.name.as_str()) + .collect() + } + /// The selected table's summary, which is where its kind and dependencies /// come from. pub(crate) fn selected_table(&self) -> Option<&TableSummary> { diff --git a/web/src/pages/admin/table_definition/ui.rs b/web/src/pages/admin/table_definition/ui.rs index faf1bc0f..667ab660 100644 --- a/web/src/pages/admin/table_definition/ui.rs +++ b/web/src/pages/admin/table_definition/ui.rs @@ -229,6 +229,20 @@ mod tests { assert!(!html.contains(r#" 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. @@ -206,7 +214,11 @@ impl ColumnCatalog { _ => String::new(), }, None => self - .find(field_type) + .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(), } @@ -246,11 +258,20 @@ impl ColumnCatalog { } return validate_decimal_arguments(precision, scale).err(); } + if let Some(target) = link_argument(&field_type) { + if !self.is_link("link") { + return Some("`link` is not a valid field type.".to_string()); + } + return validate_identifier(target, "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(format!( "`{field_type}` needs both a precision and a scale." )), + Some(column_type) if column_type.link => { + Some("`link` needs a referenced table.".to_string()) + } Some(column_type) if column_type.declarable => None, Some(_) => Some(format!( "`{field_type}` is a column type the backend generates itself." @@ -274,6 +295,13 @@ fn decimal_arguments(field_type: &str) -> Option<(&str, &str)> { }) } +fn link_argument(field_type: &str) -> Option<&str> { + field_type + .strip_prefix("link(") + .and_then(|rest| rest.strip_suffix(')')) + .map(str::trim) +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) enum MoneyMode { #[default] @@ -335,6 +363,7 @@ pub(crate) struct ColumnDraft { 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, @@ -417,6 +446,10 @@ impl ColumnDraft { 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() @@ -449,6 +482,10 @@ impl ColumnDraft { validate_decimal_arguments(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)); }; @@ -542,6 +579,7 @@ impl ColumnDraft { 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(); @@ -744,6 +782,7 @@ pub(crate) fn column_catalog(column_types: Vec) -> ColumnCatalo .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, @@ -802,6 +841,8 @@ pub(crate) struct ColumnForm { #[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, @@ -835,6 +876,7 @@ impl ColumnForm { 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(), @@ -907,6 +949,7 @@ pub(crate) mod tests { declarable: true, compound: false, parameterised: false, + link: false, requires_currency: false, creation_only: false, allows_quantity_ledger: false, @@ -974,6 +1017,11 @@ pub(crate) mod tests { ..declarable("iban_bban") }, numeric("int"), + ColumnType { + link: true, + sql_type: "BIGINT".to_string(), + ..declarable("link") + }, ColumnType { requires_currency: true, ..numeric("money") @@ -1064,6 +1112,31 @@ pub(crate) mod tests { 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().unwrap(), None); + + draft.link_table_input = "customer".to_string(); + draft.add_from_inputs().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("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] diff --git a/web/templates/pages/add_table/builder.html b/web/templates/pages/add_table/builder.html index 30392822..2ab79d74 100644 --- a/web/templates/pages/add_table/builder.html +++ b/web/templates/pages/add_table/builder.html @@ -98,8 +98,9 @@ Add a column - Column name + {% if page.draft.columns.show_link_target() %}Link alias{% else %}Column name{% endif %} + {% if page.draft.columns.show_link_target() %}The alias is the column name used to distinguish this relationship.{% endif %} Column type {% endif %} + {% if page.draft.columns.show_link_target() %} + Referenced table + + Choose a table + {% for table in page.draft.relation_tables %} + {{ table }} + {% endfor %} + + {% if page.draft.relation_tables.is_empty() %}No eligible tables exist in this scope.{% endif %} + + {% endif %} + {% if page.draft.columns.show_money_options() %} Currency - Column name + {% if page.columns.show_link_target() %}Link alias{% else %}Column name{% endif %} + {% if page.columns.show_link_target() %}The alias is the column name used to distinguish this relationship.{% endif %} Column type {% endif %} + {% if page.columns.show_link_target() %} + Referenced table + + Choose a table + {% for table in page.link_target_tables() %} + {{ table }} + {% endfor %} + + {% if page.link_target_tables().is_empty() %}No eligible tables exist in this scope.{% endif %} + + {% endif %} + {% if page.columns.show_money_options() %} Currency