Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/static-gates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions nodedb-cluster/src/rpc_codec/data_plane_error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,8 @@ pub enum DataPlaneErrorCode {
limit: u64,
},
DivisionByZero,
/// Registered sequence accessor reached expression evaluation (0A000).
FeatureNotSupported {
name: String,
},
}
10 changes: 10 additions & 0 deletions nodedb-query/src/expr/eval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(..)`
Expand Down
36 changes: 36 additions & 0 deletions nodedb-query/src/functions/eval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value, EvalError> {
// 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);
}
Expand Down Expand Up @@ -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();
Expand Down
9 changes: 9 additions & 0 deletions nodedb-sql/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },

Expand Down
2 changes: 2 additions & 0 deletions nodedb-sql/src/functions/builtins/scalars.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mod math;
mod misc;
mod pg_fts;
mod pg_json;
mod sequence;
mod spatial;
mod string;
mod vector;
Expand All @@ -22,6 +23,7 @@ pub(super) fn scalar_functions() -> Vec<FunctionMeta> {
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());
Expand Down
85 changes: 85 additions & 0 deletions nodedb-sql/src/functions/builtins/scalars/sequence.rs
Original file line number Diff line number Diff line change
@@ -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<FunctionMeta> {
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:?}"
);
}
}
}
5 changes: 5 additions & 0 deletions nodedb-sql/src/planner/const_fold.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
})
}
}
}

Expand Down
148 changes: 148 additions & 0 deletions nodedb-sql/src/planner/defaults.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,151 @@ 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()))
}

#[cfg(test)]
mod sequence_accessor_corpus {
use super::{SequenceAccessor, looks_like_sequence_accessor, sequence_accessor};

fn name(expr: &str) -> Option<String> {
sequence_accessor(expr).map(|(_, n)| n)
}

fn acc(expr: &str) -> Option<SequenceAccessor> {
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"));
}
}
Loading
Loading