Skip to content

Commit 32f33c3

Browse files
committed
feat(nodedb): support INSERT ... ON CONFLICT DO UPDATE SET with expression evaluation
Introduce `UpdateValue` on the bridge — a tagged union carrying either pre-encoded msgpack bytes (fast literal path) or a `SqlExpr` that the Data Plane evaluates against the existing row at apply time. Planner changes: - `SqlPlan::Upsert` gains an `on_conflict_updates` field for the SET assignments extracted from `ON CONFLICT (...) DO UPDATE SET`. - `dml::plan_upsert_with_on_conflict` routes `INSERT ... ON CONFLICT DO UPDATE SET` through the upsert path, carrying the assignments. - `assignments_to_update_values` converts planner `SqlExpr` to `UpdateValue`, choosing `Literal` for constant RHS and `Expr` for anything that must be evaluated per-row. - `ScanFilter` gains an `expr: Option<SqlExpr>` field so complex WHERE clauses (scalar functions, arithmetic, non-literal BETWEEN) that the planner cannot reduce to simple triples are shipped verbatim to the Data Plane for row-level evaluation. - `sql_expr_to_bridge_expr` extended to cover `UnaryOp`, `IsNull`, `Between`, `Substring`, `Trim`, and `Case` expressions. - `sql_plan_convert/filter.rs` populates the new `expr` field for general-purpose `FilterExpr::Expr` predicates. Executor changes: - `bulk_dml` and `upsert` handlers receive `&[(String, UpdateValue)]` instead of `&[(String, Vec<u8>)]`; literal arms are identical to the previous fast path, expression arms evaluate against a pre-update snapshot of the row (matching PostgreSQL semantics — assignments do not observe each other). - Empty `filter_bytes` in `bulk_dml` now means "no WHERE clause" instead of a deserialization error. - `check_generated_readonly` and `needs_recomputation` generified over the update-value type so both call sites compile without duplication. All existing call sites updated to pass `expr: None` on the `ScanFilter` constructor and supply the required `on_conflict_updates` slice.
1 parent e42c0ae commit 32f33c3

31 files changed

Lines changed: 628 additions & 589 deletions

File tree

nodedb-sql/src/engine_rules/document_schemaless.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ impl EngineRules for SchemalessRules {
2222
engine: EngineType::DocumentSchemaless,
2323
rows: p.rows,
2424
column_defaults: p.column_defaults,
25+
on_conflict_updates: p.on_conflict_updates,
2526
}])
2627
}
2728

nodedb-sql/src/engine_rules/document_strict.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ impl EngineRules for StrictRules {
2222
engine: EngineType::DocumentStrict,
2323
rows: p.rows,
2424
column_defaults: p.column_defaults,
25+
on_conflict_updates: p.on_conflict_updates,
2526
}])
2627
}
2728

nodedb-sql/src/engine_rules/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ pub struct UpsertParams {
5959
pub columns: Vec<String>,
6060
pub rows: Vec<Vec<(String, SqlValue)>>,
6161
pub column_defaults: Vec<(String, String)>,
62+
/// `ON CONFLICT (...) DO UPDATE SET` assignments. Empty for plain
63+
/// `UPSERT INTO ...`; populated when the caller is
64+
/// `INSERT ... ON CONFLICT ... DO UPDATE SET`.
65+
pub on_conflict_updates: Vec<(String, SqlExpr)>,
6266
}
6367

6468
/// Parameters for planning an AGGREGATE operation.

nodedb-sql/src/planner/dml.rs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,45 @@ use crate::parser::normalize::{normalize_ident, normalize_object_name};
88
use crate::resolver::expr::{convert_expr, convert_value};
99
use crate::types::*;
1010

