Skip to content

Commit f0f0517

Browse files
authored
Merge pull request #204 from emanzx/fix/positional-insert-column-binding
fix(sql): bind positional INSERT/UPSERT values to declared column names
2 parents 26ac75c + 87f1f68 commit f0f0517

4 files changed

Lines changed: 320 additions & 1 deletion

File tree

nodedb-sql/src/error.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,37 @@ pub enum SqlError {
3232
#[error("missing required field '{field}' for {context}")]
3333
MissingField { field: String, context: String },
3434

35+
/// A positional `INSERT`/`UPSERT` `VALUES` row (no explicit column
36+
/// list) supplied more values than the target collection has declared
37+
/// columns. Binding the overflow value(s) to a synthetic `col{N}` name
38+
/// would silently store them under an unaddressable column — the same
39+
/// failure mode as #202 — so this is rejected rather than guessed
40+
/// at.
41+
#[error(
42+
"INSERT/UPSERT into '{collection}': row has {given} value(s) but only \
43+
{declared} column(s) are declared; supply an explicit column list \
44+
or remove the extra value(s)"
45+
)]
46+
InsertColumnArityMismatch {
47+
collection: String,
48+
given: usize,
49+
declared: usize,
50+
},
51+
52+
/// A positional `INSERT`/`UPSERT` into a Key-Value collection supplied
53+
/// no explicit column list. The KV path splits key/value by matching
54+
/// column *names* against `pk_col`/`"key"`/`"ttl"`, so without a column
55+
/// list there is no principled key/value split — binding positionally
56+
/// would silently write an empty-keyed, empty-valued row (all such rows
57+
/// collide). Rejected rather than guessed at, mirroring
58+
/// [`InsertColumnArityMismatch`].
59+
#[error(
60+
"INSERT/UPSERT into KV collection '{collection}': supply an explicit \
61+
column list (KV needs named key/value columns; a positional VALUES \
62+
row has no key to bind to)"
63+
)]
64+
PositionalKvInsertUnsupported { collection: String },
65+
3566
/// A descriptor the planner depends on is being drained by
3667
/// an in-flight DDL. Callers (pgwire handlers) should retry
3768
/// the whole statement after a short backoff. Propagated

nodedb-sql/src/planner/dml.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use sqlparser::ast::{self};
77

88
use super::dml_helpers::{
99
build_kv_insert_plan, build_vector_primary_insert_plan, convert_value_rows,
10+
resolve_insert_columns,
1011
};
1112
use crate::engine_rules::{self, InsertParams};
1213
use crate::error::{Result, SqlError};
@@ -118,6 +119,9 @@ pub fn plan_insert(ins: &ast::Insert, catalog: &dyn SqlCatalog) -> Result<Vec<Sq
118119
};
119120

120121
// KV engine: key and value are fundamentally separate — handle directly.
122+
// Positional column binding (below) does not apply here: the KV path
123+
// matches columns by name against `pk_col`/`"key"`/`"ttl"`, which is
124+
// orthogonal to declared column order.
121125
if info.engine == EngineType::KeyValue {
122126
let intent = if if_absent {
123127
KvInsertIntent::InsertIfAbsent
@@ -134,6 +138,11 @@ pub fn plan_insert(ins: &ast::Insert, catalog: &dyn SqlCatalog) -> Result<Vec<Sq
134138
);
135139
}
136140

141+
// Positional INSERT (no column list): bind values to the collection's
142+
// declared column order so named projections/predicates can find them
143+
// (#202). No-op for named inserts and schemaless collections.
144+
let columns = resolve_insert_columns(columns, &info, rows_ast)?;
145+
137146
// Vector-primary collection: bypass document encoding.
138147
if info.primary == nodedb_types::PrimaryEngine::Vector
139148
&& let Some(ref vpc) = info.vector_primary
@@ -199,7 +208,8 @@ pub fn plan_upsert(ins: &ast::Insert, catalog: &dyn SqlCatalog) -> Result<Vec<Sq
199208
}
200209
};
201210

202-
// KV: upsert is just a PUT (natural overwrite).
211+
// KV: upsert is just a PUT (natural overwrite). Positional column
212+
// binding (below) does not apply here — see `plan_insert`.
203213
if info.engine == EngineType::KeyValue {
204214
return build_kv_insert_plan(
205215
table_name,
@@ -211,6 +221,10 @@ pub fn plan_upsert(ins: &ast::Insert, catalog: &dyn SqlCatalog) -> Result<Vec<Sq
211221
);
212222
}
213223

