Skip to content

Commit 14701a0

Browse files
committed
fix(sql): raise 42703 for unknown column references instead of folding to NULL
A column reference that names nothing in scope planned as a field lookup and evaluated to NULL on every row. On collections whose row shape is known at plan time that is silent misbehavior: projection returned NULL-filled rows, WHERE matched nothing (IS NULL matched everything), ORDER BY no-oped, and UPDATE/DELETE predicates touched zero rows — no error anywhere. The function-existence gate already raised for undefined functions; column resolution had no counterpart. Closed-schema collections now validate every reference at plan time and raise SQLSTATE 42703 (undefined_column): - document_strict, kv, columnar, timeseries and spatial are closed by construction. - document (schemaless) is closed when the user declared columns on it; a collection carrying only the auto-injected id stays open, so dynamic fields that appear only in the data keep resolving (the NULL fold remains the documented behavior there). The gate covers the single-table SELECT path (projection, WHERE, GROUP BY, HAVING, ORDER BY) and the UPDATE/DELETE target path (SET targets and predicates). Qualified references validate against the collection they name. Schemaless writes stay open: INSERT column lists may still carry undeclared dynamic fields. The error is typed end to end: SqlError::UnknownColumn maps to a new crate::Error::UndefinedColumn, to the public NodeDbError code 1206 with msgpack tag 79, and to SQLSTATE 42703 on pgwire and the native protocol — mirroring the undefined_function path added for the same folding class. Out of scope (pre-existing folds, tracked separately): JOIN/derived/lateral paths, aggregate ORDER BY, UPDATE ... FROM predicates, and INSERT ... SELECT. Fixes #292
1 parent 9998320 commit 14701a0

17 files changed

Lines changed: 1003 additions & 1 deletion

File tree

‎nodedb-sql/src/planner/dml_update_delete.rs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,21 @@ pub fn plan_update(stmt: &ast::Statement, catalog: &dyn SqlCatalog) -> Result<Ve
5050
name: table_name.clone(),
5151
})?;
5252

53+
// Closed-schema existence gate (42703): a WHERE or SET target naming a
54+
// column the collection does not declare must not silently no-op.
55+
let resolved = crate::resolver::columns::ResolvedTable {
56+
name: table_name.clone(),
57+
alias: None,
58+
info: info.clone(),
59+
};
60+
if let Some(selection) = &update.selection {
61+
crate::planner::select::validate_columns::validate_where(selection, &resolved)?;
62+
}
63+
5364
let mut assigns = convert_assignments(&update.assignments)?;
65+
for (column, _) in &assigns {
66+
crate::planner::select::validate_columns::validate_write_column(column, &resolved)?;
67+
}
5468
// Re-type each literal assignment to its declared column type before the
5569
// range check reads it — the same order, and for the same reason, as the
5670
// INSERT path's `coerce_and_check_rows`. Engines with a typed write path
@@ -358,6 +372,16 @@ pub fn plan_delete(stmt: &ast::Statement, catalog: &dyn SqlCatalog) -> Result<Ve
358372
name: table_name.clone(),
359373
})?;
360374

375+
// Closed-schema existence gate (42703) for the DELETE predicate.
376+
let resolved = crate::resolver::columns::ResolvedTable {
377+
name: table_name.clone(),
378+
alias: None,
379+
info: info.clone(),
380+
};
381+
if let Some(selection) = &delete.selection {
382+
crate::planner::select::validate_columns::validate_where(selection, &resolved)?;
383+
}
384+
361385
let filters = match &delete.selection {
362386
Some(expr) => super::super::select::convert_where_to_filters(expr)?,
363387
None => Vec::new(),

‎nodedb-sql/src/planner/select/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ mod order_by;
1515
mod post_process;
1616
mod query_tail;
1717
mod select_stmt;
18+
pub(crate) mod validate_columns;
1819
mod where_search;
1920

2021
pub use entry::plan_query;

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ use crate::resolver::columns::TableScope;
2020
use crate::temporal::TemporalScope;
2121
use crate::types::*;
2222

23+
use super::validate_columns;
24+
2325
/// Plan a single SELECT statement (no UNION, no CTE wrapper).
2426
///
2527
/// `tail` carries the enclosing query's ORDER BY / LIMIT so the base scan can
@@ -173,6 +175,16 @@ pub(super) fn plan_select(
173175
let normalized_select = strip_single_table_qualifiers(select, &valid_qualifiers)?;
174176
let select = &normalized_select;
175177

178+
// 4a. Closed-schema existence gate: a reference to a column the
179+
// collection does not declare raises 42703 (undefined_column) at plan
180+
// time instead of planning as a field lookup that folds to NULL per row
181+
// — silently wrong `WHERE` row sets, no-op `ORDER BY`, zero-row writes.
182+
// Schemaless collections without a declared column list stay open and
183+
// keep the fold. GROUP BY / HAVING may name projection aliases, so the
184+
// alias set is computed once here and reused by the ORDER BY check.
185+
let projection_aliases = validate_columns::projection_alias_set(select);
186+
validate_columns::validate_select(select, table, &projection_aliases)?;
187+
176188
// 4. Extract subqueries from WHERE and rewrite as semi/anti joins.
177189
let (subquery_joins, effective_where) = if let Some(expr) = &select.selection {
178190
let extraction =
@@ -289,7 +301,12 @@ pub(super) fn plan_select(
289301
// itself downstream — the same reason `scan_projection` is empty here.
290302
let (sort_keys, limit, offset) = if subquery_joins.is_empty() {
291303
let (limit, offset) = tail.limit_offset()?;
292-
(tail.sort_keys()?, limit, offset)
304+
let keys = tail.sort_keys()?;
305+
// ORDER BY may name projection aliases or output ordinals; the key
306+
// expressions are validated here against columns + output names so a
307+
// typo'd sort column raises 42703 instead of silently no-oping.
308+
validate_columns::validate_sort_keys(&keys, table, &projection_aliases)?;
309+
(keys, limit, offset)
293310
} else {
294311
(Vec::new(), None, 0)
295312
};

0 commit comments

Comments
 (0)