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

This commit is contained in:
Priec
2026-08-13 13:41:07 +02:00
parent 774c9a3ce0
commit 27d3e9af34
11 changed files with 444 additions and 10 deletions

View File

@@ -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<String>,
/// Names asked for the generated columns, sent with the request that
/// creates them.
pub generated_aliases: Vec<GeneratedAlias>,
}
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<String> {
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<GeneratedColumnAlias> {
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<String> = 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");