11+
/// Extract `ON CONFLICT (...) DO UPDATE SET` assignments from an AST
12+
/// insert, or `None` if this is a plain INSERT.
13+
fn extract_on_conflict_updates(ins: &ast::Insert) -> Result<Option<Vec<(String, SqlExpr)>>> {
14+
let Some(on) = ins.on.as_ref() else {
15+
return Ok(None);
16+
};
17+
let ast::OnInsert::OnConflict(oc) = on else {
18+
return Ok(None);
19+
};
20+
let ast::OnConflictAction::DoUpdate(do_update) = &oc.action else {
21+
// DO NOTHING maps to "ignore conflict" — currently unsupported.
22+
return Err(SqlError::Unsupported {
23+
detail: "ON CONFLICT DO NOTHING is not yet supported".into(),
24+
});
25+
};
26+
let mut pairs = Vec::with_capacity(do_update.assignments.len());
27+
for a in &do_update.assignments {
28+
let name = match &a.target {
29+
ast::AssignmentTarget::ColumnName(obj) => normalize_object_name(obj),
30+
_ => {
31+
return Err(SqlError::Unsupported {
32+
detail: "ON CONFLICT DO UPDATE SET target must be a column name".into(),
33+
});
34+
}
35+
};
36+
let expr = convert_expr(&a.value)?;
37+
pairs.push((name, expr));
38+
}
39+
Ok(Some(pairs))
40+
}
41+
1142
/// Plan an INSERT statement.
1243
pub fn plan_insert(ins: &ast::Insert, catalog: &dyn SqlCatalog) -> Result<Vec<SqlPlan>> {
44+
// `INSERT ... ON CONFLICT DO UPDATE SET` reroutes to the upsert path
45+
// with the assignments carried through. Detected before any other
46+
// work so both planning paths share the `ast::Insert` decode below.
47+
if let Some(on_conflict_updates) = extract_on_conflict_updates(ins)? {
48+
return plan_upsert_with_on_conflict(ins, catalog, on_conflict_updates);
49+
}
1350
let table_name = match &ins.table {
1451
ast::TableObject::TableName(name) => normalize_object_name(name),
1552
ast::TableObject::TableFunction(_) => {
@@ -190,6 +227,60 @@ pub fn plan_upsert(ins: &ast::Insert, catalog: &dyn SqlCatalog) -> Result<Vec<Sq
190227
columns,
191228
rows,
192229
column_defaults,
230+
on_conflict_updates: Vec::new(),
231+
})
232+
}
233+
234+
/// Plan an `INSERT ... ON CONFLICT DO UPDATE SET` statement. Identical to
235+
/// `plan_upsert` except the assignments are carried onto the upsert plan
236+
/// so the Data Plane can evaluate them against the existing row instead
237+
/// of merging the would-be-inserted values.
238+
fn plan_upsert_with_on_conflict(
239+
ins: &ast::Insert,
240+
catalog: &dyn SqlCatalog,
241+
on_conflict_updates: Vec<(String, SqlExpr)>,
242+
) -> Result<Vec<SqlPlan>> {
243+
let table_name = match &ins.table {
244+
ast::TableObject::TableName(name) => normalize_object_name(name),
245+
ast::TableObject::TableFunction(_) => {
246+
return Err(SqlError::Unsupported {
247+
detail: "INSERT ... ON CONFLICT on a table function is not supported".into(),
248+
});
249+
}
250+
};
251+
let info = catalog
252+
.get_collection(&table_name)?
253+
.ok_or_else(|| SqlError::UnknownTable {
254+
name: table_name.clone(),
255+
})?;
256+
257+
let columns: Vec<String> = ins.columns.iter().map(normalize_ident).collect();
258+
259+
let source = ins.source.as_ref().ok_or_else(|| SqlError::Parse {
260+
detail: "INSERT ... ON CONFLICT requires VALUES".into(),
261+
})?;
262+
let rows_ast = match &*source.body {
263+
ast::SetExpr::Values(values) => &values.rows,
264+
_ => {
265+
return Err(SqlError::Unsupported {
266+
detail: "INSERT ... ON CONFLICT source must be VALUES".into(),
267+
});
268+
}
269+
};
270+
271+
let rows = convert_value_rows(&columns, rows_ast)?;
272+
let column_defaults: Vec<(String, String)> = info
273+
.columns
274+
.iter()
275+
.filter_map(|c| c.default.as_ref().map(|d| (c.name.clone(), d.clone())))
276+
.collect();
277+
let rules = engine_rules::resolve_engine_rules(info.engine);
278+
rules.plan_upsert(engine_rules::UpsertParams {
279+
collection: table_name,
280+
columns,
281+
rows,
282+
column_defaults,
283+
on_conflict_updates,
193284
})
194285
}
195286

