Skip to content

Commit 5293b97

Browse files
committed
fix(sql): propagate ORDER BY into aggregate plans and support derived-FROM subqueries
Three silent-correctness bugs fixed end-to-end through planner, converter, and executor: ORDER BY after GROUP BY was silently dropped The planner's apply_order_by did not handle SqlPlan::Aggregate, so every GROUP BY … ORDER BY query returned groups in hash-map iteration order. SqlPlan::Aggregate gains a sort_keys field; apply_order_by populates it; the executor sorts finalized group rows before truncating to LIMIT. The converter rejects non-column sort expressions with a typed error rather than silently discarding them. ORDER BY on a CTE (used by derived-FROM subqueries) is now pushed into the outer plan. Derived FROM subquery support FROM (SELECT …) AS t was not a recognised pattern; the resolver dropped the factor silently, the scope was empty, and the planner errored with "multi-table FROM without JOIN". try_plan_derived_from desugars the single non-LATERAL derived-table into a synthetic CTE so the outer SELECT plans normally against the alias. Aggregate alias case mismatch UnnamedExpr aliases were kept in the case emitted by the AST formatter (e.g. COUNT(DISTINCT user_id)), but expr_column_names produced lowercase keys. The JSON row key never matched, and clients saw NULL. Alias generation now lowercases the expression string to align with the column-name lookup.
1 parent 0745c73 commit 5293b97

18 files changed

Lines changed: 258 additions & 5 deletions

File tree

nodedb-sql/src/engine_rules/columnar.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ impl EngineRules for ColumnarRules {
131131
having: p.having,
132132
limit: p.limit,
133133
grouping_sets: None,
134+
sort_keys: Vec::new(),
134135
})
135136
}
136137

nodedb-sql/src/engine_rules/document_schemaless.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ impl EngineRules for SchemalessRules {
136136
having: p.having,
137137
limit: p.limit,
138138
grouping_sets: None,
139+
sort_keys: Vec::new(),
139140
})
140141
}
141142
}

nodedb-sql/src/engine_rules/document_strict.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ impl EngineRules for StrictRules {
136136
having: p.having,
137137
limit: p.limit,
138138
grouping_sets: None,
139+
sort_keys: Vec::new(),
139140
})
140141
}
141142
}

nodedb-sql/src/engine_rules/kv.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ impl EngineRules for KvRules {
117117
having: p.having,
118118
limit: p.limit,
119119
grouping_sets: None,
120+
sort_keys: Vec::new(),
120121
})
121122
}
122123

nodedb-sql/src/engine_rules/spatial.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ impl EngineRules for SpatialRules {
123123
having: p.having,
124124
limit: p.limit,
125125
grouping_sets: None,
126+
sort_keys: Vec::new(),
126127
})
127128
}
128129

nodedb-sql/src/planner/aggregate.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ fn attach_grouping_sets(
9393
input,
9494
limit,
9595
grouping_sets: _,
96+
sort_keys,
9697
..
9798
} => SqlPlan::Aggregate {
9899
input,
@@ -101,6 +102,7 @@ fn attach_grouping_sets(
101102
having,
102103
limit,
103104
grouping_sets: Some(grouping_sets),
105+
sort_keys,
104106
},
105107
other => {
106108
// Engine returned something other than Aggregate (e.g. TimeseriesIngest).
@@ -112,6 +114,7 @@ fn attach_grouping_sets(
112114
having,
113115
limit: 10000,
114116
grouping_sets: Some(grouping_sets),
117+
sort_keys: Vec::new(),
115118
}
116119
}
117120
}
@@ -356,7 +359,15 @@ pub fn extract_aggregates_from_projection(
356359
let mut aggregates = Vec::new();
357360
for item in items {
358361
let (expr, alias): (&ast::Expr, String) = match item {
359-
ast::SelectItem::UnnamedExpr(expr) => (expr, format!("{expr}")),
362+
// Lowercase the unparsed expression text so the resulting
363+
// alias (which becomes the JSON row key after the
364+
// `apply_user_aliases_to_rows` rename) matches the pgwire
365+
// lookup key built by `expr_column_names`, which also
366+
// lowercases. Without this match the row description
367+
// column `count(distinct user_id)` would not resolve to
368+
// the JSON value stored under `COUNT(DISTINCT user_id)`,
369+
// and the client would see NULL.
370+
ast::SelectItem::UnnamedExpr(expr) => (expr, format!("{expr}").to_lowercase()),
360371
ast::SelectItem::ExprWithAlias { expr, alias } => (expr, normalize_ident(alias)),
361372
_ => continue,
362373
};

nodedb-sql/src/planner/join/plan.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ pub fn plan_join_from_select(
134134
having,
135135
limit: 10000,
136136
grouping_sets: None,
137+
sort_keys: Vec::new(),
137138
}));
138139
}
139140

