diff --git a/client b/client index 8538beba..da1557a2 160000 --- a/client +++ b/client @@ -1 +1 @@ -Subproject commit 8538beba2a7ec27f4205131fd11f84df653082b6 +Subproject commit da1557a2d4a08be07f7c3af93529aa240a060744 diff --git a/common/proto/table_definition.proto b/common/proto/table_definition.proto index 59c2f5c7..4ce00d78 100644 --- a/common/proto/table_definition.proto +++ b/common/proto/table_definition.proto @@ -53,16 +53,18 @@ message PostTableDefinitionRequest { // Table name to create inside the target profile. // Must be lowercase, alphanumeric with underscores, // start with a letter, and be <= 63 chars. - // Forbidden names: "id", "deleted", "created_at", "row_revision", or ending in "_id". + // Forbidden names: "id", "deleted", "created_at", "row_revision" -- the + // system columns, which share one namespace with table names wherever the + // two are named side by side. The "_id" suffix is allowed. string table_name = 1; - // List of user-defined columns (adds to system/id/fk columns). + // The table's columns, including its links. System columns are added on top. repeated ColumnDefinition columns = 3; - // List of column names to be indexed (must match existing user-defined columns). - // Indexes can target only user-defined columns; system columns ("id", "deleted", - // "created_at", "row_revision") and automatically generated foreign key ("*_id") columns already - // have indexes. Requests trying to index those columns are rejected. + // Column names to index, matching names declared above. System columns + // ("id", "deleted", "created_at", "row_revision") already have indexes, and + // a LINK column is indexed when it is created. Requests naming either are + // rejected. repeated string indexes = 4; // Name of profile (Postgres schema) where the table will be created. @@ -142,9 +144,13 @@ enum MoneyRounding { // Describes one user-defined column for a table. message ColumnDefinition { - // Column name that follows the same validation rules as table_name. - // Must be lowercase, start with a letter, no uppercase characters, - // and cannot be "id", "deleted", "created_at", "row_revision", or end with "_id". + // Must be lowercase, start with a letter, and use only lowercase letters, + // digits and underscores. + // + // Cannot be "id", "deleted", "created_at" or "row_revision": those are + // system columns, and a data request names system and user columns in one + // namespace. Any other name is free, including one ending in "_id" -- no + // column name is derived from a table name, so nothing collides. string name = 1; // Logical column type. Supported values (case-insensitive): diff --git a/common/proto/table_script.proto b/common/proto/table_script.proto index a5ae6163..94fb3f5c 100644 --- a/common/proto/table_script.proto +++ b/common/proto/table_script.proto @@ -181,6 +181,16 @@ message ScriptDependency { string operation = 4; // Relationship table used to match the owner row to the related collection. string via_table = 5; + // Column of the current row holding the referenced row's id: the link this + // dependency travels, resolved when the script was saved. Empty when no link + // is travelled -- a self-reference, or an aggregate, which follows a path + // instead. A table may link to one target several times, so this is the only + // thing that says which link was meant; never derive it from target_table. + string link_column = 6; + // The first argument exactly as the script wrote it: a link alias, or the + // target table's name where one link makes that unambiguous. The Steel + // runtime looks its inputs up by this text. + string name_in_script = 7; } // Identifies the active form row whose external Steel inputs must be hydrated. diff --git a/common/proto/table_structure.proto b/common/proto/table_structure.proto index d840f2f2..70ea77b7 100644 --- a/common/proto/table_structure.proto +++ b/common/proto/table_structure.proto @@ -46,7 +46,7 @@ message GetTableStructureResponse { message TableStructureResponse { // Columns of the physical table, including system columns (id, deleted, // created_at, row_revision), user-defined columns, and any foreign-key columns such as - // "_id", plus the dedicated "account_id" column on + // named by whoever declared each link, plus the dedicated "account_id" on // ACCOUNTING-enabled tables. May be empty if the physical table is missing. repeated TableColumn columns = 1; } diff --git a/common/proto/tables_data.proto b/common/proto/tables_data.proto index 5fa57a7a..2d95c3fe 100644 --- a/common/proto/tables_data.proto +++ b/common/proto/tables_data.proto @@ -62,7 +62,7 @@ service TablesData { // - Validates profile and table definition // - Returns all columns as strings (COALESCE(col::TEXT, '') AS col) // including: id, deleted, row_revision, all user-defined columns, and FK columns - // named "_id" for each table link, plus "account_id" on + // named by whoever declared each link, plus "account_id" on // ACCOUNTING-enabled tables // - Fails with NOT_FOUND if record does not exist or is soft-deleted // - If the physical table is missing but the definition exists, returns INTERNAL @@ -101,7 +101,7 @@ message PostTableDataRequest { // - User-defined columns from the table definition // - System/FK columns: // • "deleted" (BOOLEAN), optional; default FALSE if not provided - // • "_id" (BIGINT) for each table link + // • one BIGINT per link, named by whoever declared it // • "account_id" (BIGINT) on ACCOUNTING-enabled tables // // Type expectations by SQL type: @@ -250,7 +250,7 @@ message GetTableDataResponse { // Map of column_name → stringified value for: // - id, deleted // - all user-defined columns from the table definition - // - FK columns named "_id" for each table link + // - one column per link, named by whoever declared it // - account_id for ACCOUNTING-enabled tables // // All values are returned as TEXT via col::TEXT and COALESCEed to empty string diff --git a/common/src/proto/descriptor.bin b/common/src/proto/descriptor.bin index 5ef1060e..a0411d5e 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 227fa3ef..7c2078a2 100644 --- a/common/src/proto/komp_ac.table_definition.rs +++ b/common/src/proto/komp_ac.table_definition.rs @@ -6,16 +6,18 @@ pub struct PostTableDefinitionRequest { /// Table name to create inside the target profile. /// Must be lowercase, alphanumeric with underscores, /// start with a letter, and be \<= 63 chars. - /// Forbidden names: "id", "deleted", "created_at", "row_revision", or ending in "\_id". + /// Forbidden names: "id", "deleted", "created_at", "row_revision" -- the + /// system columns, which share one namespace with table names wherever the + /// two are named side by side. The "\_id" suffix is allowed. #[prost(string, tag = "1")] pub table_name: ::prost::alloc::string::String, - /// List of user-defined columns (adds to system/id/fk columns). + /// The table's columns, including its links. System columns are added on top. #[prost(message, repeated, tag = "3")] pub columns: ::prost::alloc::vec::Vec, - /// List of column names to be indexed (must match existing user-defined columns). - /// Indexes can target only user-defined columns; system columns ("id", "deleted", - /// "created_at", "row_revision") and automatically generated foreign key ("\*\_id") columns already - /// have indexes. Requests trying to index those columns are rejected. + /// Column names to index, matching names declared above. System columns + /// ("id", "deleted", "created_at", "row_revision") already have indexes, and + /// a LINK column is indexed when it is created. Requests naming either are + /// rejected. #[prost(string, repeated, tag = "4")] pub indexes: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, /// Name of profile (Postgres schema) where the table will be created. @@ -106,9 +108,13 @@ pub struct AddTableColumnsRequest { #[derive(serde::Serialize, serde::Deserialize)] #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct ColumnDefinition { - /// Column name that follows the same validation rules as table_name. - /// Must be lowercase, start with a letter, no uppercase characters, - /// and cannot be "id", "deleted", "created_at", "row_revision", or end with "\_id". + /// Must be lowercase, start with a letter, and use only lowercase letters, + /// digits and underscores. + /// + /// Cannot be "id", "deleted", "created_at" or "row_revision": those are + /// system columns, and a data request names system and user columns in one + /// namespace. Any other name is free, including one ending in "\_id" -- no + /// column name is derived from a table name, so nothing collides. #[prost(string, tag = "1")] pub name: ::prost::alloc::string::String, /// Logical column type. Supported values (case-insensitive): diff --git a/common/src/proto/komp_ac.table_script.rs b/common/src/proto/komp_ac.table_script.rs index 6eef1846..2099cdb5 100644 --- a/common/src/proto/komp_ac.table_script.rs +++ b/common/src/proto/komp_ac.table_script.rs @@ -127,6 +127,18 @@ pub struct ScriptDependency { /// Relationship table used to match the owner row to the related collection. #[prost(string, tag = "5")] pub via_table: ::prost::alloc::string::String, + /// Column of the current row holding the referenced row's id: the link this + /// dependency travels, resolved when the script was saved. Empty when no link + /// is travelled -- a self-reference, or an aggregate, which follows a path + /// instead. A table may link to one target several times, so this is the only + /// thing that says which link was meant; never derive it from target_table. + #[prost(string, tag = "6")] + pub link_column: ::prost::alloc::string::String, + /// The first argument exactly as the script wrote it: a link alias, or the + /// target table's name where one link makes that unambiguous. The Steel + /// runtime looks its inputs up by this text. + #[prost(string, tag = "7")] + pub name_in_script: ::prost::alloc::string::String, } /// Identifies the active form row whose external Steel inputs must be hydrated. #[derive(Clone, PartialEq, ::prost::Message)] diff --git a/common/src/proto/komp_ac.table_structure.rs b/common/src/proto/komp_ac.table_structure.rs index 6f66e7d1..1d17cade 100644 --- a/common/src/proto/komp_ac.table_structure.rs +++ b/common/src/proto/komp_ac.table_structure.rs @@ -26,7 +26,7 @@ pub struct GetTableStructureResponse { pub struct TableStructureResponse { /// Columns of the physical table, including system columns (id, deleted, /// created_at, row_revision), user-defined columns, and any foreign-key columns such as - /// "\\_id", plus the dedicated "account_id" column on + /// named by whoever declared each link, plus the dedicated "account_id" on /// ACCOUNTING-enabled tables. May be empty if the physical table is missing. #[prost(message, repeated, tag = "1")] pub columns: ::prost::alloc::vec::Vec, diff --git a/common/src/proto/komp_ac.tables_data.rs b/common/src/proto/komp_ac.tables_data.rs index 6835a69a..ef531db7 100644 --- a/common/src/proto/komp_ac.tables_data.rs +++ b/common/src/proto/komp_ac.tables_data.rs @@ -17,7 +17,7 @@ pub struct PostTableDataRequest { /// * User-defined columns from the table definition /// * System/FK columns: /// • "deleted" (BOOLEAN), optional; default FALSE if not provided - /// • "\\_id" (BIGINT) for each table link + /// • one BIGINT per link, named by whoever declared it /// • "account_id" (BIGINT) on ACCOUNTING-enabled tables /// /// Type expectations by SQL type: @@ -190,7 +190,7 @@ pub struct GetTableDataResponse { /// /// * id, deleted /// * all user-defined columns from the table definition - /// * FK columns named "\\_id" for each table link + /// * one column per link, named by whoever declared it /// * account_id for ACCOUNTING-enabled tables /// /// All values are returned as TEXT via col::TEXT and COALESCEed to empty string @@ -482,7 +482,7 @@ pub mod tables_data_client { /// * Validates profile and table definition /// * Returns all columns as strings (COALESCE(col::TEXT, '') AS col) /// including: id, deleted, row_revision, all user-defined columns, and FK columns - /// named "\\_id" for each table link, plus "account_id" on + /// named by whoever declared each link, plus "account_id" on /// ACCOUNTING-enabled tables /// * Fails with NOT_FOUND if record does not exist or is soft-deleted /// * If the physical table is missing but the definition exists, returns INTERNAL @@ -675,7 +675,7 @@ pub mod tables_data_server { /// * Validates profile and table definition /// * Returns all columns as strings (COALESCE(col::TEXT, '') AS col) /// including: id, deleted, row_revision, all user-defined columns, and FK columns - /// named "\\_id" for each table link, plus "account_id" on + /// named by whoever declared each link, plus "account_id" on /// ACCOUNTING-enabled tables /// * Fails with NOT_FOUND if record does not exist or is soft-deleted /// * If the physical table is missing but the definition exists, returns INTERNAL diff --git a/server b/server index 79e64a2b..c5ff2c81 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit 79e64a2bd56f064354e4fdb77ac0e4a25184dbf7 +Subproject commit c5ff2c81f1857b9958ffde1737729e149eeac8a6 diff --git a/web/src/schema/mod.rs b/web/src/schema/mod.rs index 662491b9..776f3900 100644 --- a/web/src/schema/mod.rs +++ b/web/src/schema/mod.rs @@ -670,12 +670,11 @@ pub(crate) fn validate_identifier( "{label} may only use lowercase letters, digits and underscores." )); } + // Only the system columns are reserved. The `_id` suffix is free: no + // column name is derived from a table name any more, so it collides with + // nothing. if reject_table_reserved - && (value == "id" - || value == "deleted" - || value == "created_at" - || value == "row_revision" - || value.ends_with("_id")) + && matches!(value, "id" | "deleted" | "created_at" | "row_revision") { return Some(format!("{label} uses a reserved name.")); } @@ -1127,8 +1126,6 @@ pub(crate) mod tests { draft.name_input = "Total".to_string(); assert!(draft.add_from_inputs().is_err()); - draft.name_input = "customer_id".to_string(); - assert!(draft.add_from_inputs().is_err()); draft.name_input = "created_at".to_string(); assert!(draft.add_from_inputs().is_err()); @@ -1334,3 +1331,27 @@ pub(crate) mod tests { assert_eq!(draft.selected_index_names(), vec!["number"]); } } + +#[cfg(test)] +mod link_alias_tests { + use super::validate_identifier; + + /// The `_id` suffix is an ordinary part of a column's name: nothing is + /// derived from a table name any more. + #[test] + fn a_column_name_may_end_in_id() { + assert_eq!(validate_identifier("external_id", "Column name", true), None); + } + + /// The system columns stay reserved, since they share one namespace with + /// user columns in a data request. + #[test] + fn the_system_columns_stay_reserved() { + for name in ["id", "deleted", "created_at", "row_revision"] { + assert!( + validate_identifier(name, "Column name", true).is_some(), + "`{name}` must stay reserved" + ); + } + } +}