diff --git a/common/src/lib.rs b/common/src/lib.rs index fa2b7538..07f6b4ea 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -4,6 +4,7 @@ pub mod search; pub mod decimal; pub mod grpc_error; pub mod relationship; +pub mod system_column; pub mod proto { pub mod komp_ac { diff --git a/common/src/system_column.rs b/common/src/system_column.rs new file mode 100644 index 00000000..0a09c3cb --- /dev/null +++ b/common/src/system_column.rs @@ -0,0 +1,104 @@ +// common/src/system_column.rs +//! The columns the server puts on every managed table. +//! +//! They are the one exception to the rule that a physical column name never +//! leaves the server. A user column is stored under its ordinal and shown only +//! under its alias, so an unmapped name at the public boundary is a leak; these +//! have no alias to hide behind, because the server -- not the user -- chose +//! their names. Anything asking "is this name public?" or "may a user claim +//! this name?" is asking about this list, so the list lives here once and the +//! `CREATE TABLE` fragments that make it true live next to it. + +/// A column every managed table carries, named the same in Postgres and in the +/// public API. +pub struct SystemColumn { + pub name: &'static str, + /// The `CREATE TABLE` fragment that declares it. + pub definition: &'static str, +} + +/// Declared ahead of the user columns. +pub const LEADING_SYSTEM_COLUMNS: [SystemColumn; 3] = [ + SystemColumn { + name: "id", + definition: "id BIGSERIAL PRIMARY KEY", + }, + SystemColumn { + name: "deleted", + definition: "deleted BOOLEAN NOT NULL DEFAULT FALSE", + }, + SystemColumn { + name: "row_revision", + definition: "row_revision BIGINT NOT NULL DEFAULT 1", + }, +]; + +/// Declared after the user columns. +pub const TRAILING_SYSTEM_COLUMNS: [SystemColumn; 1] = [SystemColumn { + name: "created_at", + definition: "created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP", +}]; + +/// Present only on a table that references its profile's chart of accounts. +/// Its declaration names that profile's schema, so it is built where the +/// profile is known rather than spelled out here. +pub const ACCOUNT_REFERENCE_COLUMN: &str = "account_id"; + +/// Every system column name, whether or not the column is on a given table. +/// +/// A name is reserved for all tables even when only some tables carry the +/// column, so an alias means the same thing everywhere. +pub fn system_column_names() -> impl Iterator { + LEADING_SYSTEM_COLUMNS + .iter() + .chain(TRAILING_SYSTEM_COLUMNS.iter()) + .map(|column| column.name) + .chain(std::iter::once(ACCOUNT_REFERENCE_COLUMN)) +} + +/// Whether `name` is a system column: a name that is safe to show a client +/// as-is, and that a user may not claim for a column alias or a table. +pub fn is_system_column(name: &str) -> bool { + system_column_names().any(|system_name| system_name == name) +} + +/// The system column names in a message, as `'id', 'deleted', ...`. +pub fn system_column_name_list() -> String { + system_column_names() + .map(|name| format!("'{name}'")) + .collect::>() + .join(", ") +} + +#[cfg(test)] +mod tests { + use super::{ + ACCOUNT_REFERENCE_COLUMN, is_system_column, system_column_name_list, system_column_names, + }; + + #[test] + fn a_declared_column_is_a_system_column() { + for name in system_column_names() { + assert!(is_system_column(name), "{name} is declared but not public"); + } + } + + #[test] + fn a_conditional_column_is_reserved_on_every_table() { + assert!(is_system_column(ACCOUNT_REFERENCE_COLUMN)); + } + + #[test] + fn an_ordinal_is_not_a_system_column() { + assert!(!is_system_column("1")); + assert!(!is_system_column("column_1")); + } + + #[test] + fn the_name_list_reads_as_a_sentence_fragment() { + assert_eq!( + system_column_name_list(), + "'id', 'deleted', 'row_revision', 'created_at', 'account_id'" + ); + } +} diff --git a/search/src/lib.rs b/search/src/lib.rs index ec62032b..fb772ee9 100644 --- a/search/src/lib.rs +++ b/search/src/lib.rs @@ -10,6 +10,7 @@ use common::proto::komp_ac::search::{ SearchOrderDirection, SearchRequest, SearchResponse, search_response::Hit, }; use common::search::{SchemaFields, register_tokenizers, search_index_path}; +use common::system_column::is_system_column; use query_builder::{ ConstraintMode, SearchConstraint, SearchConstraintTarget, build_master_query, }; @@ -536,7 +537,7 @@ fn remap_json_to_display_names( for (key, value) in object { let final_key = match physical_to_display.get(&key) { Some(display_name) => display_name.clone(), - None if is_public_system_column(&key) => key, + None if is_system_column(&key) => key, None => { return Err(Status::failed_precondition( "A table column has no public alias mapping", @@ -551,13 +552,6 @@ fn remap_json_to_display_names( } } -fn is_public_system_column(name: &str) -> bool { - matches!( - name, - "id" | "deleted" | "created_at" | "row_revision" | "account_id" - ) -} - /// One value per display column, positionally aligned with them, so a column /// that is NULL for this row stays visible as an empty slot. fn row_display_values(value: &serde_json::Value, columns: &[String]) -> Vec { @@ -613,7 +607,7 @@ async fn resolve_order_column( .find(|(_, display)| display.eq_ignore_ascii_case(&requested_column)) .map(|(physical, _)| physical.clone()) .or_else(|| { - is_public_system_column(&requested_column).then(|| requested_column.clone()) + is_system_column(&requested_column).then(|| requested_column.clone()) }) .ok_or_else(|| { Status::invalid_argument(format!( diff --git a/server b/server index 585ae28e..1fe9ffa7 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit 585ae28e4f26eb10e5f27d3809a9a5e8e71d2ca1 +Subproject commit 1fe9ffa75942f81d511506d4855f66e945071d69