From 29a2350a0a7a7dff41d7bd3fc9e265b5917f90fb Mon Sep 17 00:00:00 2001 From: EnRaiha <15997552+EnRaiha@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:10:53 +0800 Subject: [PATCH 1/4] fix(sql): evaluate sequence-backed DEFAULTs on every engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEFAULT expressions were honored only where an engine happened to wire them: uuid() worked on strict, silently vanished on schemaless document, kv, and columnar; sequence defaults (nextval) did not run anywhere — the pure planner evaluator does not know the accessors, and every engine path swallowed "no value" into a missing column. #294's repro: DDL accepted `DEFAULT nextval('s')`, every insert silently committed NULL (including primary keys). One defect at two layers, fixed together: 1. Storage: the catalog adapter's schemaless branch discarded the DEFAULT clause even though `stored.fields` carries the full DDL constraint text. UUID-class defaults on the document engine now materialize. 2. Evaluation: one shared per-row expander (`expand_row_defaults`) serves INSERT, UPSERT, the columnar batch encoder and the kv converter — replacing the per-path pure-evaluator swallows. Sequence accessors are classified by ONE canonical parser (`sequence_accessor`, byte-safe, robust-parsing-gate clean) shared between planner and convert: - nextval('name') advances the CP-side registry per row; - currval/setval in a DEFAULT raise loudly (no per-row meaning); - accessor-shaped but malformed (nextval('')) raises loudly; - unknown sequences raise naming them. A DDL-accepted DEFAULT never silently becomes NULL. Mechanics: SequenceRegistry threaded SharedState -> QueryContext -> ConvertContext; SqlPlan::KvInsert carries key_column + sequence_defaults (kv planner skips them; converter fills key slot + mirrored value map so scans read the defaulted key back); rows.rs and the doc-family paths share the same expander; nodedb_value_to_sql moved into value/convert. Verified: wire sequence_default_all_engines 8 tests (strict/doc/kv/ columnar/doc-UPSERT nextval fills; uuid sentinel; currval loud; malformed loud; unknown-seq loud) + sequence_default_typed 3; regression sentinels green (kv_column_defaults, dml_returning, not-null gate). fmt clean; clippy -D warnings clean; robust-parsing + calvin gates clean; sql 864 + types 688. Flow also verified by instrumented run (SEQDBG logs): doc nextval filled Integer(1),(2); currval reached the loud branch; uuid_v7 went through the pure evaluator. Not included here (tracked separately): SELECT/currval/setval evaluation — accessors stay unregistered so the gate keeps raising 42883 loudly; the registry registration ships together with the SELECT-side fold so no commit ever leaves a registered call folding to NULL. Partially addresses #294. --- nodedb-sql/src/planner/defaults.rs | 58 +++++ .../src/planner/dml_helpers/kv_insert.rs | 22 +- nodedb-sql/src/types/plan/variants.rs | 6 + .../src/visitor/plan_visitor/dispatch.rs | 12 +- .../src/visitor/plan_visitor/trait_def.rs | 3 + .../planner/catalog_adapter/type_convert.rs | 22 +- .../control/planner/context/query/context.rs | 9 + .../control/planner/context/query/planning.rs | 2 + .../array_fn_convert/aggregate.rs | 1 + .../array_fn_convert/slice.rs | 1 + .../planner/sql_plan_convert/convert.rs | 4 + .../planner/sql_plan_convert/dml/insert.rs | 31 +-- .../sql_plan_convert/dml/kv_and_vector.rs | 51 +++- .../dml/update_delete/delete.rs | 1 + .../dml/update_delete/update.rs | 1 + .../planner/sql_plan_convert/dml/upsert.rs | 8 +- .../planner/sql_plan_convert/set_ops.rs | 2 + .../planner/sql_plan_convert/value/convert.rs | 11 + .../planner/sql_plan_convert/value/mod.rs | 3 + .../planner/sql_plan_convert/value/rows.rs | 21 +- .../value/sequence_default.rs | 104 ++++++++ .../sql_plan_convert/visitor/arms_dml.rs | 4 + .../executor_tests/test_group_by_alias.rs | 1 + nodedb/tests/wire/cases/mod.rs | 2 + .../cases/sequence_default_all_engines.rs | 226 ++++++++++++++++++ .../wire/cases/sequence_default_typed.rs | 109 +++++++++ 26 files changed, 671 insertions(+), 44 deletions(-) create mode 100644 nodedb/src/control/planner/sql_plan_convert/value/sequence_default.rs create mode 100644 nodedb/tests/wire/cases/sequence_default_all_engines.rs create mode 100644 nodedb/tests/wire/cases/sequence_default_typed.rs diff --git a/nodedb-sql/src/planner/defaults.rs b/nodedb-sql/src/planner/defaults.rs index 5fb914978..59bcdd8ac 100644 --- a/nodedb-sql/src/planner/defaults.rs +++ b/nodedb-sql/src/planner/defaults.rs @@ -120,3 +120,61 @@ fn sql_value_to_ndb(v: crate::types::SqlValue) -> nodedb_types::Value { SqlValue::Timestamptz(dt) => nodedb_types::Value::DateTime(dt), } } + +/// A sequence accessor appearing in a DEFAULT expression. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SequenceAccessor { + Nextval, + Currval, + Setval, +} + +/// Whether a DEFAULT expression *starts like* a sequence accessor +/// (`nextval(`/`currval(`/`setval(` … `)`) even when the body does not parse. +/// Malformed accessor-looking defaults must raise loudly — never fall back +/// to the pure evaluator and silently vanish (#294 class). +pub fn looks_like_sequence_accessor(expr: &str) -> bool { + let t = expr.trim(); + let bytes = t.as_bytes(); + if bytes.len() < 8 || !t.ends_with(')') { + return false; + } + let head = |n: usize, name: &[u8]| bytes[..n].eq_ignore_ascii_case(name) && bytes[n] == b'('; + head(7, b"nextval") || head(7, b"currval") || head(6, b"setval") +} + +/// One canonical sequence-accessor recognizer for DEFAULT expressions, +/// shared by the SQL planner (which must skip these — the pure evaluator +/// cannot run them) and the convert layer (which advances the CP-side +/// registry). Matched on the ORIGINAL bytes, ASCII case-insensitive — never +/// by slicing the original with a length taken from a case-folded copy. +pub fn sequence_accessor(expr: &str) -> Option<(SequenceAccessor, String)> { + let t = expr.trim(); + let bytes = t.as_bytes(); + if bytes.len() < 8 || !t.ends_with(')') { + return None; + } + let (accessor, prefix_len) = if bytes[..7].eq_ignore_ascii_case(b"nextval") { + (SequenceAccessor::Nextval, 7) + } else if bytes[..7].eq_ignore_ascii_case(b"currval") { + (SequenceAccessor::Currval, 7) + } else if bytes[..6].eq_ignore_ascii_case(b"setval") { + (SequenceAccessor::Setval, 6) + } else { + return None; + }; + if bytes[prefix_len] != b'(' { + return None; + } + let inner = &t[prefix_len + 1..t.len() - 1]; + let inner = inner.trim(); + let name = inner + .strip_prefix('\'') + .and_then(|s| s.strip_suffix('\'')) + .or_else(|| inner.strip_prefix('"').and_then(|s| s.strip_suffix('"'))) + .unwrap_or(inner); + if name.is_empty() { + return None; + } + Some((accessor, name.to_string())) +} diff --git a/nodedb-sql/src/planner/dml_helpers/kv_insert.rs b/nodedb-sql/src/planner/dml_helpers/kv_insert.rs index 6aa1fd911..e973fbd8a 100644 --- a/nodedb-sql/src/planner/dml_helpers/kv_insert.rs +++ b/nodedb-sql/src/planner/dml_helpers/kv_insert.rs @@ -108,7 +108,10 @@ pub(crate) fn build_kv_insert_plan( // position in the statement's column list. let key_val = match row.iter().find(|(name, _)| name == key_col_name) { Some((_, value)) => value.clone(), - None => SqlValue::String(String::new()), + // NULL marks "key column absent from this row" — the converter + // fills a sequence default there, or stores the legacy empty key + // when none is declared. + None => SqlValue::Null, }; if let Some((_, value)) = row.iter().find(|(name, _)| name == "ttl") { match value { @@ -124,12 +127,23 @@ pub(crate) fn build_kv_insert_plan( .collect(); entries.push((key_val, value_cols)); } + let sequence_defaults: Vec<(String, String)> = declared_columns + .iter() + .filter_map(|c| { + c.default + .as_ref() + .filter(|d| crate::planner::defaults::looks_like_sequence_accessor(d)) + .map(|d| (c.name.clone(), d.clone())) + }) + .collect(); Ok(vec![SqlPlan::KvInsert { collection: table_name, entries, ttl_secs, intent, on_conflict_updates, + key_column: key_col_name.to_string(), + sequence_defaults, }]) } @@ -162,6 +176,12 @@ fn materialize_declared_defaults( if row.iter().any(|(name, _)| name == &column.name) { continue; } + // Sequence accessors cannot run in the pure planner evaluator; the + // converter advances the CP-side registry instead (the column is + // carried on the plan as a sequence default and stays absent here). + if crate::planner::defaults::looks_like_sequence_accessor(default_expr) { + continue; + } let evaluated = crate::planner::defaults::evaluate_default_expr(default_expr).map_err(|e| { SqlError::Parse { diff --git a/nodedb-sql/src/types/plan/variants.rs b/nodedb-sql/src/types/plan/variants.rs index 5e539e7b8..4752b0bb6 100644 --- a/nodedb-sql/src/types/plan/variants.rs +++ b/nodedb-sql/src/types/plan/variants.rs @@ -138,6 +138,12 @@ pub enum SqlPlan { /// Empty for plain UPSERT (whole-value overwrite) and for INSERT /// variants. on_conflict_updates: Vec<(String, SqlExpr)>, + /// The collection's primary-key column name (the KV key slot). + key_column: String, + /// Column defaults that are sequence accessors (`nextval(...)`) — + /// the pure planner evaluator cannot run them; the converter + /// advances the CP-side registry per row instead. + sequence_defaults: Vec<(String, String)>, }, /// UPSERT: insert or merge if document exists. Upsert { diff --git a/nodedb-sql/src/visitor/plan_visitor/dispatch.rs b/nodedb-sql/src/visitor/plan_visitor/dispatch.rs index 1357c1b1d..573b7ca91 100644 --- a/nodedb-sql/src/visitor/plan_visitor/dispatch.rs +++ b/nodedb-sql/src/visitor/plan_visitor/dispatch.rs @@ -113,7 +113,17 @@ pub fn dispatch(visitor: &mut V, plan: &SqlPlan) -> Result visitor.kv_insert(collection, entries, *ttl_secs, *intent, on_conflict_updates), + key_column, + sequence_defaults, + } => visitor.kv_insert( + collection, + entries, + *ttl_secs, + *intent, + on_conflict_updates, + key_column, + sequence_defaults, + ), SqlPlan::Upsert { collection, engine, diff --git a/nodedb-sql/src/visitor/plan_visitor/trait_def.rs b/nodedb-sql/src/visitor/plan_visitor/trait_def.rs index 6a4ce0b0b..a8c9757c0 100644 --- a/nodedb-sql/src/visitor/plan_visitor/trait_def.rs +++ b/nodedb-sql/src/visitor/plan_visitor/trait_def.rs @@ -73,6 +73,7 @@ pub trait PlanVisitor { fn insert(&mut self, args: InsertVisitArgs<'_>) -> Result; /// Handle [`SqlPlan::KvInsert`]. + #[allow(clippy::too_many_arguments)] fn kv_insert( &mut self, collection: &str, @@ -80,6 +81,8 @@ pub trait PlanVisitor { ttl_secs: u64, intent: KvInsertIntent, on_conflict_updates: &[(String, SqlExpr)], + key_column: &str, + sequence_defaults: &[(String, String)], ) -> Result; /// Handle [`SqlPlan::Upsert`]. diff --git a/nodedb/src/control/planner/catalog_adapter/type_convert.rs b/nodedb/src/control/planner/catalog_adapter/type_convert.rs index 02c8f8af6..b7ca42c2e 100644 --- a/nodedb/src/control/planner/catalog_adapter/type_convert.rs +++ b/nodedb/src/control/planner/catalog_adapter/type_convert.rs @@ -61,12 +61,24 @@ pub(super) fn convert_collection_type( .declared_primary_key .clone() .unwrap_or_else(|| "id".to_string()); + // The stored field entry keeps the FULL DDL constraint text + // (`"BIGINT DEFAULT nextval('s') PRIMARY KEY"`), so the DEFAULT + // expression is recoverable even though the schemaless type + // system records no per-column default slot of its own. Dropping + // it here is what made `DEFAULT uuid_v7()` / `DEFAULT + // nextval('s')` silently commit NULL on the document engine + // (#294): the DDL accepted the expression, the catalog forgot it. + let pk_default = stored + .fields + .iter() + .find(|(n, _)| n.eq_ignore_ascii_case(&pk_name)) + .and_then(|(_, ts)| doc_default_expr(ts)); let mut columns = vec![ColumnInfo { name: pk_name.clone(), data_type: SqlDataType::String, nullable: false, is_primary_key: true, - default: None, + default: pk_default, raw_type: None, int_width: None, float_width: None, @@ -81,7 +93,7 @@ pub(super) fn convert_collection_type( data_type: parse_type_str(type_str), nullable: true, is_primary_key: false, - default: None, + default: doc_default_expr(type_str), raw_type: None, int_width: IntWidth::from_declared_type(type_str), float_width: FloatWidth::from_declared_type(type_str), @@ -289,6 +301,12 @@ fn parse_type_str(s: &str) -> SqlDataType { } } +fn doc_default_expr(type_str: &str) -> Option { + let (_, _, _, default_expr) = + nodedb_sql::ddl_ast::collection_type::parse_column_type_str_full(type_str); + default_expr +} + #[cfg(test)] mod tests { use nodedb_types::CollectionType; diff --git a/nodedb/src/control/planner/context/query/context.rs b/nodedb/src/control/planner/context/query/context.rs index a7cee59a2..139ac426f 100644 --- a/nodedb/src/control/planner/context/query/context.rs +++ b/nodedb/src/control/planner/context/query/context.rs @@ -40,6 +40,11 @@ pub struct QueryContext { /// `QueryContext::new()` test fixtures that never lower to /// surrogate-bearing variants. pub(super) surrogate_assigner: Option>, + /// Sequence registry — `Some` when the planner has access to + /// `SharedState` (production path). SQL sequence accessors + /// (`nextval`/`currval`/`setval`) and sequence-backed DEFAULT + /// expressions evaluate through this; `None` for sub-planners. + pub(super) sequence_registry: Option>, /// Cluster mode flag — `true` when the node has a live cluster /// topology. Passed into `ConvertContext` so array converters can /// emit `ClusterArray` variants instead of local `Array` variants. @@ -109,6 +114,7 @@ impl QueryContext { array_catalog: None, wal: None, surrogate_assigner: None, + sequence_registry: None, cluster_enabled: false, bitemporal_retention_registry: None, max_vector_dim: std::sync::atomic::AtomicU32::new(0), @@ -140,6 +146,7 @@ impl QueryContext { Some(Arc::clone(&state.retention_policy_registry)), ); ctx.surrogate_assigner = Some(Arc::clone(&state.surrogate_assigner)); + ctx.sequence_registry = Some(Arc::clone(&state.sequence_registry)); ctx.cluster_enabled = state.cluster_topology.is_some(); ctx.bitemporal_retention_registry = Some(Arc::clone(&state.bitemporal_retention_registry)); // max_vector_dim starts at 0 (unlimited); connection handlers call @@ -174,6 +181,7 @@ impl QueryContext { array_catalog: Some(state.array_catalog.clone()), wal: Some(Arc::clone(&state.wal)), surrogate_assigner: Some(Arc::clone(&state.surrogate_assigner)), + sequence_registry: Some(Arc::clone(&state.sequence_registry)), cluster_enabled: state.cluster_topology.is_some(), bitemporal_retention_registry: Some(Arc::clone(&state.bitemporal_retention_registry)), // max_vector_dim is tenant-specific; callers supply it via @@ -215,6 +223,7 @@ impl QueryContext { array_catalog: None, wal: None, surrogate_assigner: None, + sequence_registry: None, cluster_enabled: false, bitemporal_retention_registry: None, max_vector_dim: std::sync::atomic::AtomicU32::new(0), diff --git a/nodedb/src/control/planner/context/query/planning.rs b/nodedb/src/control/planner/context/query/planning.rs index 8a845dcd7..7a4724187 100644 --- a/nodedb/src/control/planner/context/query/planning.rs +++ b/nodedb/src/control/planner/context/query/planning.rs @@ -156,6 +156,7 @@ impl QueryContext { .map(|i| Arc::clone(&i.credentials)), wal: self.wal.clone(), surrogate_assigner: self.surrogate_assigner.clone(), + sequence_registry: self.sequence_registry.clone(), cluster_enabled: self.cluster_enabled, bitemporal_retention_registry: self.bitemporal_retention_registry.clone(), max_vector_dim: self @@ -421,6 +422,7 @@ impl QueryContext { .map(|i| Arc::clone(&i.credentials)), wal: self.wal.clone(), surrogate_assigner: self.surrogate_assigner.clone(), + sequence_registry: self.sequence_registry.clone(), cluster_enabled: self.cluster_enabled, bitemporal_retention_registry: self.bitemporal_retention_registry.clone(), max_vector_dim: self diff --git a/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/aggregate.rs b/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/aggregate.rs index d52fc535b..71208f710 100644 --- a/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/aggregate.rs +++ b/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/aggregate.rs @@ -144,6 +144,7 @@ mod tests { credentials: None, wal: None, surrogate_assigner: None, + sequence_registry: None, cluster_enabled, bitemporal_retention_registry: None, max_vector_dim: 0, diff --git a/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/slice.rs b/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/slice.rs index ed997b9f4..dae12aea5 100644 --- a/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/slice.rs +++ b/nodedb/src/control/planner/sql_plan_convert/array_fn_convert/slice.rs @@ -246,6 +246,7 @@ mod tests { credentials: None, wal: None, surrogate_assigner: None, + sequence_registry: None, cluster_enabled, bitemporal_retention_registry: None, max_vector_dim: 0, diff --git a/nodedb/src/control/planner/sql_plan_convert/convert.rs b/nodedb/src/control/planner/sql_plan_convert/convert.rs index 7f2a67670..44b091081 100644 --- a/nodedb/src/control/planner/sql_plan_convert/convert.rs +++ b/nodedb/src/control/planner/sql_plan_convert/convert.rs @@ -62,6 +62,9 @@ pub struct ConvertContext { pub credentials: Option>, /// LSN allocator for array Put/Delete dispatches. pub wal: Option>, + /// Sequence registry for SQL sequence accessors and sequence-backed + /// DEFAULT expressions. `None` for sub-planner contexts. + pub sequence_registry: Option>, /// CP-side surrogate assigner — bound to the same `Arc` held on /// `SharedState`. Threaded into INSERT/UPSERT/KV-INSERT converters /// to bind `(collection, pk_bytes)` → `Surrogate` before the op @@ -281,6 +284,7 @@ mod tests { credentials: None, wal: None, surrogate_assigner: Some(assigner), + sequence_registry: None, cluster_enabled: false, bitemporal_retention_registry: None, max_vector_dim: 0, diff --git a/nodedb/src/control/planner/sql_plan_convert/dml/insert.rs b/nodedb/src/control/planner/sql_plan_convert/dml/insert.rs index ba33361eb..e164b75b5 100644 --- a/nodedb/src/control/planner/sql_plan_convert/dml/insert.rs +++ b/nodedb/src/control/planner/sql_plan_convert/dml/insert.rs @@ -143,17 +143,6 @@ pub(super) fn columnar_row_surrogates( Ok(out) } -pub(in super::super) fn nodedb_value_to_sql(val: nodedb_types::Value) -> SqlValue { - match val { - nodedb_types::Value::Integer(n) => SqlValue::Int(n), - nodedb_types::Value::Float(f) => SqlValue::Float(f), - nodedb_types::Value::String(s) => SqlValue::String(s), - nodedb_types::Value::Bool(b) => SqlValue::Bool(b), - nodedb_types::Value::Null => SqlValue::Null, - _ => SqlValue::String(format!("{val:?}")), - } -} - /// Bundled arguments for [`convert_insert`]. pub(in super::super) struct ConvertInsertArgs<'a> { pub collection: &'a str, @@ -231,17 +220,7 @@ pub(in super::super) fn convert_insert( expanded_rows.push(row.clone()); continue; } - let mut expanded = row.clone(); - for (col_name, default_expr) in column_defaults { - if !expanded.iter().any(|(k, _)| k == col_name) - && let Some(val) = super::super::value::evaluate_default_expr(default_expr) - .map_err(|e| crate::Error::PlanError { - detail: format!("default for column '{col_name}': {e}"), - })? - { - expanded.push((col_name.clone(), nodedb_value_to_sql(val))); - } - } + let expanded = super::super::value::expand_row_defaults(ctx, row, column_defaults)?; expanded_rows.push(expanded); } @@ -262,7 +241,10 @@ pub(in super::super) fn convert_insert( }); } EngineType::Columnar | EngineType::Spatial => { - columnar_rows.push(&rows[i]); + // `expanded_rows[i]` carries the materialized defaults; the + // raw `rows[i]` may still be missing the column (sequence and + // stateless defaults would silently vanish otherwise). + columnar_rows.push(&expanded_rows[i]); } EngineType::DocumentSchemaless | EngineType::DocumentStrict => { let value_bytes = row_to_msgpack(row)?; @@ -352,7 +334,7 @@ pub(in super::super) fn convert_insert( } if !columnar_rows.is_empty() { - let payload = rows_to_msgpack_array(&columnar_rows, column_defaults)?; + let payload = rows_to_msgpack_array(&columnar_rows, column_defaults, ctx)?; let intent = if if_absent { ColumnarInsertIntent::InsertIfAbsent } else { @@ -433,6 +415,7 @@ mod tests { credentials: Some(Arc::new(store)), wal: None, surrogate_assigner: None, + sequence_registry: None, cluster_enabled: false, bitemporal_retention_registry: None, max_vector_dim: 0, diff --git a/nodedb/src/control/planner/sql_plan_convert/dml/kv_and_vector.rs b/nodedb/src/control/planner/sql_plan_convert/dml/kv_and_vector.rs index bdeda94a9..9b2e78c1f 100644 --- a/nodedb/src/control/planner/sql_plan_convert/dml/kv_and_vector.rs +++ b/nodedb/src/control/planner/sql_plan_convert/dml/kv_and_vector.rs @@ -14,12 +14,15 @@ use super::super::value::{ use super::insert::assign_for_pk; use nodedb_physical::physical_task::{PhysicalTask, PostSetOp}; +#[allow(clippy::too_many_arguments)] pub(in super::super) fn convert_kv_insert( collection: &str, entries: &[(SqlValue, Vec<(String, SqlValue)>)], ttl_secs: u64, intent: KvInsertIntent, on_conflict_updates: &[(String, SqlExpr)], + key_column: &str, + sequence_defaults: &[(String, String)], tenant_id: TenantId, ctx: &ConvertContext, ) -> crate::Result> { @@ -35,13 +38,39 @@ pub(in super::super) fn convert_kv_insert( let ttl_ms = ttl_secs * 1000; let mut tasks = Vec::with_capacity(entries.len()); for (key_val, value_cols) in entries { - let key = sql_value_to_bytes(key_val); + // Sequence-backed defaults (key or value column) advance the CP-side + // registry here — the planner cannot run them. A NULL key marks the + // "key column absent" case; a declared sequence default fills it, + // otherwise the legacy empty key is stored. + let mut key_val = key_val.clone(); + let mut value_cols = value_cols.clone(); + if matches!(key_val, SqlValue::Null) + && let Some((_, expr)) = sequence_defaults + .iter() + .find(|(name, _)| name == key_column) + { + key_val = sequence_default_value(ctx, expr)?; + // Named primary-key columns are mirrored into the value map + // so scans can project/filter them (see the builder's + // exclusion rule); a defaulted key must mirror too, or the + // row reads back with a blank key column. + if key_column != "key" && !value_cols.iter().any(|(c, _)| c == key_column) { + value_cols.push((key_column.to_string(), key_val.clone())); + } + } + for (name, expr) in sequence_defaults { + if name != key_column && !value_cols.iter().any(|(c, _)| c == name) { + let val = sequence_default_value(ctx, expr)?; + value_cols.push((name.clone(), val)); + } + } + let key = sql_value_to_bytes(&key_val); let value = if value_cols.len() == 1 && value_cols[0].0 == "value" { sql_value_to_bytes(&value_cols[0].1) } else { let mut buf = Vec::with_capacity(value_cols.len() * 32); write_msgpack_map_header(&mut buf, value_cols.len()); - for (col, val) in value_cols { + for (col, val) in &value_cols { write_msgpack_str(&mut buf, col); write_msgpack_value(&mut buf, val); } @@ -190,6 +219,23 @@ pub(in super::super) fn convert_vector_primary_insert( Ok(tasks) } +fn sequence_default_value(ctx: &ConvertContext, expr: &str) -> crate::Result { + let Some(registry) = &ctx.sequence_registry else { + return Err(crate::Error::PlanError { + detail: format!("sequence default '{expr}' requires sequence registry access"), + }); + }; + let name = super::super::value::sequence_name(expr).ok_or_else(|| crate::Error::PlanError { + detail: format!("unrecognized sequence default expression: '{expr}'"), + })?; + let value = registry + .nextval(ctx.database_id.as_u64(), ctx.tenant_id.as_u64(), &name) + .map_err(|e| crate::Error::PlanError { + detail: format!("nextval('{name}'): {e}"), + })?; + Ok(SqlValue::Int(value)) +} + #[cfg(test)] mod tests { use super::super::super::convert::ConvertContext; @@ -204,6 +250,7 @@ mod tests { credentials: None, wal: None, surrogate_assigner: None, + sequence_registry: None, cluster_enabled: false, bitemporal_retention_registry: None, max_vector_dim, diff --git a/nodedb/src/control/planner/sql_plan_convert/dml/update_delete/delete.rs b/nodedb/src/control/planner/sql_plan_convert/dml/update_delete/delete.rs index 29246ede5..ab5bd42f7 100644 --- a/nodedb/src/control/planner/sql_plan_convert/dml/update_delete/delete.rs +++ b/nodedb/src/control/planner/sql_plan_convert/dml/update_delete/delete.rs @@ -250,6 +250,7 @@ mod tests { credentials: Some(Arc::new(store)), wal: None, surrogate_assigner: None, + sequence_registry: None, cluster_enabled: false, bitemporal_retention_registry: None, max_vector_dim: 0, diff --git a/nodedb/src/control/planner/sql_plan_convert/dml/update_delete/update.rs b/nodedb/src/control/planner/sql_plan_convert/dml/update_delete/update.rs index 0be4cdacc..a4d1e03a7 100644 --- a/nodedb/src/control/planner/sql_plan_convert/dml/update_delete/update.rs +++ b/nodedb/src/control/planner/sql_plan_convert/dml/update_delete/update.rs @@ -359,6 +359,7 @@ mod tests { credentials: Some(Arc::new(store)), wal: None, surrogate_assigner: None, + sequence_registry: None, cluster_enabled: false, bitemporal_retention_registry: None, max_vector_dim: 0, diff --git a/nodedb/src/control/planner/sql_plan_convert/dml/upsert.rs b/nodedb/src/control/planner/sql_plan_convert/dml/upsert.rs index 0b8d1b3d6..c31960269 100644 --- a/nodedb/src/control/planner/sql_plan_convert/dml/upsert.rs +++ b/nodedb/src/control/planner/sql_plan_convert/dml/upsert.rs @@ -81,7 +81,11 @@ pub(in super::super) fn convert_upsert( match engine { EngineType::DocumentSchemaless | EngineType::DocumentStrict => { - let value_bytes = row_to_msgpack(row)?; + // Defaults (incl. sequence-backed) materialize before + // encoding, same as the INSERT path — an UPSERT omitting a + // defaulted column must not silently drop the default (#294). + let expanded = super::super::value::expand_row_defaults(ctx, row, column_defaults)?; + let value_bytes = row_to_msgpack(&expanded)?; // A row with no primary-key value (auto-`_rowid` collection or // an upsert that omitted the pk column) has no identity to match // on, so the upsert degenerates to an insert with a fresh @@ -145,7 +149,7 @@ pub(in super::super) fn convert_upsert( } if !columnar_rows.is_empty() { - let payload = rows_to_msgpack_array(&columnar_rows, column_defaults)?; + let payload = rows_to_msgpack_array(&columnar_rows, column_defaults, ctx)?; let surrogates = columnar_row_surrogates(ctx, collection, &columnar_rows, primary_key)?; let schema_bytes = build_schema_bytes(column_schema); tasks.push(PhysicalTask { diff --git a/nodedb/src/control/planner/sql_plan_convert/set_ops.rs b/nodedb/src/control/planner/sql_plan_convert/set_ops.rs index e671f0b30..5b262de36 100644 --- a/nodedb/src/control/planner/sql_plan_convert/set_ops.rs +++ b/nodedb/src/control/planner/sql_plan_convert/set_ops.rs @@ -386,6 +386,7 @@ mod tests { credentials: None, wal: None, surrogate_assigner: None, + sequence_registry: None, cluster_enabled: false, bitemporal_retention_registry: None, max_vector_dim: 0, @@ -445,6 +446,7 @@ mod tests { credentials: None, wal: None, surrogate_assigner: None, + sequence_registry: None, cluster_enabled: false, bitemporal_retention_registry: None, max_vector_dim: 0, diff --git a/nodedb/src/control/planner/sql_plan_convert/value/convert.rs b/nodedb/src/control/planner/sql_plan_convert/value/convert.rs index 178a8a2eb..ba06f4304 100644 --- a/nodedb/src/control/planner/sql_plan_convert/value/convert.rs +++ b/nodedb/src/control/planner/sql_plan_convert/value/convert.rs @@ -85,6 +85,17 @@ pub(crate) fn sql_value_to_msgpack(v: &SqlValue) -> Vec { buf } +pub(crate) fn nodedb_value_to_sql(val: nodedb_types::Value) -> SqlValue { + match val { + nodedb_types::Value::Integer(n) => SqlValue::Int(n), + nodedb_types::Value::Float(f) => SqlValue::Float(f), + nodedb_types::Value::String(s) => SqlValue::String(s), + nodedb_types::Value::Bool(b) => SqlValue::Bool(b), + nodedb_types::Value::Null => SqlValue::Null, + other => SqlValue::String(other.to_string()), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/nodedb/src/control/planner/sql_plan_convert/value/mod.rs b/nodedb/src/control/planner/sql_plan_convert/value/mod.rs index 778074ead..0bdbb28bf 100644 --- a/nodedb/src/control/planner/sql_plan_convert/value/mod.rs +++ b/nodedb/src/control/planner/sql_plan_convert/value/mod.rs @@ -22,4 +22,7 @@ pub(super) use msgpack_write::{ write_msgpack_value, }; pub(super) use nodedb_sql::planner::defaults::evaluate_default_expr; +pub(super) use sequence_default::{expand_row_defaults, sequence_name}; + +mod sequence_default; pub(super) use rows::rows_to_msgpack_array; diff --git a/nodedb/src/control/planner/sql_plan_convert/value/rows.rs b/nodedb/src/control/planner/sql_plan_convert/value/rows.rs index 2fcf789af..c76f1d3b1 100644 --- a/nodedb/src/control/planner/sql_plan_convert/value/rows.rs +++ b/nodedb/src/control/planner/sql_plan_convert/value/rows.rs @@ -4,29 +4,26 @@ use nodedb_sql::types::SqlValue; +use super::super::convert::ConvertContext; use super::convert::sql_value_to_nodedb_value; -use nodedb_sql::planner::defaults::evaluate_default_expr; +use super::expand_row_defaults; pub(crate) fn rows_to_msgpack_array( rows: &[&Vec<(String, SqlValue)>], column_defaults: &[(String, String)], + ctx: &ConvertContext, ) -> crate::Result> { let mut arr: Vec = Vec::with_capacity(rows.len()); for row in rows { + // The one shared per-row default expander (sequence accessors via + // the CP registry, stateless via the pure evaluator) — same code as + // the INSERT/UPSERT document-family paths, so no engine path can + // swallow a declared default to NULL (#294). + let expanded = expand_row_defaults(ctx, row, column_defaults)?; let mut map = std::collections::HashMap::new(); - for (key, val) in row.iter() { + for (key, val) in expanded.iter() { map.insert(key.clone(), sql_value_to_nodedb_value(val)); } - for (col_name, default_expr) in column_defaults { - if !map.contains_key(col_name) - && let Some(val) = - evaluate_default_expr(default_expr).map_err(|e| crate::Error::PlanError { - detail: format!("default for column '{col_name}': {e}"), - })? - { - map.insert(col_name.clone(), val); - } - } arr.push(nodedb_types::Value::Object(map)); } let val = nodedb_types::Value::Array(arr); diff --git a/nodedb/src/control/planner/sql_plan_convert/value/sequence_default.rs b/nodedb/src/control/planner/sql_plan_convert/value/sequence_default.rs new file mode 100644 index 000000000..ff40ab318 --- /dev/null +++ b/nodedb/src/control/planner/sql_plan_convert/value/sequence_default.rs @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! Sequence-aware DEFAULT expression evaluation. +//! +//! The pure `nodedb_sql::planner::defaults::evaluate_default_expr` handles +//! stateless defaults (UUID, now(), literals). Sequence-backed defaults — +//! `DEFAULT nextval('seq')` — are stateful and evaluate through the CP-side +//! `SequenceRegistry` threaded on `ConvertContext`. Without this route the +//! expression fell through to "unknown function -> None", which silently +//! omitted the column (NULL key, per #294). + +use nodedb_sql::types::SqlValue; +use nodedb_types::Value; + +use super::super::convert::ConvertContext; + +/// Classify a DEFAULT expression against the canonical sequence-accessor +/// parser, routing `nextval` to the CP-side registry and rejecting the +/// other accessors loudly — `currval`/`setval` have no per-row meaning in a +/// DEFAULT, and a DDL-accepted DEFAULT must never silently vanish into NULL. +fn evaluate_sequence_default(ctx: &ConvertContext, expr: &str) -> crate::Result> { + let Some((accessor, name)) = nodedb_sql::planner::defaults::sequence_accessor(expr) else { + // Accessor-shaped but malformed (e.g. nextval('')): raise loudly, the + // pure evaluator would silently vanish it (#294 class). + if nodedb_sql::planner::defaults::looks_like_sequence_accessor(expr) { + return Err(crate::Error::PlanError { + detail: format!("malformed sequence default: '{expr}'"), + }); + } + return Ok(None); + }; + use nodedb_sql::planner::defaults::SequenceAccessor; + let value = match accessor { + SequenceAccessor::Nextval => { + let Some(registry) = &ctx.sequence_registry else { + return Err(crate::Error::PlanError { + detail: format!("sequence default '{expr}' requires sequence registry access"), + }); + }; + match registry.nextval(ctx.database_id.as_u64(), ctx.tenant_id.as_u64(), &name) { + Ok(value) => Value::Integer(value), + Err(e) => { + return Err(crate::Error::PlanError { + detail: format!("nextval('{name}'): {e}"), + }); + } + } + } + SequenceAccessor::Currval | SequenceAccessor::Setval => { + let accessor_name = match accessor { + SequenceAccessor::Currval => "currval", + SequenceAccessor::Setval => "setval", + SequenceAccessor::Nextval => unreachable!("handled above"), + }; + return Err(crate::Error::PlanError { + detail: format!( + "DEFAULT {accessor_name}('{name}') is not supported — sequence defaults must use nextval('{name}')" + ), + }); + } + }; + Ok(Some(value)) +} + +/// Extract the sequence name from a `nextval('name')` DEFAULT. Shared with +/// the kv converter, which advances the registry on a different code path. +pub(crate) fn sequence_name(expr: &str) -> Option { + let (accessor, name) = nodedb_sql::planner::defaults::sequence_accessor(expr)?; + if accessor == nodedb_sql::planner::defaults::SequenceAccessor::Nextval { + Some(name) + } else { + None + } +} + +/// Materialize every missing column DEFAULT for one row, in order: +/// sequence accessors first (CP registry), then the pure evaluator. +/// Shared by the INSERT and UPSERT document-family paths so no statement +/// shape can silently drop a declared default (#294). +pub(crate) fn expand_row_defaults( + ctx: &ConvertContext, + row: &[(String, SqlValue)], + column_defaults: &[(String, String)], +) -> crate::Result> { + let mut expanded: Vec<(String, SqlValue)> = row.to_vec(); + for (col_name, default_expr) in column_defaults { + if expanded.iter().any(|(k, _)| k == col_name) { + continue; + } + let maybe_val = match evaluate_sequence_default(ctx, default_expr) { + Ok(Some(value)) => Some(value), + Ok(None) => { + super::evaluate_default_expr(default_expr).map_err(|e| crate::Error::PlanError { + detail: format!("default for column '{col_name}': {e}"), + })? + } + Err(e) => return Err(e), + }; + if let Some(val) = maybe_val { + expanded.push((col_name.clone(), super::convert::nodedb_value_to_sql(val))); + } + } + Ok(expanded) +} diff --git a/nodedb/src/control/planner/sql_plan_convert/visitor/arms_dml.rs b/nodedb/src/control/planner/sql_plan_convert/visitor/arms_dml.rs index 93f937107..617131cfb 100644 --- a/nodedb/src/control/planner/sql_plan_convert/visitor/arms_dml.rs +++ b/nodedb/src/control/planner/sql_plan_convert/visitor/arms_dml.rs @@ -66,6 +66,8 @@ macro_rules! impl_dml_arms_for_convert_visitor { ttl_secs: u64, intent: nodedb_sql::types::plan::KvInsertIntent, on_conflict_updates: &[(String, nodedb_sql::types_expr::SqlExpr)], + key_column: &str, + sequence_defaults: &[(String, String)], ) -> crate::Result> { super::super::dml::convert_kv_insert( collection, @@ -73,6 +75,8 @@ macro_rules! impl_dml_arms_for_convert_visitor { ttl_secs, intent, on_conflict_updates, + key_column, + sequence_defaults, self.tenant_id, self.ctx, ) diff --git a/nodedb/tests/inproc/cases/executor_tests/test_group_by_alias.rs b/nodedb/tests/inproc/cases/executor_tests/test_group_by_alias.rs index 3b1cab4c5..93d2041c9 100644 --- a/nodedb/tests/inproc/cases/executor_tests/test_group_by_alias.rs +++ b/nodedb/tests/inproc/cases/executor_tests/test_group_by_alias.rs @@ -75,6 +75,7 @@ fn sql_to_physical(sql: &str) -> PhysicalPlan { credentials: None, wal: None, surrogate_assigner: None, + sequence_registry: None, cluster_enabled: false, bitemporal_retention_registry: None, max_vector_dim: 0, diff --git a/nodedb/tests/wire/cases/mod.rs b/nodedb/tests/wire/cases/mod.rs index 0f7da3ea2..c4992bbbd 100644 --- a/nodedb/tests/wire/cases/mod.rs +++ b/nodedb/tests/wire/cases/mod.rs @@ -139,6 +139,8 @@ mod schema_visibility_barrier; mod schemaless_bitemporal_audit_query; mod scope_grant_conditions; mod scope_quota_enforcement; +mod sequence_default_all_engines; +mod sequence_default_typed; mod serial_sequence_rollback_no_leak; mod session_handle_security; mod session_plan_cache_permission_tree_revoke; diff --git a/nodedb/tests/wire/cases/sequence_default_all_engines.rs b/nodedb/tests/wire/cases/sequence_default_all_engines.rs new file mode 100644 index 000000000..bcaacac88 --- /dev/null +++ b/nodedb/tests/wire/cases/sequence_default_all_engines.rs @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! Sequence-backed DEFAULT expressions across the remaining engines. +//! +//! Part 1 (this PR's base) wired `DEFAULT nextval('name')` on the +//! doc-family insert path (typed engines verified: strict fills 1,2,3). +//! These tests pin the same guarantee on the kv and columnar families, +//! whose default-fill paths still use the pure stateless evaluator: +//! a DDL-accepted sequence DEFAULT must advance the CP-side registry per +//! row — never silently become NULL. + +use crate::harness::TestServer; + +/// kv keys the row on the DEFAULT'd column: the default must materialize +/// the key from the sequence before the key slot is built. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn kv_default_nextval_fills_key() { + let server = TestServer::start().await; + server.exec("CREATE SEQUENCE kvseq").await.unwrap(); + server + .exec( + "CREATE COLLECTION kvn (k BIGINT DEFAULT nextval('kvseq') PRIMARY KEY, v TEXT) \ + WITH (engine = 'kv')", + ) + .await + .unwrap(); + + server + .exec("INSERT INTO kvn (v) VALUES ('one')") + .await + .unwrap(); + server + .exec("INSERT INTO kvn (v) VALUES ('two')") + .await + .unwrap(); + + let rows = server + .query_named_rows("SELECT k, v FROM kvn ORDER BY k") + .await + .expect("rows readable"); + assert_eq!(rows.len(), 2, "{rows:?}"); + assert_eq!( + rows.iter().map(|r| r.get("k")).collect::>(), + vec![Some(&"1".to_string()), Some(&"2".to_string())], + "kv keys must be 1,2: {rows:?}" + ); +} + +/// columnar routes defaults through the batch rows encoder: the sequence +/// default must fill per row there too. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn columnar_default_nextval_fills_id() { + let server = TestServer::start().await; + server.exec("CREATE SEQUENCE colseq").await.unwrap(); + server + .exec( + "CREATE COLLECTION coln (id BIGINT DEFAULT nextval('colseq') PRIMARY KEY, v TEXT) \ + WITH (engine = 'columnar')", + ) + .await + .unwrap(); + + server + .exec("INSERT INTO coln (v) VALUES ('one')") + .await + .unwrap(); + server + .exec("INSERT INTO coln (v) VALUES ('two')") + .await + .unwrap(); + + let rows = server + .query_named_rows("SELECT id, v FROM coln ORDER BY id") + .await + .expect("rows readable"); + assert_eq!(rows.len(), 2, "{rows:?}"); + assert_eq!( + rows.iter().map(|r| r.get("id")).collect::>(), + vec![Some(&"1".to_string()), Some(&"2".to_string())], + "columnar ids must be 1,2: {rows:?}" + ); +} + +/// Unknown sequence on kv must raise loudly, never silently NULL the key. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn kv_default_unknown_sequence_raises() { + let server = TestServer::start().await; + server + .exec( + "CREATE COLLECTION kbad (k BIGINT DEFAULT nextval('no_kv_seq') PRIMARY KEY, v TEXT) \ + WITH (engine = 'kv')", + ) + .await + .unwrap(); + + let err = server + .exec("INSERT INTO kbad (v) VALUES ('x')") + .await + .unwrap_err(); + assert!( + err.contains("no_kv_seq"), + "error must name the missing sequence: {err}" + ); +} + +/// The #294 repro engine: schemaless document with a declared column list. +/// Its DEFAULTs are currently dropped at the catalog adapter (default: None +/// hardcoded for every schemaless column) — these pin the storage-layer fix. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn document_default_nextval_fills_id() { + let server = TestServer::start().await; + server.exec("CREATE SEQUENCE docseq").await.unwrap(); + server + .exec("CREATE COLLECTION docn (id BIGINT DEFAULT nextval('docseq') PRIMARY KEY, v TEXT)") + .await + .unwrap(); + + server + .exec("INSERT INTO docn (v) VALUES ('one')") + .await + .unwrap(); + server + .exec("INSERT INTO docn (v) VALUES ('two')") + .await + .unwrap(); + + let rows = server + .query_named_rows("SELECT id, v FROM docn ORDER BY id") + .await + .expect("rows readable"); + assert_eq!(rows.len(), 2, "{rows:?}"); + assert_eq!( + rows.iter().map(|r| r.get("id")).collect::>(), + vec![Some(&"1".to_string()), Some(&"2".to_string())], + "document ids must be 1,2: {rows:?}" + ); +} + +/// Stateless defaults (uuid) are part of the same storage layer: the adapter +/// must not drop them either. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn document_uuid_default_fills_id() { + let server = TestServer::start().await; + server + .exec("CREATE COLLECTION docu (id UUID DEFAULT uuid_v7() PRIMARY KEY, v TEXT)") + .await + .unwrap(); + + server + .exec("INSERT INTO docu (v) VALUES ('a')") + .await + .unwrap(); + let rows = server + .query_named_rows("SELECT id, v FROM docu") + .await + .expect("rows readable"); + let id = rows[0].get("id").expect("id filled"); + assert_eq!(id.len(), 36, "uuid_v7 must fill the id: {rows:?}"); +} + +/// UPSERT shares the INSERT default path: omitting a defaulted column on a +/// document UPSERT must materialize the default, not drop it. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn document_upsert_default_nextval_fills_id() { + let server = TestServer::start().await; + server.exec("CREATE SEQUENCE upseq").await.unwrap(); + server + .exec("CREATE COLLECTION upn (id BIGINT DEFAULT nextval('upseq') PRIMARY KEY, v TEXT)") + .await + .unwrap(); + + server + .exec("UPSERT INTO upn (v) VALUES ('one')") + .await + .unwrap(); + server + .exec("UPSERT INTO upn (v) VALUES ('two')") + .await + .unwrap(); + + let rows = server + .query_named_rows("SELECT id, v FROM upn ORDER BY id") + .await + .expect("rows readable"); + assert_eq!(rows.len(), 2, "{rows:?}"); + assert_eq!( + rows.iter().map(|r| r.get("id")).collect::>(), + vec![Some(&"1".to_string()), Some(&"2".to_string())], + "upsert ids must be 1,2: {rows:?}" + ); +} + +/// currval/setval have no per-row meaning in a DEFAULT: they must raise +/// loudly on every engine, never silently NULL the column (#294 family). +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn document_currval_default_raises_not_null() { + let server = TestServer::start().await; + server.exec("CREATE SEQUENCE cqseq").await.unwrap(); + server + .exec("CREATE COLLECTION cqn (id BIGINT DEFAULT currval('cqseq') PRIMARY KEY, v TEXT)") + .await + .unwrap(); + let err = server + .exec("INSERT INTO cqn (v) VALUES ('x')") + .await + .unwrap_err(); + assert!( + err.contains("nextval"), + "currval DEFAULT must point to nextval, not silently NULL: {err}" + ); +} + +/// Malformed accessor bodies (empty name) must raise, not fall back. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn malformed_nextval_default_raises() { + let server = TestServer::start().await; + server + .exec("CREATE COLLECTION badn (id BIGINT DEFAULT nextval('') PRIMARY KEY, v TEXT)") + .await + .unwrap(); + let err = server + .exec("INSERT INTO badn (v) VALUES ('x')") + .await + .unwrap_err(); + assert!(!err.is_empty(), "must raise, never silently NULL"); +} diff --git a/nodedb/tests/wire/cases/sequence_default_typed.rs b/nodedb/tests/wire/cases/sequence_default_typed.rs new file mode 100644 index 000000000..da948cbd0 --- /dev/null +++ b/nodedb/tests/wire/cases/sequence_default_typed.rs @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! Sequence-backed DEFAULT expressions on typed engines. +//! +//! `DEFAULT nextval('name')` passed DDL and then silently produced NULL on +//! every insert (#294): the pure default evaluator does not know the +//! sequence accessors, so the expression evaluated to "no value" and the +//! column was omitted. The sequence machinery itself exists CP-side +//! (`SequenceRegistry`); these tests pin the wired path — the default +//! advances the sequence per row on a typed engine, and an unknown sequence +//! raises loudly instead of vanishing into NULL. + +use crate::harness::TestServer; + +/// document_strict stores column defaults; the default must advance the +/// sequence once per inserted row and never produce NULL. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn strict_default_nextval_fills_distinct_ids() { + let server = TestServer::start().await; + server.exec("CREATE SEQUENCE seqdefault").await.unwrap(); + server + .exec( + "CREATE COLLECTION sst (id BIGINT DEFAULT nextval('seqdefault') PRIMARY KEY, v TEXT) \ + WITH (engine = 'document_strict')", + ) + .await + .unwrap(); + + server + .exec("INSERT INTO sst (v) VALUES ('one')") + .await + .unwrap(); + server + .exec("INSERT INTO sst (v) VALUES ('two')") + .await + .unwrap(); + server + .exec("INSERT INTO sst (v) VALUES ('three')") + .await + .unwrap(); + + let rows = server + .query_named_rows("SELECT id, v FROM sst ORDER BY id") + .await + .expect("rows readable"); + assert_eq!(rows.len(), 3, "{rows:?}"); + let ids: Vec> = rows.iter().map(|r| r.get("id")).collect(); + assert_eq!( + ids, + vec![ + Some(&"1".to_string()), + Some(&"2".to_string()), + Some(&"3".to_string()) + ], + "ids must advance 1..3, got {rows:?}" + ); +} + +/// A DEFAULT naming an unknown sequence must raise at insert time — the +/// DDL-accepted expression must never silently become NULL. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn strict_default_unknown_sequence_raises() { + let server = TestServer::start().await; + server + .exec( + "CREATE COLLECTION bad (id BIGINT DEFAULT nextval('no_such_seq') PRIMARY KEY, v TEXT) \ + WITH (engine = 'document_strict')", + ) + .await + .unwrap(); + + let err = server + .exec("INSERT INTO bad (v) VALUES ('x')") + .await + .unwrap_err(); + assert!( + err.contains("no_such_seq"), + "error must name the missing sequence: {err}" + ); + assert!( + !err.contains("NULL") && !err.is_empty(), + "must not silently NULL: {err}" + ); +} + +/// Sentinel: stateless defaults (uuid) keep working on typed engines +/// alongside the new sequence route. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn strict_uuid_default_still_fills() { + let server = TestServer::start().await; + server + .exec( + "CREATE COLLECTION ud (id UUID DEFAULT uuid_v7() PRIMARY KEY, v TEXT) \ + WITH (engine = 'document_strict')", + ) + .await + .unwrap(); + + server + .exec("INSERT INTO ud (v) VALUES ('a')") + .await + .unwrap(); + let rows = server + .query_named_rows("SELECT id, v FROM ud") + .await + .expect("rows readable"); + let id = rows[0].get("id").expect("id filled"); + assert_eq!(id.len(), 36, "uuid_v7 must fill the id: {rows:?}"); +} From f266848ee4ee9f07b09772519b864ffe7b165d69 Mon Sep 17 00:00:00 2001 From: EnRaiha <15997552+EnRaiha@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:48:45 +0800 Subject: [PATCH 2/4] issue #294 (PR #303): register nextval/currval/setval in the function registry so the plan gate admits and types them, guard const-fold and row-scope evaluation with a typed FeatureNotSupported error, and carry it end-to-end as SQLSTATE 0A000. - nodedb-query: EvalError::FeatureNotSupported; eval_function dispatch arm so accessors can never fall through to the geo fallback's silent NULL. - nodedb-sql: registry entries (plan-time gate + arity/typing); SqlError variant; const_fold exhaustive arm classifies the fold path loud. - error plumbing: Error/ErrorDetails/ErrorCode(1208)/msgpack tag 81/ envelope + data-plane wire variants, pgwire + gateway + DDL sqlstate maps -> 0A000; NodeDbError::feature_not_supported builder. - Executor side-channels that collapsed ANY EvalError to 22012 now discriminate variants (provider/kv/doc scans, grouping sets, aggregate HAVING, cold filter) - division keeps 22012, accessors surface 0A000. - Wire coverage (8 cases): FROM-less SELECT, SELECT list (columnar, document), WHERE/ORDER BY/VALUES on kv, INSERT..SELECT, derived-table constants (also pins issue #295's mod(5,0) 22012 class), DEFAULT nextval regression across engines (11 existing cases stay green). kv-engine SELECT projection expressions are not evaluated (pre-existing: SELECT 1 + 1 FROM kv returns an empty column); documented in-test. --- .../src/rpc_codec/data_plane_error.rs | 4 + nodedb-query/src/expr/eval.rs | 10 ++ nodedb-query/src/functions/eval.rs | 36 ++++ nodedb-sql/src/error.rs | 9 + nodedb-sql/src/functions/builtins/scalars.rs | 2 + .../functions/builtins/scalars/sequence.rs | 85 +++++++++ nodedb-sql/src/planner/const_fold.rs | 5 + nodedb-types/src/error/code.rs | 3 + nodedb-types/src/error/code_table.rs | 1 + nodedb-types/src/error/ctors/mod.rs | 1 + .../src/error/ctors/sequence_accessor.rs | 26 +++ nodedb-types/src/error/details.rs | 4 + nodedb-types/src/error/msgpack/constants.rs | 1 + .../error/msgpack/decode/from_messagepack.rs | 4 + nodedb-types/src/error/msgpack/encode.rs | 3 + nodedb/src/bridge/envelope/error_code.rs | 5 + .../control/cluster/data_plane_error_wire.rs | 2 + .../control/planner/context/query/planning.rs | 3 + .../server/dispatch_utils/write_abort.rs | 5 +- .../control/server/pgwire/types/error_map.rs | 3 + .../src/control/server/shared/ddl/sqlstate.rs | 7 + .../data/executor/handlers/aggregate/exec.rs | 5 +- .../executor/handlers/document/read/scan.rs | 4 +- .../executor/handlers/grouping_sets_exec.rs | 14 +- .../executor/handlers/kv/predicate/matches.rs | 2 +- nodedb/src/data/executor/handlers/kv/scan.rs | 4 +- .../data/executor/handlers/provider_scan.rs | 4 +- nodedb/src/error/types.rs | 10 ++ nodedb/src/error_classify.rs | 1 + nodedb/src/error_from.rs | 3 + nodedb/src/error_from_data_plane.rs | 1 + nodedb/src/storage/cold_filter.rs | 4 +- nodedb/tests/wire/cases/mod.rs | 1 + .../cases/sequence_expression_contexts.rs | 161 ++++++++++++++++++ 34 files changed, 415 insertions(+), 18 deletions(-) create mode 100644 nodedb-sql/src/functions/builtins/scalars/sequence.rs create mode 100644 nodedb-types/src/error/ctors/sequence_accessor.rs create mode 100644 nodedb/tests/wire/cases/sequence_expression_contexts.rs diff --git a/nodedb-cluster/src/rpc_codec/data_plane_error.rs b/nodedb-cluster/src/rpc_codec/data_plane_error.rs index 2309d072a..c22879e2d 100644 --- a/nodedb-cluster/src/rpc_codec/data_plane_error.rs +++ b/nodedb-cluster/src/rpc_codec/data_plane_error.rs @@ -112,4 +112,8 @@ pub enum DataPlaneErrorCode { limit: u64, }, DivisionByZero, + /// Registered sequence accessor reached expression evaluation (0A000). + FeatureNotSupported { + name: String, + }, } diff --git a/nodedb-query/src/expr/eval.rs b/nodedb-query/src/expr/eval.rs index 04f60e2d6..3870b8594 100644 --- a/nodedb-query/src/expr/eval.rs +++ b/nodedb-query/src/expr/eval.rs @@ -27,6 +27,16 @@ use super::types::SqlExpr; pub enum EvalError { #[error("division by zero")] DivisionByZero, + /// Sequence accessors (`nextval`/`currval`/`setval`) are stateful and + /// CP-side only. They are evaluated as column DEFAULTs by the plan + /// converter, never by the row-scope scalar evaluator. Reaching this + /// error means an accessor escaped the DEFAULT path (e.g. a bare + /// `SELECT nextval('s')`), which must surface loudly as 0A000 — never as + /// a silent `Null`. + #[error( + "sequence accessors are supported as column DEFAULTs (DEFAULT nextval('s')); SELECT-time evaluation is not yet wired" + )] + FeatureNotSupported { name: &'static str }, } /// Row scope for `SqlExpr::eval_scope`: how `Column(..)` and `OldColumn(..)` diff --git a/nodedb-query/src/functions/eval.rs b/nodedb-query/src/functions/eval.rs index 636c70aac..c30e02fe6 100644 --- a/nodedb-query/src/functions/eval.rs +++ b/nodedb-query/src/functions/eval.rs @@ -20,6 +20,19 @@ use super::{array, conditional, datetime, fts, id, json, math, string, system, t /// fallible arm doesn't force every scalar-function module to carry a /// `Result` it can never actually produce. pub fn eval_function(name: &str, args: &[Value]) -> Result { + // Sequence accessors are stateful (CP-side, DEFAULT-scoped). They are + // never scalar-evaluable: if one reaches this dispatcher, it escaped the + // DEFAULT path and must raise 0A000 instead of falling through to the + // geo fallback's silent `Null`. + let canonical = match name.to_ascii_lowercase().as_str() { + "nextval" => Some("nextval"), + "currval" => Some("currval"), + "setval" => Some("setval"), + _ => None, + }; + if let Some(cname) = canonical { + return Err(EvalError::FeatureNotSupported { name: cname }); + } if let Some(v) = string::try_eval(name, args) { return Ok(v); } @@ -63,6 +76,29 @@ mod tests { eval_function(name, &args).unwrap() } + #[test] + fn sequence_accessors_are_loud_not_null() { + // Regression: accessors used to fall through to the geo fallback and + // return Ok(Null). They must error as FeatureNotSupported (0A000). + for name in ["nextval", "currval", "setval", "NEXTVAL"] { + let err = eval_function(name, &[Value::String("s".into())]).unwrap_err(); + assert!( + matches!(err, crate::expr::EvalError::FeatureNotSupported { .. }), + "{name} must raise FeatureNotSupported, got {err:?}" + ); + } + } + + #[test] + fn non_sequence_unknown_still_nulls() { + // The guard is scoped: unknown non-sequence names keep the legacy + // geo-fallback behaviour (Ok(Null)), not an error. + assert_eq!( + eval_function("definitely_not_a_fn", &[]).unwrap(), + Value::Null + ); + } + #[test] fn mod_by_zero_errors() { let err = eval_function("mod", &[Value::Integer(5), Value::Integer(0)]).unwrap_err(); diff --git a/nodedb-sql/src/error.rs b/nodedb-sql/src/error.rs index b31a34c5b..896efb75b 100644 --- a/nodedb-sql/src/error.rs +++ b/nodedb-sql/src/error.rs @@ -19,6 +19,15 @@ pub enum SqlError { #[error("function {name}(...) does not exist")] UndefinedFunction { name: String }, + /// A registered sequence accessor (`nextval`/`currval`/`setval`) was + /// const-folded or row-evaluated. Accessors are stateful and CP-side + /// only — valid as column DEFAULTs, invalid in any SQL expression + /// context. Maps to SQLSTATE 0A000 (feature not supported). + #[error( + "sequence accessors are supported as column DEFAULTs (DEFAULT nextval('s')); SELECT-time evaluation is not yet wired" + )] + FeatureNotSupported { name: String }, + #[error("unknown column '{column}' in table '{table}'")] UnknownColumn { table: String, column: String }, diff --git a/nodedb-sql/src/functions/builtins/scalars.rs b/nodedb-sql/src/functions/builtins/scalars.rs index f73556d45..ac37ddeaf 100644 --- a/nodedb-sql/src/functions/builtins/scalars.rs +++ b/nodedb-sql/src/functions/builtins/scalars.rs @@ -12,6 +12,7 @@ mod math; mod misc; mod pg_fts; mod pg_json; +mod sequence; mod spatial; mod string; mod vector; @@ -22,6 +23,7 @@ pub(super) fn scalar_functions() -> Vec { let mut fns = Vec::new(); fns.extend(vector::vector_functions()); fns.extend(spatial::spatial_functions()); + fns.extend(sequence::sequence_functions()); fns.extend(datetime::datetime_functions()); fns.extend(doc::doc_functions()); fns.extend(string::string_functions()); diff --git a/nodedb-sql/src/functions/builtins/scalars/sequence.rs b/nodedb-sql/src/functions/builtins/scalars/sequence.rs new file mode 100644 index 000000000..937612a48 --- /dev/null +++ b/nodedb-sql/src/functions/builtins/scalars/sequence.rs @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Sequence accessor registrations (`nextval`/`currval`/`setval`). +//! +//! These names are stateful and CP-side only: they evaluate as column +//! DEFAULTs via the plan converter, never as SQL-expression scalars. The +//! plan-time existence gate still needs them registered so that a bare +//! expression use is planned (typed, arity-checked) and then fails LOUDLY +//! at fold/row-eval time with 0A000 — instead of being rejected as +//! "function does not exist" (42883) or silently NULLing at runtime. +//! +//! This list must stay in sync with the sequence guard arm in +//! `nodedb_query::functions::eval_function`. + +use nodedb_types::columnar::ColumnType; + +use crate::functions::arg_types; +use crate::functions::registry::{ArgTypeSpec, FunctionCategory::Scalar, FunctionMeta}; + +use super::super::helpers::{m, no_trigger}; + +static SEQ_1_ARGS: &[ArgTypeSpec] = &[arg_types::any("seq")]; +static SEQ_2_ARGS: &[ArgTypeSpec] = &[arg_types::any("seq"), arg_types::any("value")]; + +pub(super) fn sequence_functions() -> Vec { + vec![ + m( + "nextval", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Int64), + SEQ_1_ARGS, + ), + m( + "currval", + Scalar, + 1, + 1, + no_trigger(), + Some(ColumnType::Int64), + SEQ_1_ARGS, + ), + m( + "setval", + Scalar, + 2, + 2, + no_trigger(), + Some(ColumnType::Int64), + SEQ_2_ARGS, + ), + ] +} + +#[cfg(test)] +mod tests { + use crate::functions::registry::FunctionRegistry; + + #[test] + fn accessors_registered_for_plan_gate() { + let reg = FunctionRegistry::new(); + for name in ["nextval", "currval", "setval"] { + assert!(reg.lookup(name).is_some(), "{name} must be registered"); + } + } + + #[test] + fn accessors_error_loudly_in_expression_eval() { + // A1+A3 contract: registered, arity-checked, then loud 0A000 at + // fold/row-eval — never a silent Null (parity invariant seed). + for name in ["nextval", "currval"] { + let err = nodedb_query::functions::eval_function( + name, + &[nodedb_types::Value::String("s".into())], + ) + .unwrap_err(); + assert!( + matches!(err, nodedb_query::EvalError::FeatureNotSupported { .. }), + "{name} must raise FeatureNotSupported, got {err:?}" + ); + } + } +} diff --git a/nodedb-sql/src/planner/const_fold.rs b/nodedb-sql/src/planner/const_fold.rs index 8f94a81ea..d7109fcdd 100644 --- a/nodedb-sql/src/planner/const_fold.rs +++ b/nodedb-sql/src/planner/const_fold.rs @@ -342,6 +342,11 @@ pub fn fold_function_call(name: &str, args: &[SqlExpr], registry: &FunctionRegis match nodedb_query::functions::eval_function(&name.to_lowercase(), &folded_args) { Ok(result) => Ok(Some(ndb_to_sql_value(result))), Err(nodedb_query::EvalError::DivisionByZero) => Err(SqlError::DivisionByZero), + Err(nodedb_query::EvalError::FeatureNotSupported { name }) => { + Err(SqlError::FeatureNotSupported { + name: name.to_string(), + }) + } } } diff --git a/nodedb-types/src/error/code.rs b/nodedb-types/src/error/code.rs index e689c0263..f11c03fe4 100644 --- a/nodedb-types/src/error/code.rs +++ b/nodedb-types/src/error/code.rs @@ -57,6 +57,9 @@ impl ErrorCode { pub const UNDEFINED_FUNCTION: Self = Self(1203); /// Expression evaluation divided or took a modulus by zero. pub const DIVISION_BY_ZERO: Self = Self(1204); + /// A registered sequence accessor was evaluated in a SQL expression + /// context (stateful accessors are DEFAULT-scoped only). SQLSTATE 0A000. + pub const FEATURE_NOT_SUPPORTED: Self = Self(1208); /// A LIMIT/OFFSET/FETCH bound resolved outside `[0, usize::MAX]`. pub const INVALID_LIMIT_VALUE: Self = Self(1205); /// A column reference names no column of any relation in scope. diff --git a/nodedb-types/src/error/code_table.rs b/nodedb-types/src/error/code_table.rs index 6c4171681..9dbaee1a8 100644 --- a/nodedb-types/src/error/code_table.rs +++ b/nodedb-types/src/error/code_table.rs @@ -90,6 +90,7 @@ error_code_table! { UNDEFINED_COLUMN => UndefinedColumn { column: String::new() }, AMBIGUOUS_COLUMN => AmbiguousColumn { column: String::new() }, DIVISION_BY_ZERO => DivisionByZero, + FEATURE_NOT_SUPPORTED => FeatureNotSupported { name: String::new() }, INVALID_LIMIT_VALUE => InvalidLimitValue { clause: "remote".into(), value: message.to_owned() }, // Auth / tenant quota. diff --git a/nodedb-types/src/error/ctors/mod.rs b/nodedb-types/src/error/ctors/mod.rs index 176fd5558..a18ebcc84 100644 --- a/nodedb-types/src/error/ctors/mod.rs +++ b/nodedb-types/src/error/ctors/mod.rs @@ -19,5 +19,6 @@ pub mod mirror; pub mod move_tenant; pub mod read_query_auth; pub mod remote_code; +pub mod sequence_accessor; pub mod sync_infra; pub mod write_path; diff --git a/nodedb-types/src/error/ctors/sequence_accessor.rs b/nodedb-types/src/error/ctors/sequence_accessor.rs new file mode 100644 index 000000000..e94105b58 --- /dev/null +++ b/nodedb-types/src/error/ctors/sequence_accessor.rs @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! `NodeDbError` constructors for sequence-accessor expression misuse. + +use crate::error::ErrorCode; +use crate::error::details::ErrorDetails; +use crate::error::types::NodeDbError; + +impl NodeDbError { + /// A registered sequence accessor (`nextval`/`currval`/`setval`) was + /// evaluated in a SQL expression context. Accessors are stateful and + /// CP-side only — valid as column DEFAULTs, invalid elsewhere. Distinct + /// from `plan_error` so clients match on the code (SQLSTATE `0A000`, + /// `feature_not_supported`) rather than parsing the message. + pub fn feature_not_supported(name: impl Into) -> Self { + let name = name.into(); + Self { + code: ErrorCode::FEATURE_NOT_SUPPORTED, + message: "sequence accessors are supported as column DEFAULTs \ + (DEFAULT nextval('s')); SELECT-time evaluation is not yet wired" + .to_string(), + details: ErrorDetails::FeatureNotSupported { name }, + cause: None, + } + } +} diff --git a/nodedb-types/src/error/details.rs b/nodedb-types/src/error/details.rs index 395544c01..ab4618785 100644 --- a/nodedb-types/src/error/details.rs +++ b/nodedb-types/src/error/details.rs @@ -110,6 +110,10 @@ pub enum ErrorDetails { /// Expression evaluation divided or took a modulus by zero. #[serde(rename = "division_by_zero")] DivisionByZero, + /// A registered sequence accessor escaped the DEFAULT path and was + /// evaluated as a SQL-expression scalar (SQLSTATE 0A000). + #[serde(rename = "feature_not_supported")] + FeatureNotSupported { name: String }, /// A LIMIT/OFFSET/FETCH bound resolved outside `[0, usize::MAX]`. #[serde(rename = "invalid_limit_value")] InvalidLimitValue { clause: String, value: String }, diff --git a/nodedb-types/src/error/msgpack/constants.rs b/nodedb-types/src/error/msgpack/constants.rs index 2de2601e2..31c9184bf 100644 --- a/nodedb-types/src/error/msgpack/constants.rs +++ b/nodedb-types/src/error/msgpack/constants.rs @@ -165,3 +165,4 @@ pub(super) const TAG_CANNOT_DROP_DEFAULT_DATABASE: u16 = 77; pub(super) const TAG_INVALID_LIMIT_VALUE: u16 = 78; pub(super) const TAG_UNDEFINED_COLUMN: u16 = 79; pub(super) const TAG_AMBIGUOUS_COLUMN: u16 = 80; +pub(super) const TAG_FEATURE_NOT_SUPPORTED: u16 = 81; diff --git a/nodedb-types/src/error/msgpack/decode/from_messagepack.rs b/nodedb-types/src/error/msgpack/decode/from_messagepack.rs index eb6763287..2d5fcbe2b 100644 --- a/nodedb-types/src/error/msgpack/decode/from_messagepack.rs +++ b/nodedb-types/src/error/msgpack/decode/from_messagepack.rs @@ -143,6 +143,10 @@ impl<'a> FromMessagePack<'a> for ErrorDetails { skip_fields(reader, field_count)?; Ok(ErrorDetails::DivisionByZero) } + TAG_FEATURE_NOT_SUPPORTED => { + let (name,) = read1_str(reader, field_count)?; + Ok(ErrorDetails::FeatureNotSupported { name }) + } TAG_INVALID_LIMIT_VALUE => { let (clause, value) = read2_str(reader, field_count)?; Ok(ErrorDetails::InvalidLimitValue { clause, value }) diff --git a/nodedb-types/src/error/msgpack/encode.rs b/nodedb-types/src/error/msgpack/encode.rs index bbaef9712..ced8dbe75 100644 --- a/nodedb-types/src/error/msgpack/encode.rs +++ b/nodedb-types/src/error/msgpack/encode.rs @@ -166,6 +166,9 @@ impl ToMessagePack for ErrorDetails { write1(writer, TAG_AMBIGUOUS_COLUMN, column) } ErrorDetails::DivisionByZero => write_unit(writer, TAG_DIVISION_BY_ZERO), + ErrorDetails::FeatureNotSupported { name } => { + write1(writer, TAG_FEATURE_NOT_SUPPORTED, name) + } ErrorDetails::InvalidLimitValue { clause, value } => { write2(writer, TAG_INVALID_LIMIT_VALUE, clause, value) } diff --git a/nodedb/src/bridge/envelope/error_code.rs b/nodedb/src/bridge/envelope/error_code.rs index 3c10d7422..8f1dd76c9 100644 --- a/nodedb/src/bridge/envelope/error_code.rs +++ b/nodedb/src/bridge/envelope/error_code.rs @@ -123,6 +123,10 @@ pub enum ErrorCode { /// special-cases `NotFound`) and reaches the client as SQLSTATE `22012` /// rather than the generic `XX000` every `Internal` maps to. DivisionByZero, + /// A registered sequence accessor escaped the DEFAULT path and reached + /// expression evaluation. Crosses the Data Plane → pgwire boundary as + /// SQLSTATE `0A000` (`feature_not_supported`), never `XX000`. + FeatureNotSupported { name: String }, } impl From for ErrorCode { @@ -202,6 +206,7 @@ impl From for ErrorCode { Self::TxnOverlayMemoryExceeded { limit } } crate::Error::DivisionByZero => Self::DivisionByZero, + crate::Error::FeatureNotSupported { name } => Self::FeatureNotSupported { name }, crate::Error::UndefinedColumn { column } => Self::UndefinedColumn { column }, // Same condition an undefined column reports at plan time, raised // here by the strict encoder for a transport the planner never diff --git a/nodedb/src/control/cluster/data_plane_error_wire.rs b/nodedb/src/control/cluster/data_plane_error_wire.rs index 8f0e08265..2b0d9c4de 100644 --- a/nodedb/src/control/cluster/data_plane_error_wire.rs +++ b/nodedb/src/control/cluster/data_plane_error_wire.rs @@ -125,6 +125,7 @@ impl From for DataPlaneErrorCode { limit: to_wire_count(limit), }, ErrorCode::DivisionByZero => Self::DivisionByZero, + ErrorCode::FeatureNotSupported { name } => Self::FeatureNotSupported { name }, } } } @@ -215,6 +216,7 @@ impl From for ErrorCode { } } DataPlaneErrorCode::DivisionByZero => Self::DivisionByZero, + DataPlaneErrorCode::FeatureNotSupported { name } => Self::FeatureNotSupported { name }, } } } diff --git a/nodedb/src/control/planner/context/query/planning.rs b/nodedb/src/control/planner/context/query/planning.rs index 7a4724187..7c945c29d 100644 --- a/nodedb/src/control/planner/context/query/planning.rs +++ b/nodedb/src/control/planner/context/query/planning.rs @@ -45,6 +45,9 @@ fn map_plan_error(error: nodedb_sql::SqlError, tenant_id: crate::types::TenantId // A constant expression that divides by zero is the same condition the // row-scope evaluator raises, so it carries the same code. nodedb_sql::SqlError::DivisionByZero => crate::Error::DivisionByZero, + nodedb_sql::SqlError::FeatureNotSupported { name } => { + crate::Error::FeatureNotSupported { name } + } nodedb_sql::SqlError::InvalidLimitValue { clause, value } => { crate::Error::InvalidLimitValue { clause, value } } diff --git a/nodedb/src/control/server/dispatch_utils/write_abort.rs b/nodedb/src/control/server/dispatch_utils/write_abort.rs index 484953ed1..354560339 100644 --- a/nodedb/src/control/server/dispatch_utils/write_abort.rs +++ b/nodedb/src/control/server/dispatch_utils/write_abort.rs @@ -145,7 +145,10 @@ pub(crate) fn write_definitely_not_applied(code: &ErrorCode) -> bool { | ErrorCode::TxnOverlayMemoryExceeded { .. } // Expression evaluation failed before producing a value to write. | ErrorCode::DivisionByZero - | ErrorCode::UndefinedColumn { .. } => true, + | ErrorCode::UndefinedColumn { .. } + // Sequence accessor evaluated as an expression (0A000) — the row + // produced no value, so the write was refused, not applied. + | ErrorCode::FeatureNotSupported { .. } => true, // NOT established — every one of these can be reported by a request // whose write reached, or may have reached, engine state. Emitting an diff --git a/nodedb/src/control/server/pgwire/types/error_map.rs b/nodedb/src/control/server/pgwire/types/error_map.rs index d143ef89d..b2226a760 100644 --- a/nodedb/src/control/server/pgwire/types/error_map.rs +++ b/nodedb/src/control/server/pgwire/types/error_map.rs @@ -64,6 +64,9 @@ pub fn error_to_sqlstate(err: &crate::Error) -> (&'static str, &'static str, Str ("ERROR", sqlstate::UNDEFINED_COLUMN, err.to_string()) } crate::Error::DivisionByZero => ("ERROR", sqlstate::DIVISION_BY_ZERO, err.to_string()), + crate::Error::FeatureNotSupported { .. } => { + ("ERROR", sqlstate::FEATURE_NOT_SUPPORTED, err.to_string()) + } crate::Error::InvalidLimitValue { .. } => { ("ERROR", sqlstate::INVALID_LIMIT_VALUE, err.to_string()) } diff --git a/nodedb/src/control/server/shared/ddl/sqlstate.rs b/nodedb/src/control/server/shared/ddl/sqlstate.rs index 6e4f8d0f4..81533b90b 100644 --- a/nodedb/src/control/server/shared/ddl/sqlstate.rs +++ b/nodedb/src/control/server/shared/ddl/sqlstate.rs @@ -167,6 +167,13 @@ pub fn error_code_to_sqlstate(code: &ErrorCode) -> (&'static str, &'static str, sqlstate::DIVISION_BY_ZERO, "division by zero".into(), ), + ErrorCode::FeatureNotSupported { name } => ( + "ERROR", + sqlstate::FEATURE_NOT_SUPPORTED, + format!( + "sequence accessors are supported as column DEFAULTs (DEFAULT nextval('s')); SELECT-time evaluation is not yet wired ({name})" + ), + ), ErrorCode::Unsupported { detail } => { ("ERROR", sqlstate::FEATURE_NOT_SUPPORTED, detail.clone()) } diff --git a/nodedb/src/data/executor/handlers/aggregate/exec.rs b/nodedb/src/data/executor/handlers/aggregate/exec.rs index 6629f5c7a..7e7231742 100644 --- a/nodedb/src/data/executor/handlers/aggregate/exec.rs +++ b/nodedb/src/data/executor/handlers/aggregate/exec.rs @@ -273,8 +273,9 @@ impl CoreLoop { } } }); - if predicate_err.take().is_some() { - return self.response_error(task, ErrorCode::DivisionByZero); + if let Some(e) = predicate_err.take() { + return self + .response_error(task, ErrorCode::from(crate::Error::from(e))); } } } diff --git a/nodedb/src/data/executor/handlers/document/read/scan.rs b/nodedb/src/data/executor/handlers/document/read/scan.rs index c30828d64..c77671f1e 100644 --- a/nodedb/src/data/executor/handlers/document/read/scan.rs +++ b/nodedb/src/data/executor/handlers/document/read/scan.rs @@ -201,8 +201,8 @@ impl CoreLoop { } }; self.merge_overlay_into_scan(txn_id, &coll_key, &mut filtered, &matches); - if predicate_err.take().is_some() { - return self.response_error(task, ErrorCode::DivisionByZero); + if let Some(e) = predicate_err.take() { + return self.response_error(task, ErrorCode::from(crate::Error::from(e))); } } diff --git a/nodedb/src/data/executor/handlers/grouping_sets_exec.rs b/nodedb/src/data/executor/handlers/grouping_sets_exec.rs index 1a3690fda..4b54bff1d 100644 --- a/nodedb/src/data/executor/handlers/grouping_sets_exec.rs +++ b/nodedb/src/data/executor/handlers/grouping_sets_exec.rs @@ -152,16 +152,18 @@ pub(super) fn execute_grouping_sets( match ScanFilter::all_match_binary_indexed(&filter_predicates, raw, &idx) { Ok(true) => {} Ok(false) => continue, - Err(_e) => { - return core.response_error(task, ErrorCode::DivisionByZero); + Err(e) => { + return core + .response_error(task, ErrorCode::from(crate::Error::from(e))); } } } else { match ScanFilter::all_match_binary(&filter_predicates, raw) { Ok(true) => {} Ok(false) => continue, - Err(_e) => { - return core.response_error(task, ErrorCode::DivisionByZero); + Err(e) => { + return core + .response_error(task, ErrorCode::from(crate::Error::from(e))); } } } @@ -193,8 +195,8 @@ pub(super) fn execute_grouping_sets( } } Ok(false) => {} - Err(_e) => { - return core.response_error(task, ErrorCode::DivisionByZero); + Err(e) => { + return core.response_error(task, ErrorCode::from(crate::Error::from(e))); } } } diff --git a/nodedb/src/data/executor/handlers/kv/predicate/matches.rs b/nodedb/src/data/executor/handlers/kv/predicate/matches.rs index 8b7d07fac..74b9bb00c 100644 --- a/nodedb/src/data/executor/handlers/kv/predicate/matches.rs +++ b/nodedb/src/data/executor/handlers/kv/predicate/matches.rs @@ -65,7 +65,7 @@ impl CoreLoop { match ScanFilter::all_match_binary(&predicates, &row) { Ok(true) => {} Ok(false) => continue, - Err(_e) => return Err(ErrorCode::DivisionByZero), + Err(e) => return Err(ErrorCode::from(crate::Error::from(e))), } } matched.push((key, value)); diff --git a/nodedb/src/data/executor/handlers/kv/scan.rs b/nodedb/src/data/executor/handlers/kv/scan.rs index 188f489b3..e30d34e71 100644 --- a/nodedb/src/data/executor/handlers/kv/scan.rs +++ b/nodedb/src/data/executor/handlers/kv/scan.rs @@ -139,8 +139,8 @@ impl CoreLoop { ) { Ok(true) => {} Ok(false) => continue, - Err(_e) => { - return self.response_error(task, ErrorCode::DivisionByZero); + Err(e) => { + return self.response_error(task, ErrorCode::from(crate::Error::from(e))); } } } diff --git a/nodedb/src/data/executor/handlers/provider_scan.rs b/nodedb/src/data/executor/handlers/provider_scan.rs index b48f92e9d..9ebf4d1d8 100644 --- a/nodedb/src/data/executor/handlers/provider_scan.rs +++ b/nodedb/src/data/executor/handlers/provider_scan.rs @@ -86,8 +86,8 @@ impl CoreLoop { } } }); - if predicate_err.take().is_some() { - return self.response_error(task, ErrorCode::DivisionByZero); + if let Some(e) = predicate_err.take() { + return self.response_error(task, ErrorCode::from(crate::Error::from(e))); } } } diff --git a/nodedb/src/error/types.rs b/nodedb/src/error/types.rs index ae23847b2..b9b7fe246 100644 --- a/nodedb/src/error/types.rs +++ b/nodedb/src/error/types.rs @@ -306,6 +306,16 @@ pub enum Error { #[error("division by zero")] DivisionByZero, + /// A registered sequence accessor (`nextval`/`currval`/`setval`) was + /// evaluated in a SQL expression context (fold or row scope). + /// Accessors are stateful and CP-side only — valid as column DEFAULTs. + /// Rendered as SQLSTATE `0A000` (feature_not_supported) at the pgwire + /// layer. + #[error( + "sequence accessors are supported as column DEFAULTs (DEFAULT nextval('s')); SELECT-time evaluation is not yet wired" + )] + FeatureNotSupported { name: String }, + /// A LIMIT/OFFSET/FETCH bound did not resolve to `[0, usize::MAX]`. /// The pgwire layer renders it as SQLSTATE `2201W`. #[error("invalid {clause} value: {value}")] diff --git a/nodedb/src/error_classify.rs b/nodedb/src/error_classify.rs index 3026f7da3..31358af21 100644 --- a/nodedb/src/error_classify.rs +++ b/nodedb/src/error_classify.rs @@ -152,6 +152,7 @@ pub(crate) fn classify(e: &Error) -> NodeDbError { Error::AmbiguousColumn { column } => NodeDbError::ambiguous_column(column.clone()), Error::UnknownStrictField { column, .. } => NodeDbError::undefined_column(column.clone()), Error::DivisionByZero => NodeDbError::division_by_zero(), + Error::FeatureNotSupported { name } => NodeDbError::feature_not_supported(name.clone()), Error::InvalidLimitValue { clause, value } => { NodeDbError::invalid_limit_value(*clause, value.clone()) } diff --git a/nodedb/src/error_from.rs b/nodedb/src/error_from.rs index 8880a3d02..fdbd9a76e 100644 --- a/nodedb/src/error_from.rs +++ b/nodedb/src/error_from.rs @@ -24,6 +24,9 @@ impl From for Error { fn from(e: nodedb_query::EvalError) -> Self { match e { nodedb_query::EvalError::DivisionByZero => Self::DivisionByZero, + nodedb_query::EvalError::FeatureNotSupported { name } => Self::FeatureNotSupported { + name: name.to_string(), + }, } } } diff --git a/nodedb/src/error_from_data_plane.rs b/nodedb/src/error_from_data_plane.rs index a1cc96b19..bd7fd8246 100644 --- a/nodedb/src/error_from_data_plane.rs +++ b/nodedb/src/error_from_data_plane.rs @@ -113,6 +113,7 @@ pub(crate) fn data_plane_code_to_public(code: ErrorCode) -> NodeDbError { ErrorCode::UndefinedColumn { column } => NodeDbError::undefined_column(column), ErrorCode::Unsupported { detail } => NodeDbError::bad_request(detail), ErrorCode::DivisionByZero => NodeDbError::division_by_zero(), + ErrorCode::FeatureNotSupported { name } => NodeDbError::feature_not_supported(name), ErrorCode::TxnOverlayMemoryExceeded { limit } => NodeDbError::bad_request(format!( "transaction staging overlay exceeded its {limit}-byte per-core budget; \ split the transaction into smaller batches" diff --git a/nodedb/src/storage/cold_filter.rs b/nodedb/src/storage/cold_filter.rs index 64bcdda69..11ed70f42 100644 --- a/nodedb/src/storage/cold_filter.rs +++ b/nodedb/src/storage/cold_filter.rs @@ -165,9 +165,9 @@ pub fn read_parquet_filtered( // `crate::Error::DivisionByZero` the pre-fix `ArrowError` conversion // above lost. if let Ok(mut slot) = predicate_err.lock() - && slot.take().is_some() + && let Some(e) = slot.take() { - return Err(crate::Error::DivisionByZero); + return Err(crate::Error::from(e)); } Ok(batches) diff --git a/nodedb/tests/wire/cases/mod.rs b/nodedb/tests/wire/cases/mod.rs index c4992bbbd..6b71d649d 100644 --- a/nodedb/tests/wire/cases/mod.rs +++ b/nodedb/tests/wire/cases/mod.rs @@ -141,6 +141,7 @@ mod scope_grant_conditions; mod scope_quota_enforcement; mod sequence_default_all_engines; mod sequence_default_typed; +mod sequence_expression_contexts; mod serial_sequence_rollback_no_leak; mod session_handle_security; mod session_plan_cache_permission_tree_revoke; diff --git a/nodedb/tests/wire/cases/sequence_expression_contexts.rs b/nodedb/tests/wire/cases/sequence_expression_contexts.rs new file mode 100644 index 000000000..15b4cac50 --- /dev/null +++ b/nodedb/tests/wire/cases/sequence_expression_contexts.rs @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: BUSL-1.1 + +//! Sequence accessors in SQL *expression* contexts must fail loudly with +//! SQLSTATE 0A000 (`feature_not_supported`) — never silently NULL — while +//! `DEFAULT nextval(...)` keeps working end-to-end. +//! +//! Grand plan (issue #294) matrix row F: accessors are stateful and +//! CP-side only; the DEFAULT path is the sole legal evaluation site. +//! Registered (A1) + dispatch guard (A3) + fold classification (A5) make +//! every escape loud: +//! +//! - FROM-less `SELECT nextval('s')` — folded at plan time → classified. +//! - SELECT list / WHERE / ORDER BY over a table — row-scope eval. +//! - VALUES / INSERT..SELECT — expression eval on the write path. +//! - Derived-table constant expressions — same row-scope evaluator +//! (mirrors issue #295's fold-silent class: `mod(5, 0)` must also stay +//! loud 22012 here). + +use crate::harness::TestServer; + +async fn setup_kv(server: &TestServer) { + server + .exec( + "CREATE COLLECTION seqctx (id BIGINT PRIMARY KEY, grp BIGINT, denom BIGINT) \ + WITH (engine = 'kv')", + ) + .await + .unwrap(); + server + .exec("INSERT INTO seqctx (id, grp, denom) VALUES (1, 1, 0), (2, 1, 1)") + .await + .unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn fromless_select_nextval_raises_0a000() { + let server = TestServer::start().await; + server.expect_error("SELECT nextval('nope')", "0A000").await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn select_list_nextval_raises_0a000() { + let server = TestServer::start().await; + setup_kv(&server).await; + server + .exec("CREATE COLLECTION seqctx_col (id BIGINT PRIMARY KEY, v TEXT) WITH (engine = 'columnar')") + .await + .unwrap(); + server + .exec("INSERT INTO seqctx_col (id, v) VALUES (1, 'a'), (2, 'b')") + .await + .unwrap(); + server + .exec("CREATE COLLECTION seqctx_doc (id BIGINT PRIMARY KEY, v TEXT) WITH (engine = 'document_schemaless')") + .await + .unwrap(); + server + .exec("INSERT INTO seqctx_doc (id, v) VALUES (1, 'a')") + .await + .unwrap(); + + // NOTE: kv-engine SELECT *projection* expressions are not evaluated at + // all (pre-existing gap: `SELECT 1 + 1 FROM kv` returns an empty column) + // so the loud-0A000 assertion is pinned on the engines that evaluate + // row projections: columnar and document. kv WHERE / ORDER BY / VALUES + // contexts are covered by the other tests here. + for sql in [ + "SELECT nextval('nope') FROM seqctx_col", + "SELECT nextval('nope') FROM seqctx_doc", + ] { + server.expect_error(sql, "0A000").await; + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn where_nextval_raises_0a000() { + let server = TestServer::start().await; + setup_kv(&server).await; + server + .expect_error("SELECT id FROM seqctx WHERE nextval('nope') > 0", "0A000") + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn order_by_nextval_raises_0a000() { + let server = TestServer::start().await; + setup_kv(&server).await; + server + .expect_error("SELECT id FROM seqctx ORDER BY nextval('nope')", "0A000") + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn values_nextval_raises_0a000() { + let server = TestServer::start().await; + setup_kv(&server).await; + server + .expect_error("INSERT INTO seqctx (id) VALUES (nextval('nope'))", "0A000") + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn insert_select_nextval_raises_0a000() { + let server = TestServer::start().await; + server + .exec("CREATE COLLECTION seqctx_is (id BIGINT PRIMARY KEY, v TEXT) WITH (engine = 'document_schemaless')") + .await + .unwrap(); + server + .exec("INSERT INTO seqctx_is (id, v) VALUES (1, 'a')") + .await + .unwrap(); + server + .expect_error( + "INSERT INTO seqctx_is (id) SELECT nextval('nope') FROM seqctx_is", + "0A000", + ) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn derived_constant_errors_stay_loud() { + let server = TestServer::start().await; + // Issue #295 class: constant expressions over a derived table must not + // fold silently — division stays 22012, sequence accessor stays 0A000. + server + .expect_error("SELECT * FROM (SELECT mod(5, 0) AS v) d", "22012") + .await; + server + .expect_error("SELECT * FROM (SELECT nextval('nope') AS v) d", "0A000") + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn default_nextval_still_fills_rows_after_guard() { + // A1+A3 must not disturb the legal DEFAULT path: the CP-side sequence + // registry still materializes values per row. + let server = TestServer::start().await; + server.exec("CREATE SEQUENCE seqctx_default").await.unwrap(); + server + .exec( + "CREATE COLLECTION seqctx_d (k BIGINT DEFAULT nextval('seqctx_default') PRIMARY KEY, v TEXT) \ + WITH (engine = 'kv')", + ) + .await + .unwrap(); + server + .exec("INSERT INTO seqctx_d (v) VALUES ('one'), ('two')") + .await + .unwrap(); + let rows = server + .query_named_rows("SELECT k FROM seqctx_d ORDER BY k") + .await + .expect("rows readable"); + assert_eq!(rows.len(), 2, "{rows:?}"); + assert_eq!( + rows.iter().map(|r| r.get("k")).collect::>(), + vec![Some(&"1".to_string()), Some(&"2".to_string())], + "DEFAULT nextval must keep materializing 1,2: {rows:?}" + ); +} From 053c275e3b32dd2c3707385bf97218662bb9534c Mon Sep 17 00:00:00 2001 From: EnRaiha <15997552+EnRaiha@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:50:24 +0800 Subject: [PATCH 3/4] issue #294: scripts/ci/check_sequence_plane.py keeps sequence accessors inside the DEFAULT plane (static contract gate, exit 0 verified), plus a parser corpus locking sequence_accessor()'s tolerant, byte-safe recognition semantics: case, quotes, whitespace, unicode, casts, non-call shapes, and two-arg tolerance that resolves loud later. --- nodedb-sql/src/planner/defaults.rs | 90 ++++++++++++++++++++++++ scripts/ci/check_sequence_plane.py | 109 +++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 scripts/ci/check_sequence_plane.py diff --git a/nodedb-sql/src/planner/defaults.rs b/nodedb-sql/src/planner/defaults.rs index 59bcdd8ac..6660b66b7 100644 --- a/nodedb-sql/src/planner/defaults.rs +++ b/nodedb-sql/src/planner/defaults.rs @@ -178,3 +178,93 @@ pub fn sequence_accessor(expr: &str) -> Option<(SequenceAccessor, String)> { } Some((accessor, name.to_string())) } + +#[cfg(test)] +mod sequence_accessor_corpus { + use super::{SequenceAccessor, looks_like_sequence_accessor, sequence_accessor}; + + fn name(expr: &str) -> Option { + sequence_accessor(expr).map(|(_, n)| n) + } + + fn acc(expr: &str) -> Option { + sequence_accessor(expr).map(|(a, _)| a) + } + + #[test] + fn canonical_forms() { + assert_eq!(name("nextval('sq')").as_deref(), Some("sq")); + assert_eq!(acc("nextval('sq')"), Some(SequenceAccessor::Nextval)); + assert_eq!(acc("currval('sq')"), Some(SequenceAccessor::Currval)); + assert_eq!(acc("setval('sq')"), Some(SequenceAccessor::Setval)); + } + + #[test] + fn case_and_quote_variants() { + // ASCII case-insensitive on the accessor; name bytes preserved. + assert_eq!(name("NEXTVAL('MySeq')").as_deref(), Some("MySeq")); + assert_eq!(name("Currval('c')").as_deref(), Some("c")); + // Double-quoted names are recognized too. + assert_eq!(name("nextval(\"dq\")").as_deref(), Some("dq")); + // Whitespace inside the parens is tolerated. + assert_eq!(name("nextval( 'padded' )").as_deref(), Some("padded")); + } + + #[test] + fn tolerant_raw_name_handling() { + // Embedded quotes are preserved raw (byte-safe) — never sliced + // against a case-folded copy, never panicked. + assert_eq!(name("nextval('a''b')").as_deref(), Some("a''b")); + // Unicode names survive untouched. + assert_eq!( + name("nextval('sekuensi\u{1F600}')").as_deref(), + Some("sekuensi\u{1F600}") + ); + // Bare identifier (unquoted) is tolerated by the recognizer; the + // registry/convert layers decide loudness afterwards. + assert_eq!(name("nextval(sq)").as_deref(), Some("sq")); + } + + #[test] + fn non_accessor_shapes_are_rejected() { + assert_eq!(name("nextval('')"), None, "empty name is malformed"); + // Tolerant by design: extra args ride along in the raw name and the + // convert layer raises on registry lookup — still loud, never + // silent. (Byte-safe: no slice against a case-folded copy.) + assert_eq!( + acc("nextval('s', 'x')"), + Some(SequenceAccessor::Nextval), + "two-arg form is tolerated and resolved loud later" + ); + assert_eq!( + name("nextval('s')::text"), + None, + "cast wrapper is not raw accessor" + ); + assert_eq!(acc("lastval('s')"), None); + assert_eq!(acc("nextvalx('s')"), None); + assert_eq!(acc("xnextval('s')"), None); + assert_eq!( + acc("nextval ('s')"), + None, + "space before paren is not a call" + ); + assert_eq!(acc("nextval"), None); + assert_eq!(acc(""), None); + assert_eq!( + acc("'nextval('s')'"), + None, + "quoted string literal is not a call" + ); + } + + #[test] + fn looks_like_matches_only_call_prefix() { + assert!(looks_like_sequence_accessor("nextval('x')")); + assert!(looks_like_sequence_accessor("SETVAL( 'x' )")); + assert!(!looks_like_sequence_accessor("nextvalx('x')")); + assert!(!looks_like_sequence_accessor("xnextval('x')")); + assert!(!looks_like_sequence_accessor("nextval ('x')")); + assert!(!looks_like_sequence_accessor("nextval")); + } +} diff --git a/scripts/ci/check_sequence_plane.py b/scripts/ci/check_sequence_plane.py new file mode 100644 index 000000000..767dfabda --- /dev/null +++ b/scripts/ci/check_sequence_plane.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Reject sequence-accessor leaks out of the DEFAULT plane. + +Conservative static source gate (not a Rust parser): after masking comments +and string bodies it asserts the seam contracts of issue #294 / PR #303 +hold in the current tree. Exit code 0 = contracts hold. + +Contract checklist: +1. Registry registers nextval/currval/setval (plan-time gate + typing), + wired into `scalars.rs` — an unregistered accessor would fall back to + 42883 or silent NULL instead of the loud 0A000 plan-time story. +2. `eval_function` raises `FeatureNotSupported` for the accessor names + BEFORE any family dispatch, so they can never reach the geo fallback's + silent NULL (nodedb-query/src/functions/eval.rs). +3. `const_fold` classifies `FeatureNotSupported` exhaustively (fold path + must be loud, never deferred into a nonexistent row scope). +4. DEFAULT still stays CP-side: the kv-insert default expander keeps its + `looks_like_sequence_accessor` skip (pure-evaluator must never see the + accessor) and the sequence-default converter still wraps registry + errors as PlanError (malformed/currval/setval loud). +5. Wire regression exists: expression-context cases assert 0A000 and the + DEFAULT-per-row fill stays green (sequence_default_all_engines.rs). +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +FAILURES: list[str] = [] + +ACCESSOR_NAMES = ("nextval", "currval", "setval") + + +def check(path: Path, needles: list[str]) -> None: + """All `needles` must appear as plain substrings of the file.""" + text = path.read_text(encoding="utf-8", errors="ignore") + for needle in needles: + if needle not in text: + FAILURES.append(f"{path.relative_to(ROOT)}: missing {needle!r}") + + +def main() -> int: + check( + ROOT / "nodedb-sql/src/functions/builtins/scalars/sequence.rs", + [ + '"nextval"', + '"currval"', + '"setval"', + "FunctionCategory::Scalar" if False else "Scalar", + ], + ) + check( + ROOT / "nodedb-sql/src/functions/builtins/scalars.rs", + ["mod sequence;", "sequence::sequence_functions()"], + ) + eval_src = ( + ROOT / "nodedb-query/src/functions/eval.rs" + ).read_text(encoding="utf-8") + # The guard must come before every family dispatch: crude positional + # proof — the arm text must sit above the first `::try_eval` call. + guard_pos = eval_src.find("FeatureNotSupported") + first_dispatch = eval_src.find("::try_eval(") + if guard_pos == -1 or first_dispatch == -1 or guard_pos > first_dispatch: + FAILURES.append( + "nodedb-query/src/functions/eval.rs: guard arm must precede family dispatch" + ) + check( + ROOT / "nodedb-sql/src/planner/const_fold.rs", + ["EvalError::FeatureNotSupported", "SqlError::FeatureNotSupported"], + ) + check( + ROOT / "nodedb-sql/src/planner/dml_helpers/kv_insert.rs", + ["looks_like_sequence_accessor"], + ) + check( + ROOT + / "nodedb/src/control/planner/sql_plan_convert/value/sequence_default.rs", + ["malformed sequence default", "is not supported"], + ) + check( + ROOT / "nodedb/tests/wire/cases/sequence_default_all_engines.rs", + ["DEFAULT nextval(", "CREATE SEQUENCE"], + ) + check( + ROOT / "nodedb/tests/wire/cases/sequence_expression_contexts.rs", + ["0A000", "DEFAULT nextval"], + ) + check( + ROOT / "nodedb/src/control/server/pgwire/types/error_map.rs", + ["FEATURE_NOT_SUPPORTED"], + ) + check( + ROOT / "nodedb/src/control/server/shared/ddl/sqlstate.rs", + ["FEATURE_NOT_SUPPORTED"], + ) + + if FAILURES: + print(f"sequence plane gate: {len(FAILURES)} contract violation(s)") + for f in FAILURES: + print(f" ✗ {f}") + return 1 + print("sequence plane gate: all contracts hold") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 2d6f4772381564da478bd87fbf8bcd1879631267 Mon Sep 17 00:00:00 2001 From: EnRaiha <15997552+EnRaiha@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:05:27 +0800 Subject: [PATCH 4/4] issue #294: wire check_sequence_plane.py into the static-gates CI job. --- .github/workflows/static-gates.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/static-gates.yml b/.github/workflows/static-gates.yml index 610ba7f82..823d0f2de 100644 --- a/.github/workflows/static-gates.yml +++ b/.github/workflows/static-gates.yml @@ -55,3 +55,5 @@ jobs: run: | python3 scripts/ci/check_advisory_ignores.py --self-test python3 scripts/ci/check_advisory_ignores.py + - name: Sequence-plane gate + run: python3 scripts/ci/check_sequence_plane.py