Skip to content

Commit 42a9993

Browse files
committed
fix(pgwire): keep both cells when two output columns share a name
`SELECT w.id, b.id FROM w JOIN b ON ...` returned the right table's value in BOTH columns. The join executor and planner were correct — the merged row carries `w.id` and `b.id` and `project_row` read each by its qualified lookup key — but the shaped row is a JSON map keyed by the column's DISPLAY name, and an unaliased qualified projection displays as its bare last segment. Both columns keyed on `id`, so the second insert overwrote the first and every encoder then re-read that one cell by name. Aliasing (`w.id AS w_id`) produced distinct display names and was unaffected, which is why the bug only showed on the unaliased spelling. Shaped rows now store each column's cell under a unique key from `response_shape::project::cell_keys` — the display name for the first occurrence, `<name>_<n>` for later duplicates, skipping any candidate that would shadow another column's name. The three consumers (pgwire non-streaming and streaming encoders, native conversion) derive the same keys, so they stay aligned. Wire column names are unchanged: duplicates still describe as `id`, `id`, matching PostgreSQL. For a duplicate-free column list the mapping is the identity, so nothing else changes. Tests: unit coverage for `cell_keys` and duplicate-name `project_row`; sql_join_correctness gains the unaliased and aliased join projections.
1 parent 4767407 commit 42a9993

6 files changed

Lines changed: 196 additions & 13 deletions

File tree