224+
// Positional UPSERT (no column list): bind to the collection's declared
225+
// column order — see `plan_insert` for the full rationale (#202).
226+
let columns = resolve_insert_columns(columns, &info, rows_ast)?;
227+
214228
let rows = convert_value_rows(&columns, rows_ast)?;
215229
let column_defaults: Vec<(String, String)> = info
216230
.columns
@@ -283,6 +297,10 @@ fn plan_upsert_with_on_conflict(
283297
);
284298
}
285299

300+
// Positional UPSERT (no column list): bind to the collection's declared
301+
// column order — see `plan_insert` for the full rationale (#202).
302+
let columns = resolve_insert_columns(columns, &info, rows_ast)?;
303+
286304
let rows = convert_value_rows(&columns, rows_ast)?;
287305
let column_defaults: Vec<(String, String)> = info
288306
.columns

nodedb-sql/src/planner/dml_helpers.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,46 @@ pub(super) fn convert_value_rows(
2525
.collect()
2626
}
2727

28+
/// Resolve the effective column list for a `VALUES`-clause INSERT/UPSERT.
29+
///
30+
/// A *positional* insert — `INSERT INTO t VALUES (...)` with no explicit
31+
/// column list — must still bind each value to the collection's declared
32+
/// column names. Left alone, `convert_value_rows` falls back to synthetic
33+
/// `col0`, `col1`, ... names for every value (see its `col{i}` fallback
34+
/// below): the row stores fine, but named projections and WHERE predicates
35+
/// can never find it again (#202).
36+
///
37+
/// Named inserts (`columns` already non-empty) and schemaless collections
38+
/// (`info.columns` empty — there is no declared order to bind to) pass
39+
/// through unchanged; the `col{i}` fallback remains the last resort for
40+
/// those.
41+
///
42+
/// Fewer values than declared columns binds by position against the
43+
/// leading columns, consistent with a partial named insert (e.g.
44+
/// `INSERT INTO t (id) VALUES (1)` on a wider table also only binds
45+
/// `id`). More values than declared columns is rejected outright:
46+
/// inventing a `colN` slot for the overflow would reproduce the exact
47+
/// unaddressable-column failure this fix closes.
48+
pub(super) fn resolve_insert_columns(
49+
columns: Vec<String>,
50+
info: &CollectionInfo,
51+
rows: &[Vec<ast::Expr>],
52+
) -> Result<Vec<String>> {
53+
if !columns.is_empty() || info.columns.is_empty() {
54+
return Ok(columns);
55+
}
56+
57+
let declared: Vec<String> = info.columns.iter().map(|c| c.name.clone()).collect();
58+
if let Some(row) = rows.iter().find(|row| row.len() > declared.len()) {
59+
return Err(SqlError::InsertColumnArityMismatch {
60+
collection: info.name.clone(),
61+
given: row.len(),
62+
declared: declared.len(),
63+
});
64+
}
65+
Ok(declared)
66+
}
67+
2868
pub(super) fn expr_to_sql_value(expr: &ast::Expr) -> Result<SqlValue> {
2969
match expr {
3070
ast::Expr::Value(v) => convert_value(&v.value),
@@ -238,6 +278,16 @@ pub(super) fn build_kv_insert_plan(
238278
on_conflict_updates: Vec<(String, SqlExpr)>,
239279
pk_col: Option<&str>,
240280
) -> Result<Vec<SqlPlan>> {
281+
// Positional KV insert (no column list): the key/value split below is
282+
// driven entirely by matching column *names* against `key_col_name`/
283+
// `"ttl"`. With an empty `columns` list there is no key to bind to, so
284+
// every row would silently become an empty-keyed, empty-valued entry
285+
// (all colliding). Reject rather than corrupt.
286+
if columns.is_empty() {
287+
return Err(SqlError::PositionalKvInsertUnsupported {
288+
collection: table_name,
289+
});
290+
}
241291
let key_col_name = pk_col.unwrap_or("key");
242292
let key_idx = columns.iter().position(|c| c == key_col_name);
243293
let ttl_idx = columns.iter().position(|c| c == "ttl");
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
3+
//! Integration tests: a positional `INSERT`/`UPSERT` `VALUES` clause (no
4+
//! explicit column list) must bind each value to the collection's
5+
//! DECLARED column names, not synthetic `col0`, `col1`, ... placeholders.
6+
//!
7+
//! The defect (#202): `INSERT INTO probe VALUES (42, 'stored?')` on
8+
//! `probe (id INT PRIMARY KEY, note TEXT)` stored the row under `col0`/
9+
//! `col1` instead of `id`/`note`. `SELECT *` still looked correct (it
10+
//! doesn't care about names), but any named projection or `WHERE`
11+
//! predicate on `id`/`note` silently saw nothing — the row was there,
12+
//! just unaddressable under its real column names.
13+
//!
14+
//! All tests use `plan_sql()` with a minimal catalog (mirrors
15+
//! `point_get_operand_order.rs` / `schema_qualified_rejection.rs`).
16+
17+
use nodedb_sql::types::{CollectionInfo, ColumnInfo, EngineType, SqlDataType};
18+
use nodedb_sql::{SqlCatalog, SqlCatalogError, SqlError, SqlPlan, SqlValue, plan_sql};
19+
use nodedb_types::DatabaseId;
20+
21+
struct Catalog;
22+
23+
impl SqlCatalog for Catalog {
24+
fn get_collection(
25+
&self,
26+
_: DatabaseId,
27+
name: &str,
28+
) -> std::result::Result<Option<CollectionInfo>, SqlCatalogError> {
29+
let info = match name {
30+
// Strict document collection with a declared 2-column schema —
31+
// the exact shape from the issue repro.
32+
"probe" => Some(CollectionInfo {
33+
name: "probe".into(),
34+
engine: EngineType::DocumentStrict,
35+
columns: vec![
36+
ColumnInfo {
37+
name: "id".into(),
38+
data_type: SqlDataType::Int64,
39+
nullable: false,
40+
is_primary_key: true,
41+
default: None,
42+
raw_type: Some("INT".into()),
43+
},
44+
ColumnInfo {
45+
name: "note".into(),
46+
data_type: SqlDataType::String,
47+
nullable: true,
48+
is_primary_key: false,
49+
default: None,
50+
raw_type: Some("TEXT".into()),
51+
},
52+
],
53+
primary_key: Some("id".into()),
54+
has_auto_tier: false,
55+
indexes: Vec::new(),
56+
bitemporal: false,
57+
primary: nodedb_types::PrimaryEngine::Document,
58+
vector_primary: None,
59+
partition_strategy: nodedb_types::PartitionStrategy::CollectionHomed,
60+
}),
61+
// Schemaless collection: no declared column order exists to
62+
// bind positionally to. The pre-existing `col{i}` fallback is
63+
// the correct, unchanged behaviour here.
64+
"loose" => Some(CollectionInfo {
65+
name: "loose".into(),
66+
engine: EngineType::DocumentSchemaless,
67+
columns: Vec::new(),
68+
primary_key: Some("id".into()),
69+
has_auto_tier: false,
70+
indexes: Vec::new(),
71+
bitemporal: false,
72+
primary: nodedb_types::PrimaryEngine::Document,
73+
vector_primary: None,
74+
partition_strategy: nodedb_types::PartitionStrategy::CollectionHomed,
75+
}),
76+
// KV collection: key/value are separated by the KV insert path
77+
// matching column names against the "key"/"ttl" sentinels, not
78+
// by declared column order. Pinned so a future refactor doesn't
79+
// accidentally route KV through the new positional-binding path.
80+
"kv_probe" => Some(CollectionInfo {
81+
name: "kv_probe".into(),
82+
engine: EngineType::KeyValue,
83+
columns: Vec::new(),
84+
primary_key: Some("key".into()),
85+
has_auto_tier: false,
86+
indexes: Vec::new(),
87+
bitemporal: false,
88+
primary: nodedb_types::PrimaryEngine::Document,
89+
vector_primary: None,
90+
partition_strategy: nodedb_types::PartitionStrategy::CollectionHomed,
91+
}),
92+
_ => None,
93+
};
94+
Ok(info)
95+
}
96+
97+
fn lookup_array(&self, _name: &str) -> Option<nodedb_sql::types::ArrayCatalogView> {
98+
None
99+
}
100+
101+
fn array_exists(&self, _name: &str) -> bool {
102+
false
103+
}
104+
}
105+
106+
fn plan_one(sql: &str) -> SqlPlan {
107+
let mut plans = plan_sql(sql, &Catalog).expect("planning must succeed");
108+
assert_eq!(plans.len(), 1, "expected exactly one plan for: {sql}");
109+
plans.pop().unwrap()
110+
}
111+
112+
fn insert_rows(plan: SqlPlan) -> Vec<Vec<(String, SqlValue)>> {
113+
match plan {
114+
SqlPlan::Insert { rows, .. } => rows,
115+
SqlPlan::Upsert { rows, .. } => rows,
116+
other => panic!("expected an Insert or Upsert plan, got {other:?}"),
117+
}
118+
}
119+
120+
/// The core regression (#202): a positional INSERT must bind its
121+
/// values to the DECLARED column names, not `col0`/`col1`.
122+
#[test]
123+
fn positional_insert_binds_declared_column_names() {
124+
let plan = plan_one("INSERT INTO probe VALUES (42, 'stored?')");
125+
let rows = insert_rows(plan);
126+
assert_eq!(rows.len(), 1);
127+
assert_eq!(
128+
rows[0],
129+
vec![
130+
("id".to_string(), SqlValue::Int(42)),
131+
("note".to_string(), SqlValue::String("stored?".into())),
132+
],
133+
"positional INSERT must bind values to the declared column names \
134+
(`id`, `note`), not synthetic `col0`/`col1`"
135+
);
136+
}
137+
138+
/// Control: a named insert must keep binding exactly as before — this fix
139+
/// must not perturb the named-column path.
140+
#[test]
141+
fn named_insert_still_binds_by_name() {
142+
let plan = plan_one("INSERT INTO probe (id, note) VALUES (43, 'named')");
143+
let rows = insert_rows(plan);
144+
assert_eq!(
145+
rows[0],
146+
vec![
147+
("id".to_string(), SqlValue::Int(43)),
148+
("note".to_string(), SqlValue::String("named".into())),
149+
]
150+
);
151+
}
152+
153+
/// A VALUES row with MORE values than the collection has declared columns
154+
/// must be rejected outright — inventing a `col2` slot for the overflow
155+
/// would reproduce the exact unaddressable-column bug this fix closes.
156+
#[test]
157+
fn positional_insert_arity_overflow_is_rejected() {
158+
let err = plan_sql("INSERT INTO probe VALUES (1, 'a', 'extra')", &Catalog)
159+
.expect_err("row has 3 values but `probe` declares only 2 columns");
160+
assert!(
161+
matches!(
162+
err,
163+
SqlError::InsertColumnArityMismatch {
164+
given: 3,
165+
declared: 2,
166+
..
167+
}
168+
),
169+
"expected InsertColumnArityMismatch {{ given: 3, declared: 2, .. }}, got {err:?}"
170+
);
171+
}
172+
173+
/// Schemaless collections have no declared column order to bind
174+
/// positionally to — the pre-existing `col{i}` fallback remains the
175+
/// correct, unchanged behaviour there.
176+
#[test]
177+
fn positional_insert_on_schemaless_collection_keeps_col_i_fallback() {
178+
let plan = plan_one("INSERT INTO loose VALUES (1, 'a')");
179+
let rows = insert_rows(plan);
180+
assert_eq!(
181+
rows[0],
182+
vec![
183+
("col0".to_string(), SqlValue::Int(1)),
184+
("col1".to_string(), SqlValue::String("a".into())),
185+
],
186+
"schemaless collections have no declared columns to bind to; the \
187+
`col{{i}}` fallback must remain the last resort"
188+
);
189+
}
190+
191+
/// Same defect, UPSERT path: `UPSERT INTO t VALUES (...)` is pre-processed
192+
/// to the same `plan_upsert` planner path and must bind identically.
193+
#[test]
194+
fn positional_upsert_binds_declared_column_names() {
195+
let plan = plan_one("UPSERT INTO probe VALUES (44, 'upserted')");
196+
let rows = insert_rows(plan);
197+
assert_eq!(
198+
rows[0],
199+
vec![
200+
("id".to_string(), SqlValue::Int(44)),
201+
("note".to_string(), SqlValue::String("upserted".into())),
202+
]
203+
);
204+
}
205+
206+
/// KV collections split key/value by matching column *names* against
207+
/// `pk_col`/`"key"`/`"ttl"`, not by declared column order. A positional
208+
/// insert supplies no column list, so there is no key to bind to —
209+
/// `build_kv_insert_plan` would otherwise emit an empty-keyed, empty-valued
210+
/// entry (every such row colliding, all data silently dropped). It is
211+
/// rejected outright, mirroring the arity-overflow decision.
212+
#[test]
213+
fn positional_insert_on_kv_collection_is_rejected() {
214+
let err = plan_sql("INSERT INTO kv_probe VALUES ('k1', 'v1')", &Catalog)
215+
.expect_err("positional KV insert has no key/value column names to bind to");
216+
assert!(
217+
matches!(err, SqlError::PositionalKvInsertUnsupported { .. }),
218+
"expected PositionalKvInsertUnsupported, got {err:?}"
219+
);
220+
}

0 commit comments

Comments
 (0)