From 22a0f42bc002f60003ad534f698eafe814b5affb Mon Sep 17 00:00:00 2001 From: EnRaiha <15997552+EnRaiha@users.noreply.github.com> Date: Tue, 15 Sep 2026 02:06:54 +0800 Subject: [PATCH] fix(sql): keep one cell per duplicate constant column `SELECT nextval('s'), nextval('s')` legally repeats an output name. The constant-result row is a JSON object keyed by column name, so the second cell overwrote the first and both wire columns rendered the last value. Key the payload and the output schema's lookup keys by the same unique per-column keys every response encoder derives, so each column keeps its own cell. --- .../sql_plan_convert/output_schema/build.rs | 31 ++++++++++++------- .../planner/sql_plan_convert/set_ops.rs | 10 ++++-- nodedb/tests/wire/cases/sql_sequences.rs | 31 +++++++++++++++++++ 3 files changed, 59 insertions(+), 13 deletions(-) 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" + ); +}