Skip to content

Commit d6e3633

Browse files
authored
Merge pull request #206 from mkhairi/fix/join-limit-and-qualified-projection
fix: join LIMIT applied before WHERE, and same-named output columns collapsing
2 parents 81169d3 + b04047b commit d6e3633

12 files changed

Lines changed: 459 additions & 14 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: 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 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();
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: 15 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) {
@@ -141,6 +146,10 @@ pub(in crate::control::server::pgwire) fn shaped_query_response(
141146
shaped: ShapedRows,
142147
formats: &[FieldFormat],
143148
) -> (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();
144153
let ShapedRows {
145154
columns,
146155
column_types,
@@ -161,7 +170,7 @@ pub(in crate::control::server::pgwire) fn shaped_query_response(
161170

162171
let encoded_rows: Vec<PgWireResult<DataRow>> = rows
163172
.iter()
164-
.map(|row| encode_shaped_row(&schema, &columns, &column_types, formats, row))
173+
.map(|row| encode_shaped_row(&schema, &keys, &column_types, formats, row))
165174
.collect();
166175

167176
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/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/src/data/executor/handlers/join/hash_handlers.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,17 @@ impl CoreLoop {
333333
// budget) and, if the probe fills it, surface a deterministic
334334
// `ResourcesExhausted` rather than dropping the excess rows. A budget
335335
// of 0 means "unlimited" → truly unbounded output.
336-
let (probe_limit, enforce_output_budget) = if join.limit != usize::MAX {
336+
//
337+
// A user LIMIT may only cap the probe when there are no post-join
338+
// WHERE filters: `filter_and_project` runs AFTER the probe, so
339+
// truncating to `n` first would emit the first `n` ON-matched rows and
340+
// then discard those failing the WHERE clause — under-filling (or
341+
// emptying) the result even though later probe rows match. With
342+
// post-filters present the probe runs under the budget ceiling and the
343+
// user LIMIT is applied after filtering, below.
344+
let has_post_filters = !join.post_filter_bytes.is_empty();
345+
let (probe_limit, enforce_output_budget) = if join.limit != usize::MAX && !has_post_filters
346+
{
337347
(join.limit, false)
338348
} else if budget == 0 {
339349
(usize::MAX, false)
@@ -374,6 +384,13 @@ impl CoreLoop {
374384
);
375385
}
376386

387+
// Deferred user LIMIT: when post-join WHERE filters exist the probe
388+
// ran unbounded (see above) so the LIMIT must be applied here, after
389+
// the filters have retained the matching rows.
390+
if has_post_filters && join.limit != usize::MAX {
391+
results.truncate(join.limit);
392+
}
393+
377394
let payload = super::super::super::response_codec::encode_binary_rows(&results);
378395
self.response_with_payload(join.task, payload)
379396
}

0 commit comments

Comments
 (0)