Skip to content

Commit b04047b

Browse files
committed
refactor(shape): centralize cell-key derivation and cover HTTP
Review follow-up. The rule "read row cells via the per-column keys, never by display name" was enforced only by each consumer re-deriving the keys itself, and the HTTP paths did not: `shape_http_payload`, `ddl_results_to_json`, and the NDJSON stream serialize the row map straight to JSON, so the `_<n>` suffix for duplicate output names was reaching clients as an undocumented, untested wire contract. `ShapedRows::cell_keys()` is now the single source of truth. pgwire and native read through it instead of each calling `project::cell_keys`, the `rows` field documents that cells are keyed by it, and all three HTTP sites document the resulting JSON shape: a duplicate-name projection emits `{"id": …, "id_1": …}` because a JSON object cannot repeat a key and dropping the duplicate would silently lose a projected column. pgwire and native stay positional and still report both columns as `id`. An accessor rather than a stored field: `ShapedRows` has 146 struct literal construction sites, so a required field would pull that many unrelated edits into this change. Derivation is per response, not per row, so caching buys nothing measurable. Tests: http_result_projection asserts both cells survive over `/v1/query` AND `/v1/query/stream`.
1 parent 42a9993 commit b04047b

6 files changed

Lines changed: 115 additions & 7 deletions

File tree

