diff --git a/nodedb/src/control/planner/sql_plan_convert/output_schema/build.rs b/nodedb/src/control/planner/sql_plan_convert/output_schema/build.rs index f7b9fa374..06733ce39 100644 --- a/nodedb/src/control/planner/sql_plan_convert/output_schema/build.rs +++ b/nodedb/src/control/planner/sql_plan_convert/output_schema/build.rs @@ -173,17 +173,26 @@ pub fn build_output_schema( let types = super::join_types::join_column_types(left, right, catalog, database_id); schema_from_projection(projection, &types, &[]) } - SqlPlan::ConstantResult { columns, .. } => OutputSchema { - columns: columns - .iter() - .map(|c| OutputColumn { - display_name: c.clone(), - lookup_key: c.clone(), - ty: DdlColType::Text, - }) - .collect(), - is_star: false, - }, + SqlPlan::ConstantResult { columns, .. } => { + // The row payload keys each cell by the unique per-column key + // (`cell_keys`), not the raw display name: two constant columns may + // share a name (`SELECT nextval('s'), nextval('s')`), and a single + // JSON object would collapse them. `display_name` keeps the + // client-facing name; `lookup_key` is the cell key. + let lookup_keys = crate::control::server::response_shape::project::cell_keys(columns); + OutputSchema { + columns: columns + .iter() + .zip(lookup_keys) + .map(|(c, lookup_key)| OutputColumn { + display_name: c.clone(), + lookup_key, + ty: DdlColType::Text, + }) + .collect(), + is_star: false, + } + } SqlPlan::Aggregate { input, group_by, diff --git a/nodedb/src/control/planner/sql_plan_convert/set_ops.rs b/nodedb/src/control/planner/sql_plan_convert/set_ops.rs index a9d435fe6..d952dc569 100644 --- a/nodedb/src/control/planner/sql_plan_convert/set_ops.rs +++ b/nodedb/src/control/planner/sql_plan_convert/set_ops.rs @@ -19,13 +19,19 @@ pub(super) fn convert_constant_result( tenant_id: TenantId, ctx: &ConvertContext, ) -> crate::Result> { + // A constant row is one JSON object, which cannot hold two cells under one + // key. `SELECT nextval('s'), nextval('s')` legally repeats an output name; + // keying both cells by the name would collapse them to the last value. Use + // the same unique per-column keys every response encoder derives, so each + // column keeps its own cell. + let cell_keys = crate::control::server::response_shape::project::cell_keys(columns); let mut obj = serde_json::Map::new(); - for (col, val) in columns.iter().zip(values.iter()) { + for ((_col, val), key) in columns.iter().zip(values.iter()).zip(cell_keys.iter()) { let json_val = match val { SqlValue::Null => serde_json::Value::Null, other => serde_json::Value::String(sql_value_to_string(other)), }; - obj.insert(col.clone(), json_val); + obj.insert(key.clone(), json_val); } let arr = serde_json::Value::Array(vec![serde_json::Value::Object(obj)]); let payload = nodedb_types::json_to_msgpack(&arr).map_err(|e| crate::Error::Serialization { diff --git a/nodedb/tests/wire/cases/sql_sequences.rs b/nodedb/tests/wire/cases/sql_sequences.rs index a14daefa6..68d27a6b0 100644 --- a/nodedb/tests/wire/cases/sql_sequences.rs +++ b/nodedb/tests/wire/cases/sql_sequences.rs @@ -426,3 +426,34 @@ async fn a_refused_accessor_produces_no_row_and_no_allocation() { "the refused statement must not have allocated, got {first:?}" ); } + +/// A from-less projection legally repeats an output name +/// (`SELECT nextval('s'), nextval('s')`). The constant row is a single JSON +/// object, so keying both cells by the display name collapsed them to the +/// last value and both wire columns rendered it. Each column must keep its +/// own cell. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn duplicate_constant_column_names_keep_their_own_cells() { + let server = TestServer::start().await; + server + .exec("CREATE SEQUENCE dup_cell_seq START 1 INCREMENT 1") + .await + .unwrap(); + + let rows = server + .query_rows("SELECT nextval('dup_cell_seq'), nextval('dup_cell_seq')") + .await + .expect("a from-less projection repeating an output name must return one row"); + + assert_eq!( + rows.len(), + 1, + "expected exactly one constant row, got {rows:?}" + ); + assert_eq!( + rows[0], + vec!["1".to_string(), "2".to_string()], + "each duplicate column must keep its own cell; keying both by the \ + display name collapses them to the last value" + ); +}