nodedb/src/control/server/native/dispatch/conversion.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,15 +146,18 @@ pub(crate) fn calvin_native_response(
146146
/// `json_to_value_display`; a column absent from a given row's map becomes
147147
/// `Value::Null`.
148148
pub(crate) fn to_native_columns_rows(shaped: &ShapedRows) -> (Vec<String>, Vec<Vec<Value>>) {
149+
// Cells live in the row maps under unique per-column keys (display names
150+
// may repeat across columns, e.g. `SELECT w.id, b.id`); derive the same
151+
// keys the shaper used so every column reads its own cell.
152+
let cell_keys = crate::control::server::response_shape::project::cell_keys(&shaped.columns);
149153
let rows = shaped
150154
.rows
151155
.iter()
152156
.map(|row| {
153-
shaped
154-
.columns
157+
cell_keys
155158
.iter()
156-
.map(|col| {
157-
row.get(col.as_str())
159+
.map(|key| {
160+
row.get(key.as_str())
158161
.map(json_to_value_display)
159162
.unwrap_or(Value::Null)
160163
})

nodedb/src/control/server/pgwire/handler/shape_encode.rs

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,22 +28,27 @@ use crate::control::server::response_shape::types::{DdlColType, ShapedRows};
2828

2929
use super::super::ddl_encode::col_type_to_field_with_format;
3030

31-
/// Encode one flat row object into a pgwire `DataRow`, using `columns` (in
32-
/// order) to look up cells in `row` and `column_types` (parallel to `columns`)
33-
/// to pick each cell's text rendering.
31+
/// Encode one flat row object into a pgwire `DataRow`, using `cell_keys` (in
32+
/// order) to look up cells in `row` and `column_types` (parallel to
33+
/// `cell_keys`) to pick each cell's text rendering.
34+
///
35+
/// `cell_keys` are the per-column unique row-map keys derived from the
36+
/// display names via `response_shape::project::cell_keys` — identical to the
37+
/// display names except where those repeat (`SELECT w.id, b.id`), in which
38+
/// case later duplicates carry a `_n` suffix so both cells survive the map.
3439
///
3540
/// Missing keys and explicit JSON `null` both encode as SQL NULL. Every other
3641
/// cell renders per its column type via [`encode_typed_cell`]; a
3742
/// missing/short `column_types` entry defaults to `Text`.
3843
pub(in crate::control::server::pgwire) fn encode_shaped_row(
3944
schema: &Arc<Vec<FieldInfo>>,
40-
columns: &[String],
45+
cell_keys: &[String],
4146
column_types: &[DdlColType],
4247
formats: &[FieldFormat],
4348
row: &serde_json::Map<String, serde_json::Value>,
4449
) -> PgWireResult<DataRow> {
4550
let mut encoder = DataRowEncoder::new(schema.clone());
46-
for (idx, name) in columns.iter().enumerate() {
51+
for (idx, name) in cell_keys.iter().enumerate() {
4752
let ct = column_types.get(idx).copied().unwrap_or(DdlColType::Text);
4853
let format = formats.get(idx).copied().unwrap_or(FieldFormat::Text);
4954
match row.get(name) {
@@ -159,9 +164,12 @@ pub(in crate::control::server::pgwire) fn shaped_query_response(
159164
.collect();
160165
let schema = Arc::new(fields);
161166

167+
// Cells live in the row maps under unique per-column keys (display names
168+
// may repeat across columns); derive the same keys the shaper used.
169+
let keys = crate::control::server::response_shape::project::cell_keys(&columns);
162170
let encoded_rows: Vec<PgWireResult<DataRow>> = rows
163171
.iter()
164-
.map(|row| encode_shaped_row(&schema, &columns, &column_types, formats, row))
172+
.map(|row| encode_shaped_row(&schema, &keys, &column_types, formats, row))
165173
.collect();
166174

167175
let response = Response::Query(QueryResponse::new(

nodedb/src/control/server/pgwire/handler/stream_response.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ pub(crate) fn streaming_shaped_response(
107107
.iter()
108108
.map(|c| c.display_name.clone())
109109
.collect();
110+
// Cells live in the shaped row maps under unique per-column keys
111+
// (display names may repeat across columns, e.g. `SELECT w.id, b.id`);
112+
// derive the same keys the shaper used so every column reads its own cell.
113+
let cell_keys = crate::control::server::response_shape::project::cell_keys(&display_columns);
110114
// Advertise each projected column's real catalog type so the streaming
111115
// path's RowDescription OIDs match the non-streaming `shaped_query_response`
112116
// (and the extended-query Describe path); `column_types` also drives the
@@ -160,7 +164,7 @@ pub(crate) fn streaming_shaped_response(
160164
break;
161165
}
162166
let encoded =
163-
encode_shaped_row(&row_schema, &display_columns, &column_types, &row_formats, row)?;
167+
encode_shaped_row(&row_schema, &cell_keys, &column_types, &row_formats, row)?;
164168
emitted += 1;
165169
yield encoded;
166170
}

nodedb/src/control/server/response_shape/compose.rs

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -244,9 +244,15 @@ pub fn shape_decoded_rows(decoded: &JsonValue, projection: Option<&OutputSchema>
244244
let lookup_keys: Vec<String> = s.columns.iter().map(|c| c.lookup_key.clone()).collect();
245245
let display_names: Vec<String> =
246246
s.columns.iter().map(|c| c.display_name.clone()).collect();
247+
// Row cells are stored under per-column unique keys, not display
248+
// names: duplicate display names (`SELECT w.id, b.id` → `id`,
249+
// `id`) would collide in the row map and collapse both wire
250+
// columns to the last value. Encoders re-derive the same keys via
251+
// `cell_keys` when reading cells.
252+
let keys = super::project::cell_keys(&display_names);
247253
let projected_rows = rows
248254
.iter()
249-
.map(|row| project_row(row, &lookup_keys, &display_names))
255+
.map(|row| project_row(row, &lookup_keys, &display_names, &keys))
250256
.collect();
251257
// Carry each projected column's real catalog type, aligned in
252258
// order with `display_names`. Only the pgwire encoder consumes
@@ -280,10 +286,15 @@ pub fn shape_decoded_rows(decoded: &JsonValue, projection: Option<&OutputSchema>
280286
/// Select and rename one flat row's fields per the projection lists, trying
281287
/// each candidate key in order: the full lookup key, then the bare
282288
/// (post-dot) column name, then the SELECT alias.
289+
///
290+
/// Cells are inserted under `cell_keys` (unique per column, see
291+
/// [`super::project::cell_keys`]) rather than the display names, which may
292+
/// repeat across columns and would otherwise collapse in the output map.
283293
fn project_row(
284294
row: &Map<String, JsonValue>,
285295
lookup_keys: &[String],
286296
display_names: &[String],
297+
cell_keys: &[String],
287298
) -> Map<String, JsonValue> {
288299
let mut out = Map::new();
289300
for (i, lookup_key) in lookup_keys.iter().enumerate() {
@@ -313,7 +324,8 @@ fn project_row(
313324
})
314325
.cloned()
315326
.unwrap_or(JsonValue::Null);
316-
out.insert(display_name.to_string(), value);
327+
let cell_key = cell_keys.get(i).map(String::as_str).unwrap_or(display_name);
328+
out.insert(cell_key.to_string(), value);
317329
}
318330
out
319331
}
@@ -372,3 +384,44 @@ fn single_result_row(text: String) -> ShapedRows {
372384
notice: None,
373385
}
374386
}
387+
388+
#[cfg(test)]
389+
mod tests {
390+
use super::*;
391+
392+
/// Two projected columns sharing the display name `id` (`SELECT w.id,
393+
/// b.id`) must keep both values in the shaped row instead of collapsing
394+
/// to the last table's value.
395+
#[test]
396+
fn project_row_keeps_both_columns_with_duplicate_display_names() {
397+
let mut row = Map::new();
398+
row.insert("w.id".to_string(), JsonValue::String("w1".to_string()));
399+
row.insert("b.id".to_string(), JsonValue::String("b1".to_string()));
400+
401+
let lookup_keys = vec!["w.id".to_string(), "b.id".to_string()];
402+
let display_names = vec!["id".to_string(), "id".to_string()];
403+
let keys = super::super::project::cell_keys(&display_names);
404+
405+
let out = project_row(&row, &lookup_keys, &display_names, &keys);
406+
assert_eq!(out.len(), 2, "both cells must survive the projection");
407+
assert_eq!(out.get("id"), Some(&JsonValue::String("w1".to_string())));
408+
assert_eq!(out.get("id_1"), Some(&JsonValue::String("b1".to_string())));
409+
}
410+
411+
/// Duplicate-free projections still store cells under the display name
412+
/// (`cell_keys` is the identity), so existing readers are unaffected.
413+
#[test]
414+
fn project_row_uses_display_names_when_unique() {
415+
let mut row = Map::new();
416+
row.insert("w.id".to_string(), JsonValue::String("w1".to_string()));
417+
row.insert("b.title".to_string(), JsonValue::String("t".to_string()));
418+
419+
let lookup_keys = vec!["w.id".to_string(), "b.title".to_string()];
420+
let display_names = vec!["id".to_string(), "title".to_string()];
421+
let keys = super::super::project::cell_keys(&display_names);
422+
423+
let out = project_row(&row, &lookup_keys, &display_names, &keys);
424+
assert_eq!(out.get("id"), Some(&JsonValue::String("w1".to_string())));
425+
assert_eq!(out.get("title"), Some(&JsonValue::String("t".to_string())));
426+
}
427+
}

nodedb/src/control/server/response_shape/project.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,69 @@ pub fn is_scan_wrapper(map: &serde_json::Map<String, serde_json::Value>) -> bool
5555
&& matches!(map.get("id"), Some(serde_json::Value::String(_)))
5656
&& matches!(map.get("data"), Some(serde_json::Value::Object(_)))
5757
}
58+
59+
/// Unique per-column cell keys for a shaped column list.
60+
///
61+
/// SQL output column names may legally repeat — `SELECT w.id, b.id` displays
62+
/// both columns as `id` — but a shaped row is a JSON map and cannot hold two
63+
/// cells under one key: inserting the second cell would overwrite the first,
64+
/// making both wire columns render the last value. The shaper therefore
65+
/// stores the first occurrence of a name under the name itself and each later
66+
/// duplicate under `<name>_<n>` (n = 1, 2, …), skipping any candidate that
67+
/// collides with another column's name. Row writers and every protocol
68+
/// encoder derive keys through this one function so they always agree; for a
69+
/// duplicate-free column list this is the identity mapping.
70+
pub fn cell_keys(columns: &[String]) -> Vec<String> {
71+
use std::collections::HashSet;
72+
73+
let names: HashSet<&str> = columns.iter().map(String::as_str).collect();
74+
let mut used: HashSet<String> = HashSet::with_capacity(columns.len());
75+
columns
76+
.iter()
77+
.map(|name| {
78+
if used.insert(name.clone()) {
79+
return name.clone();
80+
}
81+
let mut n = 1usize;
82+
loop {
83+
let candidate = format!("{name}_{n}");
84+
if !names.contains(candidate.as_str()) && used.insert(candidate.clone()) {
85+
return candidate;
86+
}
87+
n += 1;
88+
}
89+
})
90+
.collect()
91+
}
92+
93+
#[cfg(test)]
94+
mod tests {
95+
use super::cell_keys;
96+
97+
fn keys(cols: &[&str]) -> Vec<String> {
98+
cell_keys(&cols.iter().map(|s| s.to_string()).collect::<Vec<_>>())
99+
}
100+
101+
/// Duplicate-free column lists map to themselves.
102+
#[test]
103+
fn cell_keys_is_identity_without_duplicates() {
104+
assert_eq!(keys(&["id", "name", "score"]), ["id", "name", "score"]);
105+
assert_eq!(keys(&[]), Vec::<String>::new());
106+
}
107+
108+
/// Later duplicates get a `_n` suffix; the first occurrence keeps the
109+
/// bare name (`SELECT w.id, b.id` → `id`, `id_1`).
110+
#[test]
111+
fn cell_keys_suffixes_later_duplicates() {
112+
assert_eq!(keys(&["id", "id"]), ["id", "id_1"]);
113+
assert_eq!(keys(&["id", "id", "id"]), ["id", "id_1", "id_2"]);
114+
}
115+
116+
/// A suffix candidate never collides with a real column of that name:
117+
/// with columns `id, id, id_1` the second `id` skips `id_1` (taken by a
118+
/// real column) and becomes `id_2`.
119+
#[test]
120+
fn cell_keys_skips_candidates_that_shadow_real_columns() {
121+
assert_eq!(keys(&["id", "id", "id_1"]), ["id", "id_2", "id_1"]);
122+
}
123+
}

nodedb/tests/sql_join_correctness.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,55 @@ async fn natural_join_is_not_cartesian() {
339339
}
340340
}
341341

342+
// ---------------------------------------------------------------------------
343+
// Qualified same-name projections
344+
// ---------------------------------------------------------------------------
345+
346+
/// Unaliased table-qualified projections of same-named columns must each
347+
/// resolve to their own table's value: `SELECT j_t1.id, j_t2.id` returns the
348+
/// left value in the first column and the right value in the second, not the
349+
/// last table's value in both. (Aliased projections already behaved; this
350+
/// pins the unaliased path, where both output columns display as `id`.)
351+
#[tokio::test]
352+
async fn qualified_same_name_columns_resolve_per_table_without_aliases() {
353+
let server = TestServer::start().await;
354+
setup_join_tables(&server).await;
355+
356+
let rows = server
357+
.query_rows(
358+
"SELECT j_t1.id, j_t2.id FROM j_t1 \
359+
JOIN j_t2 ON j_t2.t1_id = j_t1.id \
360+
WHERE j_t2.id = 'p'",
361+
)
362+
.await
363+
.expect("join with unaliased qualified same-name projections");
364+
365+
assert_eq!(
366+
rows,
367+
vec![vec!["a".to_string(), "p".to_string()]],
368+
"each qualified `id` projection must carry its own table's value"
369+
);
370+
}
371+
372+
/// The aliased spelling of the same projection stays correct (guard against
373+
/// the unaliased fix regressing the alias path).
374+
#[tokio::test]
375+
async fn qualified_same_name_columns_resolve_per_table_with_aliases() {
376+
let server = TestServer::start().await;
377+
setup_join_tables(&server).await;
378+
379+
let rows = server
380+
.query_rows(
381+
"SELECT j_t1.id AS t1_id, j_t2.id AS t2_id FROM j_t1 \
382+
JOIN j_t2 ON j_t2.t1_id = j_t1.id \
383+
WHERE j_t2.id = 'p'",
384+
)
385+
.await
386+
.expect("join with aliased qualified same-name projections");
387+
388+
assert_eq!(rows, vec![vec!["a".to_string(), "p".to_string()]]);
389+
}
390+
342391
// ---------------------------------------------------------------------------
343392
// LIMIT with post-join filters
344393
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)