From 27d3e9af34def432712440988da4fc6da17bd6e8 Mon Sep 17 00:00:00 2001 From: Priec Date: Thu, 13 Aug 2026 13:41:07 +0200 Subject: [PATCH] aliasing for accounting, where accounting real column names are the same as others, which is 0..n as opposed to previously real names and hardcoded search via those. Now we are using a look up table for the numbers mapping to look em up --- client | 2 +- common/build.rs | 6 + common/proto/table_definition.proto | 30 +++ common/src/proto/descriptor.bin | Bin 178601 -> 180398 bytes common/src/proto/komp_ac.table_definition.rs | 32 +++ server | 2 +- web/src/pages/add_table/draft.rs | 242 +++++++++++++++++- web/src/pages/add_table/state.rs | 92 ++++++- web/src/pages/add_table/ui.rs | 28 ++ web/src/pages/admin/table_definition/logic.rs | 4 + web/templates/pages/add_table/builder.html | 16 +- 11 files changed, 444 insertions(+), 10 deletions(-) diff --git a/client b/client index c48deeb2..c923d0de 160000 --- a/client +++ b/client @@ -1 +1 @@ -Subproject commit c48deeb2caee4b4e19fbf1b50a2f654b7c8c68e4 +Subproject commit c923d0de22b3ed20cd9d0fbd322456c18e7aa338 diff --git a/common/build.rs b/common/build.rs index efb54993..618d93f5 100644 --- a/common/build.rs +++ b/common/build.rs @@ -161,6 +161,12 @@ fn main() -> Result<(), Box> { ".komp_ac.table_definition.PostTableDefinitionRequest", "#[derive(serde::Serialize, serde::Deserialize)]", ) + // Carried by the two requests that create columns, both of which are + // serialised, so it has to be serialisable with them. + .type_attribute( + ".komp_ac.table_definition.GeneratedColumnAlias", + "#[derive(serde::Serialize, serde::Deserialize)]", + ) .type_attribute( ".komp_ac.table_definition.CreateInvoiceTemplateTableRequest", "#[derive(serde::Serialize, serde::Deserialize)]", diff --git a/common/proto/table_definition.proto b/common/proto/table_definition.proto index c73de16a..d67bedfc 100644 --- a/common/proto/table_definition.proto +++ b/common/proto/table_definition.proto @@ -94,6 +94,32 @@ message PostTableDefinitionRequest { // visible from every profile. profile_name and accounting_currency are ignored. bool global = 9; + // Names for the columns a definition row generates, in place of the ones the + // backend would give them. See GeneratedColumnAlias. + repeated GeneratedColumnAlias generated_aliases = 10; + +} + +// Renames one column a definition row generates, at the moment it is created. +// +// A generated column cannot be named in the column list -- the definition row +// is required to be 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 it is free to be anything: this is where that choice is +// made, instead of a RenameColumnAlias call afterwards. +// +// The ACCOUNTING_TRANSFER connectors are the exception. They are refused here +// exactly as RenameColumnAlias refuses them. +message GeneratedColumnAlias { + // The name the backend would otherwise give the column: one of ACCOUNTING's + // "name", "tax_point_date", "debit", "credit" or "account", or a companion + // such as "work_phone_extension". Must name a column the request really + // generates -- an alias for anything else is rejected rather than ignored, + // so a typo cannot pass silently. + string generated_name = 1; + + // What the column should be called instead. Same rules as any column name. + string alias = 2; } // Defines the input for explicitly creating tables backed by one invoice @@ -147,6 +173,10 @@ message AddTableColumnsRequest { // Optional indexes for the new columns only. repeated string indexes = 4; + // Names for the columns the appended definition rows generate, exactly as on + // PostTableDefinitionRequest. + repeated GeneratedColumnAlias generated_aliases = 5; + } enum MoneyRounding { diff --git a/common/src/proto/descriptor.bin b/common/src/proto/descriptor.bin index 20521138e46403cde7cb030a9cdc74a3ce85423a..37ac7e138096a3e2fb778b0e592f4289e4c7f696 100644 GIT binary patch delta 9046 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 NmO4FWk5JF4{{aPEP-g%D delta 7306 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 D0?<@m diff --git a/common/src/proto/komp_ac.table_definition.rs b/common/src/proto/komp_ac.table_definition.rs index ffb8f478..7c25fc4f 100644 --- a/common/src/proto/komp_ac.table_definition.rs +++ b/common/src/proto/komp_ac.table_definition.rs @@ -43,6 +43,34 @@ pub struct PostTableDefinitionRequest { /// visible from every profile. profile_name and accounting_currency are ignored. #[prost(bool, tag = "9")] pub global: bool, + /// Names for the columns a definition row generates, in place of the ones the + /// backend would give them. See GeneratedColumnAlias. + #[prost(message, repeated, tag = "10")] + pub generated_aliases: ::prost::alloc::vec::Vec, +} +/// Renames one column a definition row generates, at the moment it is created. +/// +/// A generated column cannot be named in the column list -- the definition row +/// is required to be 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 it is free to be anything: this is where that choice is +/// made, instead of a RenameColumnAlias call afterwards. +/// +/// The ACCOUNTING_TRANSFER connectors are the exception. They are refused here +/// exactly as RenameColumnAlias refuses them. +#[derive(serde::Serialize, serde::Deserialize)] +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GeneratedColumnAlias { + /// The name the backend would otherwise give the column: one of ACCOUNTING's + /// "name", "tax_point_date", "debit", "credit" or "account", or a companion + /// such as "work_phone_extension". Must name a column the request really + /// generates -- an alias for anything else is rejected rather than ignored, + /// so a typo cannot pass silently. + #[prost(string, tag = "1")] + pub generated_name: ::prost::alloc::string::String, + /// What the column should be called instead. Same rules as any column name. + #[prost(string, tag = "2")] + pub alias: ::prost::alloc::string::String, } /// Defines the input for explicitly creating tables backed by one invoice /// template. typst_source must contain exactly one field declaration: @@ -110,6 +138,10 @@ pub struct AddTableColumnsRequest { /// Optional indexes for the new columns only. #[prost(string, repeated, tag = "4")] pub indexes: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + /// Names for the columns the appended definition rows generate, exactly as on + /// PostTableDefinitionRequest. + #[prost(message, repeated, tag = "5")] + pub generated_aliases: ::prost::alloc::vec::Vec, } /// Describes one user-defined column for a table. #[derive(serde::Serialize, serde::Deserialize)] diff --git a/server b/server index 2e88284d..de424f24 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit 2e88284dd3a721ebe6e187f17b5c6c3ccadcc505 +Subproject commit de424f245d0eced5419913bfd704d1a8d6b65b4d diff --git a/web/src/pages/add_table/draft.rs b/web/src/pages/add_table/draft.rs index a2f386d7..b459ab5d 100644 --- a/web/src/pages/add_table/draft.rs +++ b/web/src/pages/add_table/draft.rs @@ -12,9 +12,7 @@ //! what identifies one of its rows. use crate::{ - definitions::table_definition::{ - PostTableDefinitionRequest, - }, + definitions::table_definition::{GeneratedColumnAlias, PostTableDefinitionRequest}, schema::{ColumnCatalog, ColumnDraft, proto_columns, validate_identifier}, }; @@ -23,6 +21,28 @@ use crate::{ /// them, and [`TableDraft::preview_rows`] is where that is explained. pub(crate) const ACCOUNTING_FIELD_TYPE: &str = "accounting"; +/// The compound type whose companions the backend refuses to rename: they are +/// the connectors a transfer is posted through, and are looked up by name. +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, @@ -66,6 +86,10 @@ pub(crate) struct TableDraft { /// 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 { @@ -200,6 +224,110 @@ impl TableDraft { .collect() } + // ---- generated-column aliases ---------------------------------------- + + /// The generated columns this draft would let the user rename, in the order + /// they appear in the column list. + /// + /// ACCOUNTING_TRANSFER's companions are left out: the backend refuses to + /// name them anything else. Everything else a definition row or a companion + /// type generates is a display name over a physical column, and is free. + 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; + } + names.extend( + self.columns + .generated_columns_of(index) + .iter() + .map(|generated| generated.name.clone()), + ); + if column.data_type == ACCOUNTING_FIELD_TYPE { + 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 @@ -271,7 +399,7 @@ impl TableDraft { for generated in self.columns.generated_columns_of(index) { rows.push(PreviewRow { mark: String::new(), - column: generated.name.clone(), + 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()) @@ -291,7 +419,10 @@ impl TableDraft { mark: String::new(), column: "account_id".to_string(), data_type: "BIGINT".to_string(), - option: "not null, → accounts, written as account".to_string(), + option: format!( + "not null, → accounts, written as {}", + self.generated_display_name(ACCOUNT_API_COLUMN) + ), source: "system".to_string(), }); } @@ -333,6 +464,7 @@ impl TableDraft { if self.columns.is_empty() { return Err("Add at least one column before saving.".to_string()); } + self.validate_generated_aliases()?; self.columns.validate() } @@ -352,6 +484,10 @@ impl TableDraft { }, 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(), }) } } @@ -408,6 +544,102 @@ mod tests { 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"] + ); + } + + /// The transfer connectors are resolved by name by the posting engine, and + /// the backend refuses to rename them, so they are never offered. + #[test] + fn accounting_transfer_connectors_are_not_aliasable() { + let draft = draft_with_column("accounting_transfer", "accounting_transfer"); + + assert!(draft.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"); diff --git a/web/src/pages/add_table/state.rs b/web/src/pages/add_table/state.rs index cedd9dc8..90f852ff 100644 --- a/web/src/pages/add_table/state.rs +++ b/web/src/pages/add_table/state.rs @@ -13,7 +13,10 @@ use crate::schema::{ColumnCatalog, ColumnDraft, columns_from_rows}; -use super::draft::{ACCOUNTING_FIELD_TYPE, TableDraft}; +use super::draft::{ + ACCOUNT_API_COLUMN, ACCOUNTING_FIELD_TYPE, ACCOUNTING_TRANSFER_FIELD_TYPE, GeneratedAlias, + TableDraft, +}; /// The `profile_name` option meaning "create a new profile too". pub(crate) const NEW_PROFILE: &str = "__new__"; @@ -82,6 +85,13 @@ pub(crate) struct BuilderForm { #[serde(default)] pub row_display_columns: Vec, + + // One pair per generated column offered an alias, in list order: the + // generated column's own name, and what the user typed for it. + #[serde(default)] + pub generated_alias_sources: Vec, + #[serde(default)] + pub generated_alias_names: Vec, } impl BuilderForm { @@ -148,6 +158,18 @@ impl BuilderForm { // Filled in by the loader from the live profile tree, never by the // client: it is what duplicate table names are checked against. existing_profile_tables: Vec::new(), + // Which of these still name a generated column is the catalog's + // answer, and the catalog is not filled in yet, so a pair is kept + // here and matched up when the aliases are read. + generated_aliases: self + .generated_alias_sources + .iter() + .zip(self.generated_alias_names.iter()) + .map(|(source, alias)| GeneratedAlias { + source: source.trim().to_string(), + alias: alias.trim().to_string(), + }) + .collect(), } } } @@ -200,8 +222,16 @@ impl AddTablePageState { indexable: columns.is_indexable(index), indexed: column.indexed, tags, + alias_source: None, + alias: String::new(), }); + // A generated column is named by the backend, but the name is a + // display name: it can be aliased, and the alias is applied as a + // rename the moment the table exists. The connectors of an + // ACCOUNTING_TRANSFER are the exception the backend protects. + let aliasable = column.data_type != ACCOUNTING_TRANSFER_FIELD_TYPE; + for generated in columns.generated_columns_of(index) { let mut tags = vec![format!("generated by {}", column.data_type)]; if generated.inherits_currency { @@ -216,6 +246,8 @@ impl AddTablePageState { indexable: false, indexed: false, tags, + alias_source: aliasable.then(|| generated.name.clone()), + alias: self.draft.alias_for(&generated.name).to_string(), }); } @@ -233,8 +265,13 @@ impl AddTablePageState { indexed: true, tags: vec![ "system column".to_string(), - "written as account".to_string(), + format!( + "written as {}", + self.draft.generated_display_name(ACCOUNT_API_COLUMN) + ), ], + alias_source: Some(ACCOUNT_API_COLUMN.to_string()), + alias: self.draft.alias_for(ACCOUNT_API_COLUMN).to_string(), }); } } @@ -287,6 +324,12 @@ pub(crate) struct ColumnRow { pub indexable: bool, pub indexed: bool, pub tags: Vec, + /// The generated column an alias would rename, when this row is one that + /// may be aliased. `None` for a declared column — it is named where it is + /// described — and for the columns the backend refuses to rename. + pub alias_source: Option, + /// The alias typed for it so far. + pub alias: String, } pub(crate) struct RowDisplayCandidate { @@ -425,6 +468,51 @@ mod tests { assert!(row("name").tags.contains(&"generated by accounting".to_string())); } + /// The generated columns of an ACCOUNTING row are the user's to alias, and + /// the alias travels back with the rest of the draft. + #[test] + fn the_generated_columns_of_an_accounting_row_offer_an_alias() { + let mut form = posted_form(); + form.column_names.push("accounting".into()); + form.column_types.push("accounting".into()); + form.column_indexed.push("no".into()); + form.column_quantity_ledger.push("no".into()); + form.column_rounding.push("exact".into()); + form.column_currencies.push("EUR".into()); + form.generated_alias_sources = vec!["debit".into(), "account".into()]; + form.generated_alias_names = vec![" md ".into(), "ucet".into()]; + + let mut draft = form.to_draft(); + draft.columns.catalog = crate::schema::tests::catalog(); + + assert_eq!(draft.alias_for("debit"), "md"); + assert_eq!( + draft.aliased_generated_columns(), + vec![ + ("debit".to_string(), "md".to_string()), + ("account".to_string(), "ucet".to_string()), + ] + ); + + let page = AddTablePageState { + nav: crate::ui::Nav::default(), + profiles: Vec::new(), + draft, + status: None, + error: None, + }; + let rows = page.column_rows(); + let row = |name: &str| rows.iter().find(|row| row.name == name).unwrap(); + assert_eq!(row("debit").alias_source.as_deref(), Some("debit")); + assert_eq!(row("debit").alias, "md"); + assert_eq!(row("account_id").alias_source.as_deref(), Some("account")); + assert!(row("account_id").tags.contains(&"written as ucet".to_string())); + assert!( + row("accounting").alias_source.is_none(), + "a declared column is named where it is described" + ); + } + #[test] fn the_draft_never_trusts_the_posted_table_list() { // `existing_profile_tables` is what duplicate-name checks read, so it diff --git a/web/src/pages/add_table/ui.rs b/web/src/pages/add_table/ui.rs index 976e605c..722ace2d 100644 --- a/web/src/pages/add_table/ui.rs +++ b/web/src/pages/add_table/ui.rs @@ -125,6 +125,34 @@ mod tests { assert!(html.contains("TIMESTAMPTZ")); } + /// The columns an ACCOUNTING row generates get a name field of their own, + /// which is what makes them aliasable at creation time. + #[test] + fn generated_accounting_columns_get_an_alias_field() { + let mut page = page(); + page.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(), + }); + + let html = render_builder(&page); + + assert!(!html.contains("Template error"), "{html}"); + for generated in ["name", "tax_point_date", "debit", "credit", "account"] { + assert!( + html.contains(&format!( + r#""# + )), + "`{generated}` should be aliasable" + ); + } + assert!(html.contains(r#"name="generated_alias_names""#)); + } + #[test] fn the_page_offers_the_new_profile_option_and_the_currency_list() { let html = render_page(&page()); diff --git a/web/src/pages/admin/table_definition/logic.rs b/web/src/pages/admin/table_definition/logic.rs index 61942712..5706ac85 100644 --- a/web/src/pages/admin/table_definition/logic.rs +++ b/web/src/pages/admin/table_definition/logic.rs @@ -246,6 +246,10 @@ pub(crate) async fn add_columns( table_name: inputs.selection.table.clone(), columns: proto_columns(&inputs.columns.added), indexes: inputs.columns.selected_index_names(), + // The append panel names its columns itself; the only generated ones it + // can produce are the phone and IBAN companions, which it does not + // offer to rename. The table's own rename form is where that is done. + generated_aliases: Vec::new(), }; let Ok(request) = authenticated_request(&headers, request) else { return Redirect::to("/login").into_response(); diff --git a/web/templates/pages/add_table/builder.html b/web/templates/pages/add_table/builder.html index 3d8b6cf0..9ef48686 100644 --- a/web/templates/pages/add_table/builder.html +++ b/web/templates/pages/add_table/builder.html @@ -238,6 +238,7 @@
  • account_id system column — the account each row is posted to, written as account
  • {% endif %} +

    Each of these can be given a name of your own in the column list once the row is added; leave a field empty to keep the name shown here.

    These names are reserved: no column of your own may use them, and the table may hold only one {{ page.draft.columns.pending_compound_name() }}. They land where this row sits in the column list, and can be moved with it.

    {% endif %} @@ -276,7 +277,20 @@ {% endif %} - {{ row.name }} + + {{ row.name }} + {# + A generated column is named by the backend, so it cannot be + named in the request that creates the table — but the name is a + display name, and an alias here is applied as a rename as soon + as the table exists. Leaving it empty keeps the backend's name. + #} + {% if let Some(source) = row.alias_source %} + + + {% endif %} + {{ row.data_type }} {# A compound column expands into schema-managed companions, so