From 76a5f7619435cc72608a2007291c61fd9fc1ef9b Mon Sep 17 00:00:00 2001 From: Priec Date: Wed, 29 Jul 2026 11:34:08 +0200 Subject: [PATCH] multirow reference in the client --- client | 2 +- common/proto/search.proto | 6 +- common/proto/search2.proto | 4 +- common/proto/table_definition.proto | 15 ++-- common/proto/tables_data.proto | 9 +-- common/src/proto/descriptor.bin | Bin 135425 -> 135717 bytes common/src/proto/komp_ac.search.rs | 12 ++-- common/src/proto/komp_ac.search2.rs | 12 ++-- common/src/proto/komp_ac.table_definition.rs | 25 ++++--- common/src/proto/komp_ac.tables_data.rs | 13 ++-- graphs/src/pages/add_table/state.rs | 17 ++--- graphs/src/pages/add_table/ui.rs | 4 +- graphs/src/pages/admin/admin/loader.rs | 2 +- graphs/src/pages/admin/admin/state.rs | 2 +- graphs/src/pages/admin/admin/ui.rs | 2 +- search/src/lib.rs | 68 +++++++++++-------- server | 2 +- 17 files changed, 105 insertions(+), 90 deletions(-) diff --git a/client b/client index 05e4d39..8605da5 160000 --- a/client +++ b/client @@ -1 +1 @@ -Subproject commit 05e4d398d210e60fec1197beb95fcea898caa64a +Subproject commit 8605da5f4869ab05d91b389e69f7f4719ce6e4e1 diff --git a/common/proto/search.proto b/common/proto/search.proto index 344220a..edf8eb7 100644 --- a/common/proto/search.proto +++ b/common/proto/search.proto @@ -44,9 +44,9 @@ message SearchResponse { float score = 2; string content_json = 3; string table_name = 4; - // Configured human-readable value for this row. - string row_display_value = 5; - string row_display_column = 6; + // Configured human-readable values for this row, in display order. + repeated string row_display_values = 5; + repeated string row_display_columns = 6; optional uint64 position = 7; } repeated Hit hits = 1; diff --git a/common/proto/search2.proto b/common/proto/search2.proto index 5becf89..1d5285a 100644 --- a/common/proto/search2.proto +++ b/common/proto/search2.proto @@ -40,8 +40,8 @@ message Search2Response { int64 id = 1; string content_json = 2; // No score - this is SQL-based optional string match_info = 3; // Info about which columns matched - string row_display_value = 4; // Configured human-readable value for this row - string row_display_column = 5; + repeated string row_display_values = 4; // Configured human-readable values for this row + repeated string row_display_columns = 5; } repeated Hit hits = 1; int32 total_count = 2; // Total matching records (for pagination) diff --git a/common/proto/table_definition.proto b/common/proto/table_definition.proto index 37995f4..19361bf 100644 --- a/common/proto/table_definition.proto +++ b/common/proto/table_definition.proto @@ -86,9 +86,10 @@ message PostTableDefinitionRequest { // ISO-4217 base currency used by every MONEY column in this table. string base_currency = 6; - // Column whose value identifies a row to users in pickers. "id" is always - // valid; otherwise this must name one of the user-defined columns above. - string row_display_column = 7; + // Columns whose values identify a row to users in pickers, in the order + // they are shown. Each must name one of the user-defined columns above. + // Empty means the row is identified by its id alone. + repeated string row_display_columns = 7; // ISO-4217 currency the profile keeps its accounting in. A profile is one // accounting entity and keeps one set of books, so this is required only when @@ -117,7 +118,7 @@ message CreateInvoiceTemplateTableRequest { string table_name = 2; string typst_source = 3; string base_currency = 4; - string row_display_column = 5; + repeated string row_display_columns = 5; } // One physical dynamic table created for an invoice template scope. The root @@ -225,8 +226,8 @@ message ProfileTreeResponse { // Other tables this one references (based on link definitions only). repeated string depends_on = 3; - // Column whose value is used as the human-readable row label. - string row_display_column = 4; + // Columns whose values make up the human-readable row label, in order. + repeated string row_display_columns = 4; // "dynamic" for user-defined tables, "system" for backend-managed tables. string table_kind = 5; @@ -304,7 +305,7 @@ message TableDetail { repeated ColumnDefinition columns = 3; repeated ScriptInfo scripts = 4; string base_currency = 5; - string row_display_column = 6; + repeated string row_display_columns = 6; map column_behaviors = 7; string table_kind = 8; } diff --git a/common/proto/tables_data.proto b/common/proto/tables_data.proto index b70fd7b..056b1e6 100644 --- a/common/proto/tables_data.proto +++ b/common/proto/tables_data.proto @@ -271,10 +271,11 @@ message GetTableDataResponse { // (NULL becomes ""). The row is returned only if deleted = FALSE. map data = 1; - // Configured human-readable value for this row. Empty when that column is - // NULL/empty; clients should then visibly fall back to the row id. - string row_display_value = 2; - string row_display_column = 3; + // Configured human-readable values for this row, in display order. A value + // is empty when its column is NULL/empty; an empty list means the row is + // identified by its id alone. + repeated string row_display_values = 2; + repeated string row_display_columns = 3; } // Count non-deleted rows. diff --git a/common/src/proto/descriptor.bin b/common/src/proto/descriptor.bin index c57fd5aa5034cbf092e9893a85e56dcd644f03c7..8673c3a189dd865c8e112ee05f43b92f9e011f47 100644 GIT binary patch delta 7351 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 diff --git a/common/src/proto/komp_ac.search.rs b/common/src/proto/komp_ac.search.rs index fccc0dd..1a7b073 100644 --- a/common/src/proto/komp_ac.search.rs +++ b/common/src/proto/komp_ac.search.rs @@ -50,11 +50,13 @@ pub mod search_response { pub content_json: ::prost::alloc::string::String, #[prost(string, tag = "4")] pub table_name: ::prost::alloc::string::String, - /// Configured human-readable value for this row. - #[prost(string, tag = "5")] - pub row_display_value: ::prost::alloc::string::String, - #[prost(string, tag = "6")] - pub row_display_column: ::prost::alloc::string::String, + /// Configured human-readable values for this row, in display order. + #[prost(string, repeated, tag = "5")] + pub row_display_values: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + #[prost(string, repeated, tag = "6")] + pub row_display_columns: ::prost::alloc::vec::Vec< + ::prost::alloc::string::String, + >, #[prost(uint64, optional, tag = "7")] pub position: ::core::option::Option, } diff --git a/common/src/proto/komp_ac.search2.rs b/common/src/proto/komp_ac.search2.rs index c006812..e9e455b 100644 --- a/common/src/proto/komp_ac.search2.rs +++ b/common/src/proto/komp_ac.search2.rs @@ -49,11 +49,13 @@ pub mod search2_response { /// Info about which columns matched #[prost(string, optional, tag = "3")] pub match_info: ::core::option::Option<::prost::alloc::string::String>, - /// Configured human-readable value for this row - #[prost(string, tag = "4")] - pub row_display_value: ::prost::alloc::string::String, - #[prost(string, tag = "5")] - pub row_display_column: ::prost::alloc::string::String, + /// Configured human-readable values for this row + #[prost(string, repeated, tag = "4")] + pub row_display_values: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + #[prost(string, repeated, tag = "5")] + pub row_display_columns: ::prost::alloc::vec::Vec< + ::prost::alloc::string::String, + >, } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] diff --git a/common/src/proto/komp_ac.table_definition.rs b/common/src/proto/komp_ac.table_definition.rs index 8ab477e..0dbd787 100644 --- a/common/src/proto/komp_ac.table_definition.rs +++ b/common/src/proto/komp_ac.table_definition.rs @@ -45,10 +45,11 @@ pub struct PostTableDefinitionRequest { /// ISO-4217 base currency used by every MONEY column in this table. #[prost(string, tag = "6")] pub base_currency: ::prost::alloc::string::String, - /// Column whose value identifies a row to users in pickers. "id" is always - /// valid; otherwise this must name one of the user-defined columns above. - #[prost(string, tag = "7")] - pub row_display_column: ::prost::alloc::string::String, + /// Columns whose values identify a row to users in pickers, in the order + /// they are shown. Each must name one of the user-defined columns above. + /// Empty means the row is identified by its id alone. + #[prost(string, repeated, tag = "7")] + pub row_display_columns: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, /// ISO-4217 currency the profile keeps its accounting in. A profile is one /// accounting entity and keeps one set of books, so this is required only when /// the request creates the profile, and is ignored afterwards. It is unrelated @@ -82,8 +83,8 @@ pub struct CreateInvoiceTemplateTableRequest { pub typst_source: ::prost::alloc::string::String, #[prost(string, tag = "4")] pub base_currency: ::prost::alloc::string::String, - #[prost(string, tag = "5")] - pub row_display_column: ::prost::alloc::string::String, + #[prost(string, repeated, tag = "5")] + pub row_display_columns: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, } /// One physical dynamic table created for an invoice template scope. The root /// has an empty collection_path and parent_table_name. Each \[\] scope names its @@ -205,9 +206,11 @@ pub mod profile_tree_response { /// Other tables this one references (based on link definitions only). #[prost(string, repeated, tag = "3")] pub depends_on: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, - /// Column whose value is used as the human-readable row label. - #[prost(string, tag = "4")] - pub row_display_column: ::prost::alloc::string::String, + /// Columns whose values make up the human-readable row label, in order. + #[prost(string, repeated, tag = "4")] + pub row_display_columns: ::prost::alloc::vec::Vec< + ::prost::alloc::string::String, + >, /// "dynamic" for user-defined tables, "system" for backend-managed tables. #[prost(string, tag = "5")] pub table_kind: ::prost::alloc::string::String, @@ -313,8 +316,8 @@ pub struct TableDetail { pub scripts: ::prost::alloc::vec::Vec, #[prost(string, tag = "5")] pub base_currency: ::prost::alloc::string::String, - #[prost(string, tag = "6")] - pub row_display_column: ::prost::alloc::string::String, + #[prost(string, repeated, tag = "6")] + pub row_display_columns: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, #[prost(map = "string, message", tag = "7")] pub column_behaviors: ::std::collections::HashMap< ::prost::alloc::string::String, diff --git a/common/src/proto/komp_ac.tables_data.rs b/common/src/proto/komp_ac.tables_data.rs index 0bf47fa..fa82b8d 100644 --- a/common/src/proto/komp_ac.tables_data.rs +++ b/common/src/proto/komp_ac.tables_data.rs @@ -216,12 +216,13 @@ pub struct GetTableDataResponse { ::prost::alloc::string::String, ::prost::alloc::string::String, >, - /// Configured human-readable value for this row. Empty when that column is - /// NULL/empty; clients should then visibly fall back to the row id. - #[prost(string, tag = "2")] - pub row_display_value: ::prost::alloc::string::String, - #[prost(string, tag = "3")] - pub row_display_column: ::prost::alloc::string::String, + /// Configured human-readable values for this row, in display order. A value + /// is empty when its column is NULL/empty; an empty list means the row is + /// identified by its id alone. + #[prost(string, repeated, tag = "2")] + pub row_display_values: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + #[prost(string, repeated, tag = "3")] + pub row_display_columns: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, } /// Count non-deleted rows. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] diff --git a/graphs/src/pages/add_table/state.rs b/graphs/src/pages/add_table/state.rs index 18fad18..3fda684 100644 --- a/graphs/src/pages/add_table/state.rs +++ b/graphs/src/pages/add_table/state.rs @@ -18,8 +18,8 @@ pub(crate) struct CreateTableForm { pub optional_links: String, #[serde(default)] pub base_currency: String, - #[serde(default = "default_row_display_column")] - pub row_display_column: String, + #[serde(default)] + pub row_display_columns: String, } pub(crate) struct AddTablePageState { @@ -121,10 +121,7 @@ impl CreateTableForm { indexes, profile_name, base_currency: if has_money { base_currency } else { String::new() }, - row_display_column: { - let value = self.row_display_column.trim(); - if value.is_empty() { "id".to_string() } else { value.to_string() } - }, + row_display_columns: comma_separated(&self.row_display_columns), }) } } @@ -138,10 +135,6 @@ fn comma_separated(value: &str) -> Vec { .collect() } -fn default_row_display_column() -> String { - "id".to_string() -} - #[cfg(test)] mod tests { use super::*; @@ -154,7 +147,7 @@ mod tests { columns: "number: text:indexed\namount: money:half-up,recompute".into(), required_links: "customer".into(), base_currency: "eur".into(), - row_display_column: "number".into(), + row_display_columns: "number, amount".into(), ..Default::default() } .into_request() @@ -164,7 +157,7 @@ mod tests { assert_eq!(request.links[0].linked_table_name, "customer"); assert!(request.links[0].required); assert_eq!(request.base_currency, "EUR"); - assert_eq!(request.row_display_column, "number"); + assert_eq!(request.row_display_columns, vec!["number", "amount"]); assert!(request.columns[1].recompute_on_dependency_change); } } diff --git a/graphs/src/pages/add_table/ui.rs b/graphs/src/pages/add_table/ui.rs index 36b6704..0f099e0 100644 --- a/graphs/src/pages/add_table/ui.rs +++ b/graphs/src/pages/add_table/ui.rs @@ -21,14 +21,14 @@ pub(crate) fn render_page(page: &AddTablePageState) -> String { .map(render_submission_error) .unwrap_or_default(); format!( - "Add table
Komp Accounting
← Admin panel

Table definition

Add table

Create a table through the existing gRPC table-definition service.

{error}
Cancel
", + "Add table
Komp Accounting
← Admin panel

Table definition

Add table

Create a table through the existing gRPC table-definition service.

{error}
Cancel
", crate::escape_html(&page.form.table_name), crate::escape_html(&page.form.columns), crate::escape_html(&page.form.indexed_columns), crate::escape_html(&page.form.base_currency), crate::escape_html(&page.form.required_links), crate::escape_html(&page.form.optional_links), - crate::escape_html(&page.form.row_display_column), + crate::escape_html(&page.form.row_display_columns), ) } diff --git a/graphs/src/pages/admin/admin/loader.rs b/graphs/src/pages/admin/admin/loader.rs index 72c9eb5..31875b0 100644 --- a/graphs/src/pages/admin/admin/loader.rs +++ b/graphs/src/pages/admin/admin/loader.rs @@ -69,7 +69,7 @@ pub(crate) async fn load_admin_page( .map(|table| TableView { name: table.name.clone(), depends_on: table.depends_on.clone(), - row_display_column: table.row_display_column.clone(), + row_display_columns: table.row_display_columns.clone(), }) .collect::>() }) diff --git a/graphs/src/pages/admin/admin/state.rs b/graphs/src/pages/admin/admin/state.rs index 87a3a61..18e1c53 100644 --- a/graphs/src/pages/admin/admin/state.rs +++ b/graphs/src/pages/admin/admin/state.rs @@ -26,7 +26,7 @@ pub(crate) struct ProfileView { pub(crate) struct TableView { pub name: String, pub depends_on: Vec, - pub row_display_column: String, + pub row_display_columns: Vec, } #[derive(Debug)] diff --git a/graphs/src/pages/admin/admin/ui.rs b/graphs/src/pages/admin/admin/ui.rs index 33081e1..f89d854 100644 --- a/graphs/src/pages/admin/admin/ui.rs +++ b/graphs/src/pages/admin/admin/ui.rs @@ -69,7 +69,7 @@ fn render_tables(page: &AdminPageState) -> String { crate::escape_html(&table.name), crate::escape_html(&table.name), crate::escape_html(&dependencies), - crate::escape_html(&table.row_display_column), + crate::escape_html(&table.row_display_columns.join(", ")), ) }) .collect() diff --git a/search/src/lib.rs b/search/src/lib.rs index fde5b71..294f894 100644 --- a/search/src/lib.rs +++ b/search/src/lib.rs @@ -504,14 +504,14 @@ async fn table_physical_to_display_map( Ok(mapping) } -async fn table_row_display_column( +async fn table_row_display_columns( pool: &PgPool, profile_name: &str, table_name: &str, -) -> Result { +) -> Result, Status> { sqlx::query_scalar( r#" - SELECT td.row_display_column + SELECT td.row_display_columns FROM schemas s JOIN table_definitions td ON td.schema_id = s.id WHERE s.name = $1 AND td.table_name = $2 @@ -521,7 +521,7 @@ async fn table_row_display_column( .bind(table_name) .fetch_one(pool) .await - .map_err(|e| Status::internal(format!("Row display column lookup failed: {}", e))) + .map_err(|e| Status::internal(format!("Row display columns lookup failed: {}", e))) } fn remap_json_to_display_names( @@ -541,13 +541,18 @@ fn remap_json_to_display_names( } } -fn row_display_value(value: &serde_json::Value, column: &str) -> String { - match value.get(column) { - Some(serde_json::Value::String(value)) => value.clone(), - Some(serde_json::Value::Number(value)) => value.to_string(), - Some(serde_json::Value::Bool(value)) => value.to_string(), - _ => String::new(), - } +/// 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 { + columns + .iter() + .map(|column| match value.get(column) { + Some(serde_json::Value::String(value)) => value.clone(), + Some(serde_json::Value::Number(value)) => value.to_string(), + Some(serde_json::Value::Bool(value)) => value.to_string(), + _ => String::new(), + }) + .collect() } enum ResolvedOrderColumn { @@ -573,8 +578,14 @@ async fn resolve_order_column( return Ok(ResolvedOrderColumn::Position); } - let requested_column = if requested_column.eq_ignore_ascii_case("row_display_column") { - table_row_display_column(pool, profile_name, table_name).await? + // Sorting by "the display column" means the first one: it is the part + // callers read left to right. A table with none sorts by id instead. + let requested_column = if requested_column.eq_ignore_ascii_case("row_display_columns") { + table_row_display_columns(pool, profile_name, table_name) + .await? + .into_iter() + .next() + .unwrap_or_else(|| "id".to_string()) } else { requested_column.to_string() }; @@ -661,7 +672,7 @@ async fn fetch_ordered_rows( offset: usize, ) -> Result, Status> { let physical_to_display = table_physical_to_display_map(pool, profile_name, table_name).await?; - let display_column = table_row_display_column(pool, profile_name, table_name).await?; + let display_columns = table_row_display_columns(pool, profile_name, table_name).await?; let (resolved_order, direction) = match order { Some(order) => ( resolve_order_column(pool, profile_name, table_name, &order.column).await?, @@ -694,14 +705,14 @@ async fn fetch_ordered_rows( let json_data: serde_json::Value = row.try_get("data").unwrap_or_default(); let position: i64 = row.try_get("picker_position").unwrap_or_default(); let json_data = remap_json_to_display_names(json_data, &physical_to_display); - let row_display_value = row_display_value(&json_data, &display_column); + let row_display_values = row_display_values(&json_data, &display_columns); Hit { id, score: 0.0, content_json: json_data.to_string(), table_name: table_name.to_string(), - row_display_value, - row_display_column: display_column.clone(), + row_display_values, + row_display_columns: display_columns.clone(), position: u64::try_from(position).ok(), } }) @@ -810,12 +821,13 @@ async fn run_search( .push(*pg_id); } - let mut content_map: HashMap<(String, i64), (String, String, String)> = HashMap::new(); + let mut content_map: HashMap<(String, i64), (String, Vec, Vec)> = + HashMap::new(); for (table_name, pg_ids) in ids_by_table { validate_identifier(&table_name, "table_name")?; let physical_to_display = table_physical_to_display_map(pool, profile_name, &table_name).await?; - let display_column = table_row_display_column(pool, profile_name, &table_name).await?; + let display_columns = table_row_display_columns(pool, profile_name, &table_name).await?; let sql = format!( "SELECT id, to_jsonb(t) AS data FROM {} t WHERE deleted = FALSE AND id = ANY($1)", qualify_profile_table(profile_name, &table_name) @@ -830,10 +842,10 @@ async fn run_search( let id: i64 = row.try_get("id").unwrap_or_default(); let json_data: serde_json::Value = row.try_get("data").unwrap_or_default(); let json_data = remap_json_to_display_names(json_data, &physical_to_display); - let display_value = row_display_value(&json_data, &display_column); + let display_values = row_display_values(&json_data, &display_columns); content_map.insert( (table_name.clone(), id), - (json_data.to_string(), display_value, display_column.clone()), + (json_data.to_string(), display_values, display_columns.clone()), ); } } @@ -843,13 +855,13 @@ async fn run_search( .filter_map(|(score, pg_id, table_name)| { content_map .get(&(table_name.clone(), pg_id)) - .map(|(content_json, row_display_value, row_display_column)| Hit { + .map(|(content_json, row_display_values, row_display_columns)| Hit { id: pg_id, score, content_json: content_json.clone(), table_name, - row_display_value: row_display_value.clone(), - row_display_column: row_display_column.clone(), + row_display_values: row_display_values.clone(), + row_display_columns: row_display_columns.clone(), position: None, }) }) @@ -866,7 +878,7 @@ async fn fetch_ordered_candidate_rows( offset: usize, ) -> Result, Status> { let physical_to_display = table_physical_to_display_map(pool, profile_name, table_name).await?; - let display_column = table_row_display_column(pool, profile_name, table_name).await?; + let display_columns = table_row_display_columns(pool, profile_name, table_name).await?; let resolved_order = resolve_order_column(pool, profile_name, table_name, &order.column).await?; let sql = format!( @@ -919,14 +931,14 @@ async fn fetch_ordered_candidate_rows( let position: i64 = row.try_get("picker_position").unwrap_or_default(); let score: f32 = row.try_get("candidate_score").unwrap_or_default(); let json_data = remap_json_to_display_names(json_data, &physical_to_display); - let display_value = row_display_value(&json_data, &display_column); + let display_values = row_display_values(&json_data, &display_columns); Hit { id, score, content_json: json_data.to_string(), table_name: table_name.to_string(), - row_display_value: display_value, - row_display_column: display_column.clone(), + row_display_values: display_values, + row_display_columns: display_columns.clone(), position: u64::try_from(position).ok(), } }) diff --git a/server b/server index a8bc888..01ce5a6 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit a8bc888a22ce38fc7871bbf882605bfd4edaf43d +Subproject commit 01ce5a63b7ffd8ccc6336cb508364e69c4d6b40b