nodedb-sql/src/types.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ pub enum SqlPlan {
6565
engine: EngineType,
6666
rows: Vec<Vec<(String, SqlValue)>>,
6767
column_defaults: Vec<(String, String)>,
68+
/// `ON CONFLICT (...) DO UPDATE SET field = expr` assignments.
69+
/// When empty, upsert is a plain merge: new columns overwrite existing.
70+
/// When non-empty, the engine applies these per-row against the
71+
/// *existing* document instead of merging the inserted values.
72+
on_conflict_updates: Vec<(String, SqlExpr)>,
6873
},
6974
InsertSelect {
7075
target: String,

nodedb/src/bridge/physical_plan/document.rs

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,54 @@
22
33
use nodedb_types::columnar::StrictSchema;
44

5+
/// Right-hand side of an UPDATE ... SET field = <...> assignment.
6+
///
7+
/// The planner turns each assignment into one of these before it crosses
8+
/// the SPSC bridge:
9+
///
10+
/// - `Literal` — pre-encoded msgpack bytes for constant RHS. This is the
11+
/// fast path: the Data Plane can merge these at the binary level for
12+
/// non-strict collections without decoding the current row.
13+
/// - `Expr` — a `SqlExpr` that must be evaluated against the *current*
14+
/// document at apply time. Used for arithmetic (`col + 1`), functions
15+
/// (`LOWER(col)`, `NOW()`), `CASE`, concatenation, and anything else
16+
/// whose result depends on the row being updated.
17+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
18+
pub enum UpdateValue {
19+
Literal(Vec<u8>),
20+
Expr(crate::bridge::expr_eval::SqlExpr),
21+
}
22+
23+
impl zerompk::ToMessagePack for UpdateValue {
24+
fn write<W: zerompk::Write>(&self, writer: &mut W) -> zerompk::Result<()> {
25+
writer.write_array_len(2)?;
26+
match self {
27+
UpdateValue::Literal(bytes) => {
28+
writer.write_u8(0)?;
29+
bytes.write(writer)
30+
}
31+
UpdateValue::Expr(expr) => {
32+
writer.write_u8(1)?;
33+
expr.write(writer)
34+
}
35+
}
36+
}
37+
}
38+
39+
impl<'a> zerompk::FromMessagePack<'a> for UpdateValue {
40+
fn read<R: zerompk::Read<'a>>(reader: &mut R) -> zerompk::Result<Self> {
41+
reader.check_array_len(2)?;
42+
let tag = reader.read_u8()?;
43+
match tag {
44+
0 => Ok(UpdateValue::Literal(Vec::<u8>::read(reader)?)),
45+
1 => Ok(UpdateValue::Expr(crate::bridge::expr_eval::SqlExpr::read(
46+
reader,
47+
)?)),
48+
_ => Err(zerompk::Error::InvalidMarker(tag)),
49+
}
50+
}
51+
}
52+
553
/// Storage encoding mode for a document collection.
654
///
755
/// Determines how documents are serialized before storage in the sparse engine.
@@ -139,8 +187,8 @@ pub enum DocumentOp {
139187
PointUpdate {
140188
collection: String,
141189
document_id: String,
142-
/// Field name → new JSON value.
143-
updates: Vec<(String, Vec<u8>)>,
190+
/// Field name → assignment RHS (literal bytes or row-scope expression).
191+
updates: Vec<(String, UpdateValue)>,
144192
/// If true, return the post-update document as payload (for RETURNING clause).
145193
returning: bool,
146194
},
@@ -217,18 +265,22 @@ pub enum DocumentOp {
217265
source_limit: usize,
218266
},
219267

220-
/// Upsert: insert or merge.
268+
/// Upsert: insert or merge. When `on_conflict_updates` is non-empty,
269+
/// the conflict branch evaluates those assignments against the
270+
/// *existing* document instead of merging the inserted value —
271+
/// the `INSERT ... ON CONFLICT DO UPDATE SET ...` path.
221272
Upsert {
222273
collection: String,
223274
document_id: String,
224275
value: Vec<u8>,
276+
on_conflict_updates: Vec<(String, UpdateValue)>,
225277
},
226278

227279
/// Bulk update: scan + apply field updates to all matches.
228280
BulkUpdate {
229281
collection: String,
230282
filters: Vec<u8>,
231-
updates: Vec<(String, Vec<u8>)>,
283+
updates: Vec<(String, UpdateValue)>,
232284
/// If true, return updated documents as JSON array payload (for RETURNING clause).
233285
returning: bool,
234286
},

nodedb/src/bridge/physical_plan/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ pub use columnar::ColumnarOp;
2020
pub use crdt::CrdtOp;
2121
pub use document::{
2222
BalancedDef, DocumentOp, EnforcementOptions, GeneratedColumnSpec, MaterializedSumBinding,
23-
PeriodLockConfig, StorageMode,
23+
PeriodLockConfig, StorageMode, UpdateValue,
2424
};
2525
pub use graph::GraphOp;
2626
pub use kv::KvOp;

nodedb/src/control/planner/rls_injection.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,7 @@ fn inject_permission_tree_for_plan(
352352
.collect(),
353353
),
354354
clauses: Vec::new(),
355+
expr: None,
355356
};
356357