nodedb-sql/src/planner/select/entry.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -392,9 +392,9 @@ fn apply_limit(mut plan: SqlPlan, limit_clause: &Option<ast::LimitClause>) -> Sq
392392
}
393393

394394
/// Catalog wrapper that resolves CTE names as schemaless document collections.
395-
struct CteCatalog<'a> {
396-
inner: &'a dyn SqlCatalog,
397-
cte_names: Vec<String>,
395+
pub(crate) struct CteCatalog<'a> {
396+
pub(crate) inner: &'a dyn SqlCatalog,
397+
pub(crate) cte_names: Vec<String>,
398398
}
399399

400400
impl SqlCatalog for CteCatalog<'_> {

nodedb-sql/src/planner/select/order_by/apply.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,37 @@ pub(in crate::planner::select) fn apply_order_by(
9090
window_functions: window_functions.clone(),
9191
temporal: *temporal,
9292
}),
93+
// ORDER BY applied to a GROUP BY result: stash the sort keys
94+
// on the Aggregate plan; the executor sorts the finalized
95+
// group rows before returning. Without this branch the sort
96+
// is silently dropped — every `… GROUP BY x ORDER BY x` query
97+
// comes back in hash-map iteration order, which is a
98+
// data-correctness bug for any downstream consumer.
99+
SqlPlan::Aggregate {
100+
input,
101+
group_by,
102+
aggregates,
103+
having,
104+
limit,
105+
grouping_sets,
106+
..
107+
} => Ok(SqlPlan::Aggregate {
108+
input: input.clone(),
109+
group_by: group_by.clone(),
110+
aggregates: aggregates.clone(),
111+
having: having.clone(),
112+
limit: *limit,
113+
grouping_sets: grouping_sets.clone(),
114+
sort_keys,
115+
}),
116+
// Cte wraps an inner outer plan; push ORDER BY into that outer
117+
// so derived-table queries (`SELECT … FROM (…) AS t ORDER BY …`)
118+
// honour the sort. inline_cte downstream merges the outer Scan
119+
// with the inner subquery plan; the sort_keys ride along.
120+
SqlPlan::Cte { definitions, outer } => Ok(SqlPlan::Cte {
121+
definitions: definitions.clone(),
122+
outer: Box::new(apply_order_by(outer, order_by, functions, select_items)?),
123+
}),
93124
_ => Ok(plan.clone()),
94125
}
95126
}

