diff --git a/common/build.rs b/common/build.rs index a52fc88d..efb54993 100644 --- a/common/build.rs +++ b/common/build.rs @@ -157,10 +157,6 @@ fn main() -> Result<(), Box> { ".komp_ac.table_definition.ColumnDefinition", "#[derive(serde::Serialize, serde::Deserialize)]", ) - .type_attribute( - ".komp_ac.table_definition.TableLink", - "#[derive(serde::Serialize, serde::Deserialize)]" - ) .type_attribute( ".komp_ac.table_definition.PostTableDefinitionRequest", "#[derive(serde::Serialize, serde::Deserialize)]", diff --git a/common/proto/table_definition.proto b/common/proto/table_definition.proto index 575cf6ed..6ed207e1 100644 --- a/common/proto/table_definition.proto +++ b/common/proto/table_definition.proto @@ -48,19 +48,6 @@ service TableDefinition { rpc DeleteTable(DeleteTableRequest) returns (DeleteTableResponse); } -// A single link to another table within the same profile (schema). -message TableLink { - // Name of an existing table within the same profile to link to. - // For each link, a "_id" column is created on the new table. - // That column references ""(id) and adds an index automatically. - string linked_table_name = 1; - - // If true, the generated foreign key column is NOT NULL. - // Otherwise the column allows NULL. - // Duplicate links to the same target table in one request are rejected. - bool required = 2; -} - // Defines the input for creating a new table definition. message PostTableDefinitionRequest { // Table name to create inside the target profile. @@ -69,10 +56,6 @@ message PostTableDefinitionRequest { // Forbidden names: "id", "deleted", "created_at", "row_revision", or ending in "_id". string table_name = 1; - // List of links (foreign keys) to existing tables in the same profile. - // Each will automatically get a "_id" column and an index. - repeated TableLink links = 2; - // List of user-defined columns (adds to system/id/fk columns). repeated ColumnDefinition columns = 3; @@ -187,6 +170,9 @@ message ColumnDefinition { // DURATION // PERIOD // DECIMAL(p,s) → NUMERIC(p,s) + // LINK(table) → BIGINT referencing that table in the same profile, indexed + // automatically. A table may hold several links to the same + // target as long as the columns are named differently. // DECIMAL args must be integers (no sign, no dot, no leading zeros); // s ≤ p and p ≥ 1. string field_type = 2; @@ -371,6 +357,12 @@ enum ColumnTypeSpelling { // at least 1 and scale no greater than precision; neither may carry a sign, // a decimal point, or leading zeros. COLUMN_TYPE_SPELLING_DECIMAL = 1; + + // The name takes the name of another table in the same profile: + // "link(adresar)". The column holds that table's id, and the server creates + // the foreign key and its index. A picker offers the profile's other tables + // as the argument. + COLUMN_TYPE_SPELLING_LINK = 2; } // Response describing the whole column-type vocabulary. diff --git a/common/src/proto/descriptor.bin b/common/src/proto/descriptor.bin index 33e504fd..7437890d 100644 Binary files a/common/src/proto/descriptor.bin and b/common/src/proto/descriptor.bin differ diff --git a/common/src/proto/komp_ac.table_definition.rs b/common/src/proto/komp_ac.table_definition.rs index 51c043d4..7e06f452 100644 --- a/common/src/proto/komp_ac.table_definition.rs +++ b/common/src/proto/komp_ac.table_definition.rs @@ -1,19 +1,4 @@ // This file is @generated by prost-build. -/// A single link to another table within the same profile (schema). -#[derive(serde::Serialize, serde::Deserialize)] -#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct TableLink { - /// Name of an existing table within the same profile to link to. - /// For each link, a "\_id" column is created on the new table. - /// That column references ""(id) and adds an index automatically. - #[prost(string, tag = "1")] - pub linked_table_name: ::prost::alloc::string::String, - /// If true, the generated foreign key column is NOT NULL. - /// Otherwise the column allows NULL. - /// Duplicate links to the same target table in one request are rejected. - #[prost(bool, tag = "2")] - pub required: bool, -} /// Defines the input for creating a new table definition. #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, ::prost::Message)] @@ -24,10 +9,6 @@ pub struct PostTableDefinitionRequest { /// Forbidden names: "id", "deleted", "created_at", "row_revision", or ending in "\_id". #[prost(string, tag = "1")] pub table_name: ::prost::alloc::string::String, - /// List of links (foreign keys) to existing tables in the same profile. - /// Each will automatically get a "\_id" column and an index. - #[prost(message, repeated, tag = "2")] - pub links: ::prost::alloc::vec::Vec, /// List of user-defined columns (adds to system/id/fk columns). #[prost(message, repeated, tag = "3")] pub columns: ::prost::alloc::vec::Vec, @@ -153,6 +134,9 @@ pub struct ColumnDefinition { /// DURATION /// PERIOD /// DECIMAL(p,s) → NUMERIC(p,s) + /// LINK(table) → BIGINT referencing that table in the same profile, indexed + /// automatically. A table may hold several links to the same + /// target as long as the columns are named differently. /// DECIMAL args must be integers (no sign, no dot, no leading zeros); /// s ≤ p and p ≥ 1. #[prost(string, tag = "2")] @@ -486,6 +470,11 @@ pub enum ColumnTypeSpelling { /// at least 1 and scale no greater than precision; neither may carry a sign, /// a decimal point, or leading zeros. Decimal = 1, + /// The name takes the name of another table in the same profile: + /// "link(adresar)". The column holds that table's id, and the server creates + /// the foreign key and its index. A picker offers the profile's other tables + /// as the argument. + Link = 2, } impl ColumnTypeSpelling { /// String value of the enum field names used in the ProtoBuf definition. @@ -496,6 +485,7 @@ impl ColumnTypeSpelling { match self { Self::Bare => "COLUMN_TYPE_SPELLING_BARE", Self::Decimal => "COLUMN_TYPE_SPELLING_DECIMAL", + Self::Link => "COLUMN_TYPE_SPELLING_LINK", } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -503,6 +493,7 @@ impl ColumnTypeSpelling { match value { "COLUMN_TYPE_SPELLING_BARE" => Some(Self::Bare), "COLUMN_TYPE_SPELLING_DECIMAL" => Some(Self::Decimal), + "COLUMN_TYPE_SPELLING_LINK" => Some(Self::Link), _ => None, } } diff --git a/server b/server index 8ed66653..9b5956ce 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit 8ed666531ba2d5745f8b3c0019333ce1b7f7785c +Subproject commit 9b5956ce99709ddad209c2b870ddcc19cb6580a6 diff --git a/web/src/pages/add_table/draft.rs b/web/src/pages/add_table/draft.rs index 5869a022..863ffc52 100644 --- a/web/src/pages/add_table/draft.rs +++ b/web/src/pages/add_table/draft.rs @@ -8,65 +8,16 @@ //! //! What a *column* may be is not here: that is [`crate::schema`], which the //! append screen in `admin/table_definition` shares. This module is only what -//! is true of a table being created — its profile, its name, its links, and +//! is true of a table being created — its profile, its name, its columns, and //! what identifies one of its rows. use crate::{ definitions::table_definition::{ - PostTableDefinitionRequest, TableLink as ProtoTableLink, + PostTableDefinitionRequest, }, schema::{ColumnCatalog, ColumnDraft, proto_columns, validate_identifier}, }; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub(crate) enum LinkMode { - #[default] - None, - Optional, - Required, -} - -impl LinkMode { - pub(crate) fn label(self) -> &'static str { - match self { - Self::None => "none", - Self::Optional => "optional", - Self::Required => "required", - } - } - - /// Cycles none → optional → required → none, as `Select` does in the TUI. - pub(crate) fn next(self) -> Self { - match self { - Self::None => Self::Optional, - Self::Optional => Self::Required, - Self::Required => Self::None, - } - } - - pub(crate) fn from_label(value: &str) -> Self { - match value.trim() { - "optional" => Self::Optional, - "required" => Self::Required, - _ => Self::None, - } - } - - pub(crate) fn is_active(self) -> bool { - !matches!(self, Self::None) - } - - pub(crate) fn is_required(self) -> bool { - matches!(self, Self::Required) - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct LinkDefinition { - pub linked_table_name: String, - pub mode: LinkMode, -} - /// One row of the "Table definition preview" — the schema as it will exist. pub(crate) struct PreviewRow { pub mark: String, @@ -91,7 +42,9 @@ pub(crate) struct TableDraft { /// The column panel: the pending column and the ones already described. pub columns: ColumnDraft, - pub links: Vec, + /// Tables in the target profile, offered as `link(...)` targets by the + /// column picker. + pub relation_tables: Vec, /// Columns identifying a row to users, in the order they are shown. /// Empty means rows are identified by their id alone. pub row_display_columns: Vec, @@ -133,12 +86,6 @@ impl TableDraft { Ok(format!("Column `{}` removed.", removed.name)) } - pub(crate) fn cycle_link_mode(&mut self, index: usize) { - if let Some(link) = self.links.get_mut(index) { - link.mode = link.mode.next(); - } - } - /// Adds or removes one display-column candidate. /// /// Index 0 is `id`, which is not a display column of its own: choosing it @@ -175,25 +122,12 @@ impl TableDraft { } } - /// Rebuilds the link list from the tables available in the target profile, - /// keeping whatever mode each surviving link already had. + /// Records the tables the target profile offers as link targets. A table + /// cannot link to itself, so its own name is never among them. pub(crate) fn set_available_relation_tables(&mut self, table_names: Vec) { - let previous_modes = self - .links - .iter() - .map(|link| (link.linked_table_name.clone(), link.mode)) - .collect::>(); - - self.links = table_names + self.relation_tables = table_names .into_iter() .filter(|table_name| table_name != &self.table_name) - .map(|linked_table_name| LinkDefinition { - mode: previous_modes - .get(&linked_table_name) - .copied() - .unwrap_or(LinkMode::None), - linked_table_name, - }) .collect(); } @@ -249,20 +183,6 @@ impl TableDraft { }, ]; - for link in self.links.iter().filter(|link| link.mode.is_active()) { - rows.push(PreviewRow { - mark: String::new(), - column: format!("{}_id", link.linked_table_name), - data_type: "BIGINT".to_string(), - option: if link.mode.is_required() { - "required".to_string() - } else { - "optional".to_string() - }, - source: "relation".to_string(), - }); - } - for column in &self.columns.added { rows.push(PreviewRow { mark: self @@ -324,16 +244,6 @@ impl TableDraft { profile_name: self.effective_profile_name(), columns: proto_columns(&self.columns.added), indexes: self.columns.selected_index_names(), - links: self - .links - .iter() - .filter(|link| link.mode.is_active()) - .map(|link| ProtoTableLink { - linked_table_name: link.linked_table_name.clone(), - required: link.mode.is_required(), - name: link.linked_table_name.clone(), - }) - .collect(), accounting_currency: if self.creating_new_profile { self.accounting_currency.trim().to_ascii_uppercase() } else { @@ -430,34 +340,12 @@ mod tests { } #[test] - fn links_keep_their_mode_when_the_table_list_is_reloaded() { - let mut draft = draft_with_column("total", "int"); - draft.set_available_relation_tables(vec!["customer".into(), "project".into()]); - draft.cycle_link_mode(0); // none -> optional - draft.cycle_link_mode(0); // optional -> required - - draft.set_available_relation_tables(vec![ - "customer".into(), - "project".into(), - "address".into(), - ]); - - assert_eq!(draft.links[0].mode, LinkMode::Required); - assert_eq!(draft.links[2].mode, LinkMode::None); - - let request = draft.into_request().unwrap(); - assert_eq!(request.links.len(), 1); - assert_eq!(request.links[0].linked_table_name, "customer"); - assert!(request.links[0].required); - } - - #[test] - fn a_table_never_links_to_itself() { - let mut draft = draft_with_column("total", "int"); + fn a_table_never_offers_itself_as_a_link_target() { + let mut draft = TableDraft::new(); + draft.table_name = "invoice".into(); draft.set_available_relation_tables(vec!["invoice".into(), "customer".into()]); - assert_eq!(draft.links.len(), 1); - assert_eq!(draft.links[0].linked_table_name, "customer"); + assert_eq!(draft.relation_tables, vec!["customer".to_string()]); } #[test] @@ -513,10 +401,8 @@ mod tests { } #[test] - fn the_preview_shows_system_relation_and_user_columns() { - let mut draft = draft_with_column("number", "text"); - draft.set_available_relation_tables(vec!["customer".into()]); - draft.cycle_link_mode(0); + fn the_preview_shows_system_and_user_columns() { + let draft = draft_with_column("number", "text"); let rows = draft.preview_rows(); let columns = rows @@ -524,11 +410,7 @@ mod tests { .map(|row| row.column.as_str()) .collect::>(); - assert_eq!( - columns, - vec!["id", "deleted", "customer_id", "number", "created_at"] - ); - assert_eq!(rows[2].option, "optional"); + assert_eq!(columns, vec!["id", "deleted", "number", "created_at"]); // No display column chosen, so `id` identifies the row. assert_eq!(rows[0].mark, "[x]"); } diff --git a/web/src/pages/add_table/loader.rs b/web/src/pages/add_table/loader.rs index aeed21b6..34403b78 100644 --- a/web/src/pages/add_table/loader.rs +++ b/web/src/pages/add_table/loader.rs @@ -80,7 +80,7 @@ pub(crate) async fn load_page( // A brand-new (or not-yet-named) profile has nothing to link to. None => { draft.existing_profile_tables.clear(); - draft.links.clear(); + draft.relation_tables.clear(); } } diff --git a/web/src/pages/add_table/logic.rs b/web/src/pages/add_table/logic.rs index 19a5957c..36867c4c 100644 --- a/web/src/pages/add_table/logic.rs +++ b/web/src/pages/add_table/logic.rs @@ -142,7 +142,6 @@ fn apply_action(page: &mut AddTablePageState, form: &BuilderForm) { Err(message) => page.error = Some(message), }, "toggle-index" => page.draft.columns.toggle_indexed(index), - "cycle-link" => page.draft.cycle_link_mode(index), "toggle-display" => page.draft.toggle_row_display_candidate(index), _ => {} } diff --git a/web/src/pages/add_table/state.rs b/web/src/pages/add_table/state.rs index 99a43692..7e5d1aa6 100644 --- a/web/src/pages/add_table/state.rs +++ b/web/src/pages/add_table/state.rs @@ -1,7 +1,7 @@ //! Wire format for the builder form, and the page state the templates read. //! //! HTTP is stateless, so the whole draft travels with every interaction: each -//! already-added column, link and display column is posted back as a set of +//! already-added column and display column is posted back as a set of //! parallel repeated fields. `serde_html_form` (via `axum_extra::extract::Form`) //! decodes the repeats into `Vec`s, which `to_draft` zips back into a //! [`TableDraft`]. @@ -13,7 +13,7 @@ use crate::schema::{ColumnCatalog, ColumnDraft, columns_from_rows}; -use super::draft::{LinkDefinition, LinkMode, TableDraft}; +use super::draft::TableDraft; /// The `profile_name` option meaning "create a new profile too". pub(crate) const NEW_PROFILE: &str = "__new__"; @@ -72,11 +72,9 @@ pub(crate) struct BuilderForm { #[serde(default)] pub column_currencies: Vec, - // One entry per link target offered by the profile, in order. + // Tables the profile offers as link targets, in order. #[serde(default)] - pub link_tables: Vec, - #[serde(default)] - pub link_modes: Vec, + pub relation_tables: Vec, #[serde(default)] pub row_display_columns: Vec, @@ -118,14 +116,6 @@ impl BuilderForm { creating_table: true, }; - let link_count = self.link_tables.len().min(self.link_modes.len()); - let links = (0..link_count) - .map(|index| LinkDefinition { - linked_table_name: self.link_tables[index].clone(), - mode: LinkMode::from_label(&self.link_modes[index]), - }) - .collect(); - // Drop display columns whose column is gone, so a stale post cannot // send a display column that no longer exists. let row_display_columns = self @@ -146,7 +136,7 @@ impl BuilderForm { accounting_currency: self.accounting_currency.clone(), table_name: self.table_name.clone(), columns, - links, + relation_tables: self.relation_tables.clone(), row_display_columns, // Filled in by the loader from the live profile tree, never by the // client: it is what duplicate table names are checked against. @@ -240,22 +230,20 @@ mod tests { column_quantity_ledger: vec!["no".into(), "no".into()], column_rounding: vec!["exact".into(), "half-up".into()], column_currencies: vec![String::new(), "EUR".into()], - link_tables: vec!["customer".into(), "project".into()], - link_modes: vec!["required".into(), "none".into()], + relation_tables: vec!["customer".into(), "project".into()], row_display_columns: vec!["number".into()], ..Default::default() } } #[test] - fn round_trips_columns_links_and_display_columns() { + fn round_trips_columns_relation_tables_and_display_columns() { let draft = posted_form().to_draft(); assert_eq!(draft.columns.added.len(), 2); assert!(draft.columns.added[0].indexed); assert_eq!(draft.columns.added[1].money_mode, MoneyMode::Rounded); - assert_eq!(draft.links[0].mode, LinkMode::Required); - assert_eq!(draft.links[1].mode, LinkMode::None); + assert_eq!(draft.relation_tables, vec!["customer", "project"]); assert_eq!(draft.row_display_columns, vec!["number"]); assert!(!draft.creating_new_profile); } diff --git a/web/templates/pages/add_table/builder.html b/web/templates/pages/add_table/builder.html index 3fe6e298..235b3ee8 100644 --- a/web/templates/pages/add_table/builder.html +++ b/web/templates/pages/add_table/builder.html @@ -195,26 +195,8 @@
-

Relations

- {% if page.draft.links.is_empty() %} -

No other tables in this profile to link to.

- {% else %} -

Each active relation adds a <table>_id column.

-
    - {% for link in page.draft.links %} -
  • - - {{ link.linked_table_name }} -
  • - {% endfor %} -
- {% endif %} - - {% for link in page.draft.links %} - - + {% for table in page.draft.relation_tables %} + {% endfor %}