Skip to content

Commit 87f1f68

Browse files
committed
fix(sql): reject positional INSERT/UPSERT into KV collections
KV splits key/value by matching column names against pk_col/"key"/"ttl", so a positional VALUES row with no column list has no principled key to bind to and would silently produce an empty-keyed, empty-valued row (all such rows colliding). Reject with a dedicated error instead.
1 parent af54dda commit 87f1f68

3 files changed

Lines changed: 37 additions & 24 deletions

File tree

nodedb-sql/src/error.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,20 @@ pub enum SqlError {
4949
declared: usize,
5050
},
5151

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+
5266
/// A descriptor the planner depends on is being drained by
5367
/// an in-flight DDL. Callers (pgwire handlers) should retry
5468
/// the whole statement after a short backoff. Propagated

nodedb-sql/src/planner/dml_helpers.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,16 @@ pub(super) fn build_kv_insert_plan(
278278
on_conflict_updates: Vec<(String, SqlExpr)>,
279279
pk_col: Option<&str>,
280280
) -> 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+
}
281291
let key_col_name = pk_col.unwrap_or("key");
282292
let key_idx = columns.iter().position(|c| c == key_col_name);
283293
let ttl_idx = columns.iter().position(|c| c == "ttl");

nodedb-sql/tests/positional_insert_column_binding.rs

Lines changed: 13 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -203,29 +203,18 @@ fn positional_upsert_binds_declared_column_names() {
203203
);
204204
}
205205

206-
/// KV collections separate key/value by name match against `pk_col`/
207-
/// `"key"`/`"ttl"`, not by declared column order — this fix intentionally
208-
/// leaves that path untouched (see judgment call in the PR description).
209-
///
210-
/// Pre-existing (out-of-scope) behaviour pinned here: `build_kv_insert_plan`
211-
/// walks `columns.iter().enumerate()` to build the value map, so an empty
212-
/// (positional) column list yields an EMPTY value map and an empty-string
213-
/// key — it never falls back to `col{i}` the way `convert_value_rows` does,
214-
/// because it isn't built from that helper. That is a real gap for
215-
/// positional KV inserts, but a separate, wider problem than #202
216-
/// (there is no principled key/value split to fall back to without a
217-
/// column list at all); this test only pins that this fix does not change
218-
/// that existing behaviour.
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.
219212
#[test]
220-
fn positional_insert_on_kv_collection_is_unaffected() {
221-
let plan = plan_one("INSERT INTO kv_probe VALUES ('k1', 'v1')");
222-
match plan {
223-
SqlPlan::KvInsert { entries, .. } => {
224-
assert_eq!(entries.len(), 1);
225-
let (key, value_cols) = &entries[0];
226-
assert_eq!(*key, SqlValue::String(String::new()));
227-
assert_eq!(value_cols, &Vec::<(String, SqlValue)>::new());
228-
}
229-
other => panic!("expected a KvInsert plan, got {other:?}"),
230-
}
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+
);
231220
}

0 commit comments

Comments
 (0)