nodedb-sql/src/planner/select/select_stmt.rs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,16 @@ use super::helpers::{convert_projection, convert_where_to_filters, eval_constant
99
use super::where_search::try_extract_where_search;
1010
use crate::error::{Result, SqlError};
1111
use crate::functions::registry::FunctionRegistry;
12+
use crate::parser::normalize::normalize_ident;
1213
use crate::planner::lateral::plan::{
1314
is_lateral_derived, lateral_alias_from_factor, plan_lateral_join, subquery_from_factor,
1415
};
1516
use crate::resolver::columns::TableScope;
1617
use crate::temporal::TemporalScope;
1718
use crate::types::*;
1819

20+
use super::entry::{CteCatalog, plan_query};
21+
1922
/// Plan a single SELECT statement (no UNION, no CTE wrapper).
2023
pub(super) fn plan_select(
2124
select: &Select,
@@ -31,6 +34,19 @@ pub(super) fn plan_select(
3134
return Ok(plan);
3235
}
3336

37+
// 0.5. Derived FROM subquery: `FROM (SELECT ...) AS t`.
38+
//
39+
// Plan the inner subquery first, then desugar into a synthetic CTE
40+
// so the outer SELECT — which may reference `t` like any other
41+
// relation — plans against a catalog that resolves the alias to a
42+
// schemaless source. Until this branch existed the resolver
43+
// dropped non-LATERAL derived factors silently, the scope ended
44+
// up empty, and the planner errored with "multi-table FROM
45+
// without JOIN".
46+
if let Some(plan) = try_plan_derived_from(select, catalog, functions, temporal)? {
47+
return Ok(plan);
48+
}
49+
3450
// 1. Resolve FROM tables.
3551
let scope = TableScope::resolve_from(catalog, &select.from)?;
3652

@@ -345,6 +361,71 @@ fn has_aggregation(select: &Select, functions: &FunctionRegistry) -> bool {
345361
false
346362
}
347363

364+
/// Desugar `FROM (SELECT ...) AS alias` into a synthetic single-CTE plan.
365+
///
366+
/// Recognises the single-source, non-LATERAL derived-table pattern. The
367+
/// inner subquery is planned with the original catalog; the outer
368+
/// SELECT is replanned with a `CteCatalog` that resolves the alias to
369+
/// a schemaless source. The result is wrapped as `SqlPlan::Cte` so the
370+
/// `convert_cte` lowering takes care of execution.
371+
///
372+
/// Returns `Ok(None)` when the FROM clause is not a single derived
373+
/// table, so the caller falls through to the regular planning path.
374+
fn try_plan_derived_from(
375+
select: &Select,
376+
catalog: &dyn SqlCatalog,
377+
functions: &FunctionRegistry,
378+
temporal: TemporalScope,
379+
) -> Result<Option<SqlPlan>> {
380+
if select.from.len() != 1 {
381+
return Ok(None);
382+
}
383+
let from = &select.from[0];
384+
if !from.joins.is_empty() {
385+
return Ok(None);
386+
}
387+
let (subquery, alias_ident) = match &from.relation {
388+
ast::TableFactor::Derived {
389+
lateral: false,
390+
subquery,
391+
alias: Some(alias),
392+
..
393+
} => (subquery, alias),
394+
_ => return Ok(None),
395+
};
396+
397+
let alias_name = normalize_ident(&alias_ident.name);
398+
let inner_plan = plan_query(subquery, catalog, functions, temporal)?;
399+
400+
// Replan the outer SELECT against a catalog that resolves the alias
401+
// as a schemaless source. The outer can reference `alias.col`
402+
// qualified or unqualified — the resolver treats CTE rows as a
403+
// schemaless document so any projected column flows through.
404+
let derived_catalog = CteCatalog {
405+
inner: catalog,
406+
cte_names: vec![alias_name.clone()],
407+
};
408+
let mut outer_select = select.clone();
409+
outer_select.from[0].relation = ast::TableFactor::Table {
410+
name: ast::ObjectName::from(vec![ast::Ident::new(alias_name.clone())]),
411+
alias: None,
412+
args: None,
413+
with_hints: Vec::new(),
414+
version: None,
415+
with_ordinality: false,
416+
partitions: Vec::new(),
417+
json_path: None,
418+
sample: None,
419+
index_hints: Vec::new(),
420+
};
421+
let outer_plan = plan_select(&outer_select, &derived_catalog, functions, temporal)?;
422+
423+
Ok(Some(SqlPlan::Cte {
424+
definitions: vec![(alias_name, inner_plan)],
425+
outer: Box::new(outer_plan),
426+
}))
427+
}
428+
348429
/// Dispatch to the JOIN planner if the FROM contains joins.
349430
fn try_plan_join(
350431
select: &Select,

0 commit comments

Comments
 (0)