357358
let filter_bytes =

nodedb/src/control/planner/sql_plan_convert/aggregate.rs

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -345,8 +345,43 @@ pub(super) fn serialize_window_functions(
345345

346346
#[cfg(test)]
347347
mod tests {
348-
use super::{extract_computed_columns, extract_projection_names};
349-
use nodedb_sql::types::{Projection, SqlExpr, WindowSpec};
348+
use super::{agg_expr_to_spec, extract_computed_columns, extract_projection_names};
349+
use nodedb_sql::types::{AggregateExpr, BinaryOp, Projection, SqlExpr, SqlValue, WindowSpec};
350+
351+
#[test]
352+
fn aggregate_spec_preserves_alias_and_case_expression() {
353+
let agg = AggregateExpr {
354+
function: "sum".into(),
355+
args: vec![SqlExpr::Case {
356+
operand: None,
357+
when_then: vec![(
358+
SqlExpr::BinaryOp {
359+
left: Box::new(SqlExpr::Column {
360+
table: None,
361+
name: "category".into(),
362+
}),
363+
op: BinaryOp::Eq,
364+
right: Box::new(SqlExpr::Literal(SqlValue::String("tools".into()))),
365+
},
366+
SqlExpr::Literal(SqlValue::Int(1)),
367+
)],
368+
else_expr: Some(Box::new(SqlExpr::Literal(SqlValue::Int(0)))),
369+
}],
370+
alias: "tools_count".into(),
371+
distinct: false,
372+
};
373+
374+
let spec = agg_expr_to_spec(&agg);
375+
376+
assert_eq!(spec.function, "sum");
377+
assert_eq!(spec.alias, "sum(*)");
378+
assert_eq!(spec.user_alias.as_deref(), Some("tools_count"));
379+
assert_eq!(spec.field, "*");
380+
assert!(matches!(
381+
spec.expr,
382+
Some(crate::bridge::expr_eval::SqlExpr::Case { .. })
383+
));
384+
}
350385

351386
#[test]
352387
fn window_aliases_stay_in_projection_and_out_of_computed_columns() {

nodedb/src/control/planner/sql_plan_convert/convert.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,15 @@ pub(super) fn convert_one(
8484
engine,
8585
rows,
8686
column_defaults,
87-
} => super::dml::convert_upsert(collection, engine, rows, column_defaults, tenant_id),
87+
on_conflict_updates,
88+
} => super::dml::convert_upsert(
89+
collection,
90+
engine,
91+
rows,
92+
column_defaults,
93+
on_conflict_updates,
94+
tenant_id,
95+
),
8896

8997
SqlPlan::KvInsert {
9098
collection,

0 commit comments

Comments
 (0)