nodedb/src/control/server/http/routes/query_stream.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ pub(super) fn ndjson_body_stream(
122122
return;
123123
}
124124
};
125+
// Row maps are keyed by `ShapedRows::cell_keys`, so each NDJSON
126+
// line serializes as-is; two output columns sharing a name emit
127+
// `{"id": …, "id_1": …}` rather than collapsing to one cell.
125128
let shaped = shape_decoded_rows(&value, projection.as_ref());
126129
for row in shaped.rows {
127130
if emitted >= limit {

nodedb/src/control/server/http/routes/result_shape.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ pub(super) fn shape_http_payload(
4747
database_id,
4848
tenant_id,
4949
)? {
50+
// Each row map is already keyed by `ShapedRows::cell_keys`, so it
51+
// serializes to JSON as-is. When two output columns share a name the
52+
// later one carries a `_<n>` suffix (`SELECT w.id, b.id` →
53+
// `{"id": …, "id_1": …}`) — a JSON object cannot repeat a key, and
54+
// dropping the duplicate would silently lose a projected column.
5055
ShapeOutcome::Rows(shaped) => Ok(HttpShaped::Rows(
5156
shaped
5257
.rows
@@ -111,6 +116,8 @@ pub(super) fn ddl_results_to_json(
111116
"tag": command,
112117
}));
113118
}
119+
// Keyed by `ShapedRows::cell_keys`, same JSON contract as
120+
// `shape_http_payload` above (duplicate names take a `_<n>` key).
114121
DdlResult::Rows(shaped) => {
115122
for row in shaped.rows {
116123
rows.push(serde_json::Value::Object(row));

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,10 +146,10 @@ 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);
149+
// Cells live in the row maps under per-column keys (display names may
150+
// repeat across columns, e.g. `SELECT w.id, b.id`), so read through the
151+
// shared accessor rather than by display name.
152+
let cell_keys = shaped.cell_keys();
153153
let rows = shaped
154154
.rows
155155
.iter()

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,10 @@ pub(in crate::control::server::pgwire) fn shaped_query_response(
146146
shaped: ShapedRows,
147147
formats: &[FieldFormat],
148148
) -> (Response, Option<String>) {
149+
// Cells live in the row maps under per-column keys that differ from the
150+
// display names only when two columns share a name; derived here before
151+
// the struct is destructured.
152+
let keys = shaped.cell_keys();
149153
let ShapedRows {
150154
columns,
151155
column_types,
@@ -164,9 +168,6 @@ pub(in crate::control::server::pgwire) fn shaped_query_response(
164168
.collect();
165169
let schema = Arc::new(fields);
166170

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);
170171
let encoded_rows: Vec<PgWireResult<DataRow>> = rows
171172
.iter()
172173
.map(|row| encode_shaped_row(&schema, &keys, &column_types, formats, row))

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,10 @@ pub struct ShapedRows {
228228
/// type OIDs; the native and http entrypoints ignore it. `Text` is used
229229
/// wherever the source type is unknown.
230230
pub column_types: Vec<DdlColType>,
231+
/// One map per row. Cells are keyed by [`ShapedRows::cell_keys`], NOT by
232+
/// `columns` directly — SQL output names may repeat (`SELECT w.id, b.id`
233+
/// displays both as `id`) and a map cannot hold two cells under one key.
234+
/// Read cells through `cell_keys` so each column reads its own value.
231235
pub rows: Vec<serde_json::Map<String, serde_json::Value>>,
232236
pub notice: Option<String>,
233237
}
@@ -238,4 +242,24 @@ impl ShapedRows {
238242
pub fn text_types(n: usize) -> Vec<DdlColType> {
239243
vec![DdlColType::Text; n]
240244
}
245+
246+
/// Per-column keys for reading cells out of [`ShapedRows::rows`], parallel
247+
/// to `columns`.
248+
///
249+
/// This is the single source of truth every consumer shares — the pgwire
250+
/// encoders, the native converter, and the HTTP JSON serializers all key
251+
/// rows through it, so the row-map layout can never drift from what a
252+
/// consumer expects.
253+
///
254+
/// Identical to `columns` unless two output columns share a name, in which
255+
/// case later duplicates take a `_<n>` suffix (see
256+
/// [`super::project::cell_keys`]). Because the HTTP transports serialize a
257+
/// row map directly to JSON, that suffix is user-visible there: a
258+
/// duplicate-name `SELECT w.id, b.id` emits `{"id": …, "id_1": …}`, since
259+
/// a JSON object likewise cannot carry the same key twice. pgwire and
260+
/// native are positional on the wire and still report both columns as
261+
/// `id`, matching PostgreSQL.
262+
pub fn cell_keys(&self) -> Vec<String> {
263+
super::project::cell_keys(&self.columns)
264+
}
241265
}

nodedb/tests/http_result_projection.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,3 +220,76 @@ async fn http_vector_search_serializes_distance() {
220220
"distance must be numeric, got {distance:?}"
221221
);
222222
}
223+
224+
// ── duplicate output column names ────────────────────────────────────────────
225+
226+
/// POST `sql` to `/v1/query/stream` and return one parsed JSON value per
227+
/// NDJSON line.
228+
async fn query_stream_lines(http_port: u16, sql: &str) -> Vec<serde_json::Value> {
229+
let url = format!("http://127.0.0.1:{http_port}/v1/query/stream");
230+
let resp = reqwest::Client::new()
231+
.post(&url)
232+
.json(&serde_json::json!({ "sql": sql }))
233+
.send()
234+
.await
235+
.expect("POST /v1/query/stream");
236+
assert!(
237+
resp.status().is_success(),
238+
"stream query must succeed over HTTP: {} — sql={sql}",
239+
resp.status()
240+
);
241+
let body = resp.text().await.expect("read NDJSON body");
242+
body.lines()
243+
.filter(|l| !l.trim().is_empty())
244+
.map(|l| serde_json::from_str(l).unwrap_or_else(|e| panic!("parse NDJSON line {l:?}: {e}")))
245+
.collect()
246+
}
247+
248+
/// Two joined collections both projecting `id` must keep BOTH values in the
249+
/// HTTP JSON row. A JSON object cannot repeat a key, so the second column
250+
/// lands under the `_<n>`-suffixed cell key (`id_1`) — dropping it would
251+
/// silently lose a projected column, which is what the row map did before
252+
/// cells were keyed independently of the display name.
253+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
254+
async fn http_keeps_both_cells_for_duplicate_column_names() {
255+
let srv = TestServer::start().await;
256+
srv.exec("CREATE COLLECTION nb_dup_w (id TEXT PRIMARY KEY, wname TEXT) WITH (engine='document_strict')")
257+
.await
258+
.expect("create nb_dup_w");
259+
srv.exec("CREATE COLLECTION nb_dup_b (id TEXT PRIMARY KEY, ref TEXT) WITH (engine='document_strict')")
260+
.await
261+
.expect("create nb_dup_b");
262+
srv.exec("INSERT INTO nb_dup_w (id, wname) VALUES ('w1','alpha')")
263+
.await
264+
.expect("insert nb_dup_w");
265+
srv.exec("INSERT INTO nb_dup_b (id, ref) VALUES ('b1','w1')")
266+
.await
267+
.expect("insert nb_dup_b");
268+
269+
let sql = "SELECT w.id, b.id FROM nb_dup_w w JOIN nb_dup_b b ON b.ref = w.id";
270+
let rows = query_rows(srv.http_port, sql).await;
271+
let stream_rows = query_stream_lines(srv.http_port, sql).await;
272+
srv.graceful_shutdown().await;
273+
274+
assert_eq!(rows.len(), 1, "one joined row expected: {rows:?}");
275+
let obj = rows[0].as_object().expect("row is a JSON object");
276+
assert_eq!(
277+
obj.get("id").and_then(|v| v.as_str()),
278+
Some("w1"),
279+
"first `id` must carry the left table's value: {obj:?}"
280+
);
281+
assert_eq!(
282+
obj.get("id_1").and_then(|v| v.as_str()),
283+
Some("b1"),
284+
"the duplicate `id` must survive under the suffixed key: {obj:?}"
285+
);
286+
287+
// The NDJSON streaming path serializes the same row maps, so it carries
288+
// the identical contract.
289+
assert_eq!(stream_rows.len(), 1, "one streamed row: {stream_rows:?}");
290+
let stream_obj = stream_rows[0]
291+
.as_object()
292+
.expect("streamed row is an object");
293+
assert_eq!(stream_obj.get("id").and_then(|v| v.as_str()), Some("w1"));
294+
assert_eq!(stream_obj.get("id_1").and_then(|v| v.as_str()), Some("b1"));
295+
}

0 commit comments

Comments
 (0)