link in table definition

This commit is contained in:
Priec
2026-08-12 17:17:29 +02:00
parent fa50f29ff0
commit 08af99e334
8 changed files with 134 additions and 4 deletions

View File

@@ -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(),

View File

@@ -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#"<option value="customer""#));
}
#[test]

View File

@@ -277,6 +277,14 @@ pub(crate) struct TablePermissionAction {
}
impl TableDefinitionPageState {
pub(crate) fn link_target_tables(&self) -> 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> {

View File

@@ -229,6 +229,20 @@ mod tests {
assert!(!html.contains(r#"<option value="accounting""#));
}
#[test]
fn the_append_panel_offers_link_targets_and_an_alias() {
let mut state = page();
state.columns.type_input = "link".to_string();
state.tables.push(table("customer", "dynamic"));
let html = render_column_panel(&state);
assert!(html.contains("Link alias"));
assert!(html.contains(r#"name="link_table_input""#));
assert!(html.contains(r#"<option value="customer""#));
assert!(!html.contains(r#"<option value="invoice""#));
}
/// A system table is the backend's own; every write below is refused for
/// it, so the workspace shows the definition and stops there.
#[test]

View File

@@ -48,6 +48,7 @@ const TYPE_DISPLAY_ORDER: &[&str] = &[
"email_address",
"credit_card",
"gtin",
"link",
];
/// One column type as the backend describes it.
@@ -66,6 +67,8 @@ pub(crate) struct ColumnType {
pub compound: bool,
/// The name takes a precision and a scale: `decimal(12,3)`.
pub parameterised: bool,
/// The name takes the target table: `link(customer)`.
pub link: bool,
pub requires_currency: bool,
/// Only choosable while the table is being created.
pub creation_only: bool,
@@ -192,6 +195,11 @@ impl ColumnCatalog {
.is_some_and(|column_type| column_type.parameterised)
}
fn is_link(&self, field_type: &str) -> 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<ProtoColumnType>) -> 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]