diff --git a/search/src/lib.rs b/search/src/lib.rs index e28f8cf7..222731aa 100644 --- a/search/src/lib.rs +++ b/search/src/lib.rs @@ -1,6 +1,6 @@ mod query_builder; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::path::Path; use std::sync::{Arc, Mutex}; @@ -32,6 +32,118 @@ const DEFAULT_LIST_LIMIT: usize = 5; const SEARCH_SCORE_RELATIVE_FLOOR: f32 = 0.25; const SEARCH_SCORE_GROUP_WIDTH: f32 = 0.25; +pub struct AccountPathProjection { + paths: HashMap, + account_column: Option, + accounts_table: bool, +} + +impl AccountPathProjection { + pub async fn load( + pool: &PgPool, + profile_name: &str, + table_name: &str, + physical_to_display: &HashMap, + ) -> Result { + let account_column = physical_to_display + .get(common::system_column::ACCOUNT_REFERENCE_COLUMN) + .cloned(); + let accounts_table = table_name == "accounts"; + if account_column.is_none() && !accounts_table { + return Ok(Self { + paths: HashMap::new(), + account_column, + accounts_table, + }); + } + + let qualified_accounts = format!( + "\"{}\".\"accounts\"", + profile_name.replace('"', "\"\""), + ); + let rows = sqlx::query(AssertSqlSafe(format!( + "SELECT id, segment, parent_account_id FROM {qualified_accounts} WHERE deleted = FALSE" + ))) + .fetch_all(pool) + .await + .map_err(|error| Status::internal(format!("Account path lookup failed: {error}")))?; + let mut nodes = BTreeMap::new(); + for row in rows { + let id: i64 = row.try_get("id").map_err(|error| { + Status::internal(format!("Account id read failed: {error}")) + })?; + let segment: String = row.try_get("segment").map_err(|error| { + Status::internal(format!("Account segment read failed: {error}")) + })?; + let parent: Option = row.try_get("parent_account_id").map_err(|error| { + Status::internal(format!("Account parent read failed: {error}")) + })?; + nodes.insert(id, (segment, parent)); + } + let mut paths = HashMap::new(); + for &id in nodes.keys() { + let mut segments = Vec::new(); + let mut visited = BTreeSet::new(); + let mut current = Some(id); + while let Some(node_id) = current { + if !visited.insert(node_id) { + return Err(Status::internal("Stored account hierarchy contains a cycle")); + } + let (segment, parent) = nodes.get(&node_id).ok_or_else(|| { + Status::internal(format!("Account {node_id} was not found")) + })?; + segments.push(segment.clone()); + current = *parent; + } + segments.reverse(); + paths.insert(id, segments.join("/")); + } + Ok(Self { + paths, + account_column, + accounts_table, + }) + } + + pub fn apply(&self, row_id: i64, value: &mut serde_json::Value) -> Result<(), Status> { + let Some(object) = value.as_object_mut() else { + return Ok(()); + }; + if let Some(column) = &self.account_column { + if let Some(stored) = object.get(column).filter(|value| !value.is_null()) { + let account_id = match stored { + serde_json::Value::Number(value) => value.as_i64(), + serde_json::Value::String(value) => value.parse().ok(), + _ => None, + } + .ok_or_else(|| Status::internal("Stored account reference is invalid"))?; + let path = self.paths.get(&account_id).ok_or_else(|| { + Status::internal(format!("Account {account_id} was not found")) + })?; + object.insert(column.clone(), serde_json::Value::String(path.clone())); + } + } + if self.accounts_table { + let path = self.paths.get(&row_id).ok_or_else(|| { + Status::internal(format!("Account {row_id} was not found")) + })?; + object.insert( + common::system_column::ACCOUNT_API_COLUMN.to_string(), + serde_json::Value::String(path.clone()), + ); + } + Ok(()) + } + + pub fn row_display_columns(&self, columns: &[String]) -> Vec { + if self.accounts_table { + vec![common::system_column::ACCOUNT_API_COLUMN.to_string()] + } else { + columns.to_vec() + } + } +} + pub struct SearcherService { pub pool: PgPool, profiles: Mutex>>, @@ -1243,7 +1355,16 @@ async fn fetch_ordered_rows( ) -> Result, Status> { let physical_to_display = table_physical_to_display_map(pool, profile_name, table_name).await?; let internal_columns = table_internal_column_names(pool, profile_name, table_name).await?; - let display_columns = table_row_display_columns(pool, profile_name, table_name).await?; + let account_projection = AccountPathProjection::load( + pool, + profile_name, + table_name, + &physical_to_display, + ) + .await?; + let display_columns = account_projection.row_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?, @@ -1286,11 +1407,12 @@ async fn fetch_ordered_rows( .get("version") .and_then(|value| value.as_i64()) .unwrap_or(0); - let json_data = remap_json_to_display_names( + let mut json_data = remap_json_to_display_names( json_data, &physical_to_display, &internal_columns, )?; + account_projection.apply(id, &mut json_data)?; let row_display_values = row_display_values(&json_data, &display_columns); Ok(Hit { id, @@ -1531,7 +1653,16 @@ async fn run_search( table_physical_to_display_map(pool, profile_name, &table_name).await?; let internal_columns = table_internal_column_names(pool, profile_name, &table_name).await?; - let display_columns = table_row_display_columns(pool, profile_name, &table_name).await?; + let account_projection = AccountPathProjection::load( + pool, + profile_name, + &table_name, + &physical_to_display, + ) + .await?; + let display_columns = account_projection.row_display_columns( + &table_row_display_columns(pool, profile_name, &table_name).await?, + ); let qualified_table = qualified_visible_table(pool, profile_name, &table_name).await?; let pg_ids = candidate_rows.iter().map(|item| item.0).collect::>(); let revisions_by_id = candidate_rows.into_iter().collect::>(); @@ -1555,11 +1686,12 @@ async fn run_search( continue; } let version = json_data.get("version").and_then(|value| value.as_i64()).unwrap_or(0); - let json_data = remap_json_to_display_names( + let mut json_data = remap_json_to_display_names( json_data, &physical_to_display, &internal_columns, )?; + account_projection.apply(id, &mut json_data)?; let display_values = row_display_values(&json_data, &display_columns); content_map.insert(search_row_key(&table_name, id), ( json_data.to_string(), @@ -1587,7 +1719,16 @@ async fn run_search( table_physical_to_display_map(pool, profile_name, &table_name).await?; let internal_columns = table_internal_column_names(pool, profile_name, &table_name).await?; - let display_columns = table_row_display_columns(pool, profile_name, &table_name).await?; + let account_projection = AccountPathProjection::load( + pool, + profile_name, + &table_name, + &physical_to_display, + ) + .await?; + let display_columns = account_projection.row_display_columns( + &table_row_display_columns(pool, profile_name, &table_name).await?, + ); let table_definition_ids = archives.iter().map(|item| item.0).collect::>(); let row_ids = archives.iter().map(|item| item.1).collect::>(); let versions = archives.iter().map(|item| item.2).collect::>(); @@ -1635,11 +1776,12 @@ async fn run_search( row_id, version, ); - let json_data = remap_json_to_display_names( + let mut json_data = remap_json_to_display_names( snapshot, &physical_to_display, &internal_columns, )?; + account_projection.apply(row_id, &mut json_data)?; let display_values = row_display_values(&json_data, &display_columns); content_map.insert(row_key, ( json_data.to_string(), @@ -1683,7 +1825,16 @@ async fn fetch_ordered_candidate_rows( ) -> Result, Status> { let physical_to_display = table_physical_to_display_map(pool, profile_name, table_name).await?; let internal_columns = table_internal_column_names(pool, profile_name, table_name).await?; - let display_columns = table_row_display_columns(pool, profile_name, table_name).await?; + let account_projection = AccountPathProjection::load( + pool, + profile_name, + table_name, + &physical_to_display, + ) + .await?; + let display_columns = account_projection.row_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 qualified_table = qualified_visible_table(pool, profile_name, table_name).await?; @@ -1754,11 +1905,12 @@ async fn fetch_ordered_candidate_rows( .get("version") .and_then(|value| value.as_i64()) .unwrap_or(0); - let json_data = remap_json_to_display_names( + let mut json_data = remap_json_to_display_names( json_data, &physical_to_display, &internal_columns, )?; + account_projection.apply(id, &mut json_data)?; let display_values = row_display_values(&json_data, &display_columns); Ok(Hit { id, @@ -1813,6 +1965,35 @@ mod tests { assert_eq!(mapped, serde_json::json!({"customer": "Acme", "id": 4})); } + #[test] + fn account_projection_replaces_internal_ids_with_public_paths() { + let projection = AccountPathProjection { + paths: HashMap::from([(3, "600/10".to_string())]), + account_column: Some("account".to_string()), + accounts_table: false, + }; + let mut value = serde_json::json!({"account": 3, "name": "posting"}); + + projection.apply(9, &mut value).unwrap(); + + assert_eq!(value, serde_json::json!({"account": "600/10", "name": "posting"})); + } + + #[test] + fn accounts_search_exposes_and_displays_the_complete_path() { + let projection = AccountPathProjection { + paths: HashMap::from([(3, "600/10".to_string())]), + account_column: None, + accounts_table: true, + }; + let mut value = serde_json::json!({"segment": "10", "parent_account_id": 2}); + + projection.apply(3, &mut value).unwrap(); + + assert_eq!(value["account"], "600/10"); + assert_eq!(projection.row_display_columns(&["segment".to_string()]), ["account"]); + } + #[test] fn count_collector_returns_zero_documents_for_an_empty_index() { let schema = create_search_schema(); diff --git a/server b/server index d0504ad2..f83ebc80 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit d0504ad2a963671674eae1ec93e03bc5fc0c0f1b +Subproject commit f83ebc802a1847a42bf2b193b7e2f31e8d5c5a74