link in table definition
This commit is contained in:
2
server
2
server
Submodule server updated: 52ca7446cc...20f226f5d8
@@ -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(),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -98,8 +98,9 @@
|
||||
<section class="builder-section">
|
||||
<h2>Add a column</h2>
|
||||
<div class="form-grid">
|
||||
<label>Column name
|
||||
<label>{% if page.draft.columns.show_link_target() %}Link alias{% else %}Column name{% endif %}
|
||||
<input name="column_name_input" value="{{ page.draft.columns.name_input }}" placeholder="number">
|
||||
{% if page.draft.columns.show_link_target() %}<small>The alias is the column name used to distinguish this relationship.</small>{% endif %}
|
||||
</label>
|
||||
<label>Column type
|
||||
<select name="column_type_input" hx-post="/admin/tables/builder" hx-trigger="change"
|
||||
@@ -147,6 +148,18 @@
|
||||
</label>
|
||||
{% endif %}
|
||||
|
||||
{% if page.draft.columns.show_link_target() %}
|
||||
<label>Referenced table
|
||||
<select name="link_table_input">
|
||||
<option value="">Choose a table</option>
|
||||
{% for table in page.draft.relation_tables %}
|
||||
<option value="{{ table }}" {% if page.draft.columns.link_table_input == *table %}selected{% endif %}>{{ table }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if page.draft.relation_tables.is_empty() %}<small>No eligible tables exist in this scope.</small>{% endif %}
|
||||
</label>
|
||||
{% endif %}
|
||||
|
||||
{% if page.draft.columns.show_money_options() %}
|
||||
<label>Currency
|
||||
<input name="column_currency_input" value="{{ page.draft.columns.currency_input }}" list="currency-codes"
|
||||
|
||||
@@ -23,8 +23,9 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="form-grid">
|
||||
<label>Column name
|
||||
<label>{% if page.columns.show_link_target() %}Link alias{% else %}Column name{% endif %}
|
||||
<input name="column_name_input" value="{{ page.columns.name_input }}" placeholder="issued_on">
|
||||
{% if page.columns.show_link_target() %}<small>The alias is the column name used to distinguish this relationship.</small>{% endif %}
|
||||
</label>
|
||||
<label>Column type
|
||||
<select name="column_type_input"
|
||||
@@ -74,6 +75,18 @@
|
||||
</label>
|
||||
{% endif %}
|
||||
|
||||
{% if page.columns.show_link_target() %}
|
||||
<label>Referenced table
|
||||
<select name="link_table_input">
|
||||
<option value="">Choose a table</option>
|
||||
{% for table in page.link_target_tables() %}
|
||||
<option value="{{ table }}" {% if page.columns.link_table_input == *table %}selected{% endif %}>{{ table }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if page.link_target_tables().is_empty() %}<small>No eligible tables exist in this scope.</small>{% endif %}
|
||||
</label>
|
||||
{% endif %}
|
||||
|
||||
{% if page.columns.show_money_options() %}
|
||||
<label>Currency
|
||||
<input name="column_currency_input" value="{{ page.columns.currency_input }}" list="currency-codes"
|
||||
|
||||
Reference in New Issue
Block a user