alias is surface, real column name is internal

This commit is contained in:
Priec
2026-08-08 18:01:57 +02:00
parent 58b7d0b5fb
commit 8881041740
2 changed files with 95 additions and 35 deletions

View File

@@ -438,11 +438,13 @@ async fn resolve_constraint_targets(
.map_err(|e| Status::internal(format!("Column mapping lookup failed: {}", e)))?; .map_err(|e| Status::internal(format!("Column mapping lookup failed: {}", e)))?;
if rows.is_empty() { if rows.is_empty() {
let target = SearchConstraintTarget { return Err(Status::invalid_argument(format!(
table_name: table_filter.map(str::to_string), "Column alias '{}' was not found{}",
column: column.to_string(), column,
}; table_filter
return Ok(vec![target]); .map(|table_name| format!(" in table '{}'", table_name))
.unwrap_or_default()
)));
} }
let mut seen = HashSet::new(); let mut seen = HashSet::new();
@@ -527,20 +529,35 @@ async fn table_row_display_columns(
fn remap_json_to_display_names( fn remap_json_to_display_names(
value: serde_json::Value, value: serde_json::Value,
physical_to_display: &HashMap<String, String>, physical_to_display: &HashMap<String, String>,
) -> serde_json::Value { ) -> Result<serde_json::Value, Status> {
match value { match value {
serde_json::Value::Object(object) => { serde_json::Value::Object(object) => {
let mut remapped = serde_json::Map::with_capacity(object.len()); let mut remapped = serde_json::Map::with_capacity(object.len());
for (key, value) in object { for (key, value) in object {
let final_key = physical_to_display.get(&key).cloned().unwrap_or(key); let final_key = match physical_to_display.get(&key) {
Some(display_name) => display_name.clone(),
None if is_public_system_column(&key) => key,
None => {
return Err(Status::failed_precondition(
"A table column has no public alias mapping",
));
}
};
remapped.insert(final_key, value); remapped.insert(final_key, value);
} }
serde_json::Value::Object(remapped) Ok(serde_json::Value::Object(remapped))
} }
other => other, other => Ok(other),
} }
} }
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 /// One value per display column, positionally aligned with them, so a column
/// that is NULL for this row stays visible as an empty slot. /// that is NULL for this row stays visible as an empty slot.
fn row_display_values(value: &serde_json::Value, columns: &[String]) -> Vec<String> { fn row_display_values(value: &serde_json::Value, columns: &[String]) -> Vec<String> {
@@ -593,12 +610,17 @@ async fn resolve_order_column(
table_physical_to_display_map(pool, profile_name, table_name).await?; table_physical_to_display_map(pool, profile_name, table_name).await?;
let physical_column = physical_to_display let physical_column = physical_to_display
.iter() .iter()
.find(|(physical, display)| { .find(|(_, display)| display.eq_ignore_ascii_case(&requested_column))
physical.eq_ignore_ascii_case(&requested_column)
|| display.eq_ignore_ascii_case(&requested_column)
})
.map(|(physical, _)| physical.clone()) .map(|(physical, _)| physical.clone())
.unwrap_or_else(|| requested_column.clone()); .or_else(|| {
is_public_system_column(&requested_column).then(|| requested_column.clone())
})
.ok_or_else(|| {
Status::invalid_argument(format!(
"Column alias '{}' was not found in table '{}.{}'",
requested_column, profile_name, table_name
))
})?;
let physical_column = sqlx::query_scalar::<_, String>( let physical_column = sqlx::query_scalar::<_, String>(
r#" r#"
@@ -698,15 +720,21 @@ async fn fetch_ordered_rows(
.await .await
.map_err(|e| Status::internal(format!("DB query for default results failed: {}", e)))?; .map_err(|e| Status::internal(format!("DB query for default results failed: {}", e)))?;
Ok(rows rows
.into_iter() .into_iter()
.map(|row| { .map(|row| -> Result<Hit, Status> {
let id: i64 = row.try_get("id").unwrap_or_default(); let id: i64 = row
let json_data: serde_json::Value = row.try_get("data").unwrap_or_default(); .try_get("id")
let position: i64 = row.try_get("picker_position").unwrap_or_default(); .map_err(|error| Status::internal(format!("Search id read failed: {}", error)))?;
let json_data = remap_json_to_display_names(json_data, &physical_to_display); let json_data: serde_json::Value = row.try_get("data").map_err(|error| {
Status::internal(format!("Search row read failed: {}", error))
})?;
let position: i64 = row.try_get("picker_position").map_err(|error| {
Status::internal(format!("Search position read failed: {}", error))
})?;
let json_data = remap_json_to_display_names(json_data, &physical_to_display)?;
let row_display_values = row_display_values(&json_data, &display_columns); let row_display_values = row_display_values(&json_data, &display_columns);
Hit { Ok(Hit {
id, id,
score: 0.0, score: 0.0,
content_json: json_data.to_string(), content_json: json_data.to_string(),
@@ -714,9 +742,9 @@ async fn fetch_ordered_rows(
row_display_values, row_display_values,
row_display_columns: display_columns.clone(), row_display_columns: display_columns.clone(),
position: u64::try_from(position).ok(), position: u64::try_from(position).ok(),
}
}) })
.collect()) })
.collect()
} }
async fn run_search( async fn run_search(
@@ -841,7 +869,7 @@ async fn run_search(
for row in rows { for row in rows {
let id: i64 = row.try_get("id").unwrap_or_default(); 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: serde_json::Value = row.try_get("data").unwrap_or_default();
let json_data = remap_json_to_display_names(json_data, &physical_to_display); let json_data = remap_json_to_display_names(json_data, &physical_to_display)?;
let display_values = row_display_values(&json_data, &display_columns); let display_values = row_display_values(&json_data, &display_columns);
content_map.insert( content_map.insert(
(table_name.clone(), id), (table_name.clone(), id),
@@ -923,16 +951,24 @@ async fn fetch_ordered_candidate_rows(
.await .await
.map_err(|e| Status::internal(format!("Ordered search query failed: {}", e)))?; .map_err(|e| Status::internal(format!("Ordered search query failed: {}", e)))?;
Ok(rows rows
.into_iter() .into_iter()
.map(|row| { .map(|row| -> Result<Hit, Status> {
let id: i64 = row.try_get("id").unwrap_or_default(); let id: i64 = row
let json_data: serde_json::Value = row.try_get("data").unwrap_or_default(); .try_get("id")
let position: i64 = row.try_get("picker_position").unwrap_or_default(); .map_err(|error| Status::internal(format!("Search id read failed: {}", error)))?;
let score: f32 = row.try_get("candidate_score").unwrap_or_default(); let json_data: serde_json::Value = row.try_get("data").map_err(|error| {
let json_data = remap_json_to_display_names(json_data, &physical_to_display); Status::internal(format!("Search row read failed: {}", error))
})?;
let position: i64 = row.try_get("picker_position").map_err(|error| {
Status::internal(format!("Search position read failed: {}", error))
})?;
let score: f32 = row.try_get("candidate_score").map_err(|error| {
Status::internal(format!("Search score read failed: {}", error))
})?;
let json_data = remap_json_to_display_names(json_data, &physical_to_display)?;
let display_values = row_display_values(&json_data, &display_columns); let display_values = row_display_values(&json_data, &display_columns);
Hit { Ok(Hit {
id, id,
score, score,
content_json: json_data.to_string(), content_json: json_data.to_string(),
@@ -940,9 +976,9 @@ async fn fetch_ordered_candidate_rows(
row_display_values: display_values, row_display_values: display_values,
row_display_columns: display_columns.clone(), row_display_columns: display_columns.clone(),
position: u64::try_from(position).ok(), position: u64::try_from(position).ok(),
}
}) })
.collect()) })
.collect()
} }
#[tonic::async_trait] #[tonic::async_trait]
@@ -960,6 +996,30 @@ mod tests {
use super::*; use super::*;
use common::proto::komp_ac::search::{SearchOrder, SearchOrderDirection}; use common::proto::komp_ac::search::{SearchOrder, SearchOrderDirection};
#[test]
fn search_response_mapping_exposes_aliases_only() {
let mappings = HashMap::from([("1".to_string(), "customer".to_string())]);
let mapped = remap_json_to_display_names(
serde_json::json!({"1": "Acme", "id": 4}),
&mappings,
)
.unwrap();
assert_eq!(mapped, serde_json::json!({"customer": "Acme", "id": 4}));
}
#[test]
fn search_response_mapping_fails_instead_of_leaking_a_real_name() {
let error = remap_json_to_display_names(
serde_json::json!({"1": "Acme", "2": "hidden"}),
&HashMap::from([("1".to_string(), "customer".to_string())]),
)
.unwrap_err();
assert_eq!(error.code(), tonic::Code::FailedPrecondition);
assert!(!error.message().contains("'2'"));
}
fn request(order: Option<SearchOrder>, table_name: Option<&str>) -> SearchRequest { fn request(order: Option<SearchOrder>, table_name: Option<&str>) -> SearchRequest {
SearchRequest { SearchRequest {
profile_name: "finance".to_string(), profile_name: "finance".to_string(),

2
server

Submodule server updated: 598521ccd4...585ae28e4f