Skip to content

Commit bc78809

Browse files
authored
Merge pull request #35 from NodeDB-Lab/fix/now-function-evaluation
fix(sql): evaluate now() / current_timestamp at plan time (#33)
2 parents d9d34f9 + d9473c2 commit bc78809

10 files changed

Lines changed: 361 additions & 33 deletions

File tree

.config/nextest.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ binary(/cluster/)
4545
| binary(descriptor_versioning_cross_node)
4646
| binary(prepared_cache_invalidation)
4747
| binary(sql_cluster_cross_node_dml)
48+
| binary(pgwire_gateway_migration)
4849
'''
4950
test-group = 'cluster'
5051
threads-required = 'num-test-threads'

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

nodedb-sql/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ description = "SQL parser, planner, and optimizer for NodeDB"
88

99
[dependencies]
1010
nodedb-types = { workspace = true }
11+
nodedb-query = { workspace = true }
1112
sqlparser = "0.61"
1213
thiserror = { workspace = true }
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
//! Plan-time constant folding for `SqlExpr`.
2+
//!
3+
//! Evaluates literal expressions and registered zero-or-few-arg scalar
4+
//! functions (e.g. `now()`, `current_timestamp`, `date_add(now(), '1h')`)
5+
//! at plan time via the shared `nodedb_query::functions::eval_function`
6+
//! evaluator.
7+
//!
8+
//! This keeps the bare-`SELECT` projection path, the `INSERT`/`UPSERT`
9+
//! `VALUES` path, and any future default-expression paths from drifting
10+
//! apart — they all reach the same evaluator that the Data Plane uses
11+
//! for column-reference evaluation.
12+
//!
13+
//! Semantics: Postgres / SQL-standard compatible. `now()` and
14+
//! `current_timestamp` snapshot once per statement — `CURRENT_TIMESTAMP`
15+
//! is defined to return the same value for every row of a single
16+
//! statement, and Postgres goes further (same value for the whole
17+
//! transaction). Folding at plan time satisfies both contracts and is
18+
//! cheaper than per-row runtime dispatch.
19+
20+
use std::sync::LazyLock;
21+
22+
use nodedb_types::Value;
23+
24+
use crate::functions::registry::{FunctionCategory, FunctionRegistry};
25+
use crate::types::{BinaryOp, SqlExpr, SqlValue, UnaryOp};
26+
27+
/// Process-wide default registry. Used by call sites that don't already
28+
/// thread a `FunctionRegistry` through (e.g. the DML `VALUES` path).
29+
static DEFAULT_REGISTRY: LazyLock<FunctionRegistry> = LazyLock::new(FunctionRegistry::new);
30+
31+
/// Access the shared default registry.
32+
pub fn default_registry() -> &'static FunctionRegistry {
33+
&DEFAULT_REGISTRY
34+
}
35+
36+
/// Convenience wrapper around [`fold_constant`] using the default registry.
37+
pub fn fold_constant_default(expr: &SqlExpr) -> Option<SqlValue> {
38+
fold_constant(expr, default_registry())
39+
}
40+
41+
/// Fold a `SqlExpr` to a literal `SqlValue` at plan time, or return
42+
/// `None` if the expression depends on row/runtime state (column refs,
43+
/// subqueries, unknown functions, etc.).
44+
pub fn fold_constant(expr: &SqlExpr, registry: &FunctionRegistry) -> Option<SqlValue> {
45+
match expr {
46+
SqlExpr::Literal(v) => Some(v.clone()),
47+
SqlExpr::UnaryOp {
48+
op: UnaryOp::Neg,
49+
expr,
50+
} => match fold_constant(expr, registry)? {
51+
SqlValue::Int(i) => Some(SqlValue::Int(-i)),
52+
SqlValue::Float(f) => Some(SqlValue::Float(-f)),
53+
_ => None,
54+
},
55+
SqlExpr::BinaryOp { left, op, right } => {
56+
let l = fold_constant(left, registry)?;
57+
let r = fold_constant(right, registry)?;
58+
fold_binary(l, *op, r)
59+
}
60+
SqlExpr::Function { name, args, .. } => fold_function_call(name, args, registry),
61+
_ => None,
62+
}
63+
}
64+
65+
fn fold_binary(l: SqlValue, op: BinaryOp, r: SqlValue) -> Option<SqlValue> {
66+
Some(match (l, op, r) {
67+
(SqlValue::Int(a), BinaryOp::Add, SqlValue::Int(b)) => SqlValue::Int(a + b),
68+
(SqlValue::Int(a), BinaryOp::Sub, SqlValue::Int(b)) => SqlValue::Int(a - b),
69+
(SqlValue::Int(a), BinaryOp::Mul, SqlValue::Int(b)) => SqlValue::Int(a * b),
70+
(SqlValue::Float(a), BinaryOp::Add, SqlValue::Float(b)) => SqlValue::Float(a + b),
71+
(SqlValue::Float(a), BinaryOp::Sub, SqlValue::Float(b)) => SqlValue::Float(a - b),
72+
(SqlValue::Float(a), BinaryOp::Mul, SqlValue::Float(b)) => SqlValue::Float(a * b),
73+
(SqlValue::String(a), BinaryOp::Concat, SqlValue::String(b)) => {
74+
SqlValue::String(format!("{a}{b}"))
75+
}
76+
_ => return None,
77+
})
78+
}
79+
80+
/// Fold a function call by recursively folding its arguments, dispatching
81+
/// through the shared scalar evaluator, and converting the result back to
82+
/// `SqlValue`. Only folds functions that are present in `registry`, so
83+
/// callers can distinguish "unknown function" from "known function, all
84+
/// args folded".
85+
pub fn fold_function_call(
86+
name: &str,
87+
args: &[SqlExpr],
88+
registry: &FunctionRegistry,
89+
) -> Option<SqlValue> {
90+
// Gate on registry so unknown-function paths keep their existing
91+
// fallbacks instead of collapsing to SqlValue::Null. Aggregates and
92+
// window functions aren't foldable — they need a row stream.
93+
let meta = registry.lookup(name)?;
94+
if matches!(
95+
meta.category,
96+
FunctionCategory::Aggregate | FunctionCategory::Window
97+
) {
98+
return None;
99+
}
100+
101+
let folded_args: Vec<Value> = args
102+
.iter()
103+
.map(|a| fold_constant(a, registry).map(sql_to_ndb_value))
104+
.collect::<Option<_>>()?;
105+
106+
let result = nodedb_query::functions::eval_function(name, &folded_args);
107+
Some(ndb_to_sql_value(result))
108+
}
109+
110+
fn sql_to_ndb_value(v: SqlValue) -> Value {
111+
match v {
112+
SqlValue::Null => Value::Null,
113+
SqlValue::Bool(b) => Value::Bool(b),
114+
SqlValue::Int(i) => Value::Integer(i),
115+
SqlValue::Float(f) => Value::Float(f),
116+
SqlValue::String(s) => Value::String(s),
117+
SqlValue::Bytes(b) => Value::Bytes(b),
118+
SqlValue::Array(a) => Value::Array(a.into_iter().map(sql_to_ndb_value).collect()),
119+
}
120+
}
121+
122+
fn ndb_to_sql_value(v: Value) -> SqlValue {
123+
match v {
124+
Value::Null => SqlValue::Null,
125+
Value::Bool(b) => SqlValue::Bool(b),
126+
Value::Integer(i) => SqlValue::Int(i),
127+
Value::Float(f) => SqlValue::Float(f),
128+
Value::String(s) => SqlValue::String(s),
129+
Value::Bytes(b) => SqlValue::Bytes(b),
130+
Value::Array(a) => SqlValue::Array(a.into_iter().map(ndb_to_sql_value).collect()),
131+
Value::DateTime(dt) => SqlValue::String(dt.to_iso8601()),
132+
Value::Uuid(s) | Value::Ulid(s) | Value::Regex(s) => SqlValue::String(s),
133+
Value::Duration(d) => SqlValue::String(d.to_human()),
134+
Value::Decimal(d) => SqlValue::String(d.to_string()),
135+
// Structured and opaque types collapse to Null — callers that
136+
// need these go through the runtime expression path, not folding.
137+
Value::Object(_)
138+
| Value::Geometry(_)
139+
| Value::Set(_)
140+
| Value::Range { .. }
141+
| Value::Record { .. } => SqlValue::Null,
142+
}
143+
}
144+
145+
#[cfg(test)]
146+
mod tests {
147+
use super::*;
148+
149+
#[test]
150+
fn fold_now_produces_non_epoch_string() {
151+
let registry = FunctionRegistry::new();
152+
let expr = SqlExpr::Function {
153+
name: "now".into(),
154+
args: vec![],
155+
distinct: false,
156+
};
157+
let val = fold_constant(&expr, &registry).expect("now() should fold");
158+
match val {
159+
SqlValue::String(s) => {
160+
assert!(!s.starts_with("1970"), "got {s}");
161+
assert!(s.contains('T'), "not ISO-8601: {s}");
162+
}
163+
other => panic!("expected string, got {other:?}"),
164+
}
165+
}
166+
167+
#[test]
168+
fn fold_current_timestamp() {
169+
let registry = FunctionRegistry::new();
170+
let expr = SqlExpr::Function {
171+
name: "current_timestamp".into(),
172+
args: vec![],
173+
distinct: false,
174+
};
175+
assert!(matches!(
176+
fold_constant(&expr, &registry),
177+
Some(SqlValue::String(_))
178+
));
179+
}
180+
181+
#[test]
182+
fn fold_unknown_function_returns_none() {
183+
let registry = FunctionRegistry::new();
184+
let expr = SqlExpr::Function {
185+
name: "definitely_not_a_real_function".into(),
186+
args: vec![],
187+
distinct: false,
188+
};
189+
assert!(fold_constant(&expr, &registry).is_none());
190+
}
191+
192+
#[test]
193+
fn fold_literal_arithmetic_still_works() {
194+
let registry = FunctionRegistry::new();
195+
let expr = SqlExpr::BinaryOp {
196+
left: Box::new(SqlExpr::Literal(SqlValue::Int(2))),
197+
op: BinaryOp::Add,
198+
right: Box::new(SqlExpr::Literal(SqlValue::Int(3))),
199+
};
200+
assert_eq!(fold_constant(&expr, &registry), Some(SqlValue::Int(5)));
201+
}
202+
203+
#[test]
204+
fn fold_column_ref_returns_none() {
205+
let registry = FunctionRegistry::new();
206+
let expr = SqlExpr::Column {
207+
table: None,
208+
name: "name".into(),
209+
};
210+
assert!(fold_constant(&expr, &registry).is_none());
211+
}
212+
}

nodedb-sql/src/planner/dml.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -471,8 +471,19 @@ fn expr_to_sql_value(expr: &ast::Expr) -> Result<SqlValue> {
471471
}
472472
}
473473
_ => {
474-
// Other functions like now() — store as string for runtime eval.
475-
Ok(SqlValue::String(format!("{expr}")))
474+
// Try folding via the shared scalar evaluator. Handles
475+
// `now()`, `current_timestamp`, `date_add(now(),'1h')`,
476+
// etc. — Postgres semantics: one snapshot per statement.
477+
// Unknown or non-foldable functions fall back to the
478+
// legacy string passthrough so existing behavior for
479+
// other callers is preserved.
480+
if let Ok(sql_expr) = crate::resolver::expr::convert_expr(expr)
481+
&& let Some(v) = super::const_fold::fold_constant_default(&sql_expr)
482+
{
483+
Ok(v)
484+
} else {
485+
Ok(SqlValue::String(format!("{expr}")))
486+
}
476487
}
477488
}
478489
}

nodedb-sql/src/planner/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub mod aggregate;
2+
pub mod const_fold;
23
pub mod cte;
34
pub mod dml;
45
pub mod join;

nodedb-sql/src/planner/select.rs

Lines changed: 7 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ fn plan_select(
105105
match proj {
106106
Projection::Computed { expr, alias } => {
107107
columns.push(alias.clone());
108-
values.push(eval_constant_expr(expr));
108+
values.push(eval_constant_expr(expr, functions));
109109
}
110110
Projection::Column(name) => {
111111
columns.push(name.clone());
@@ -797,36 +797,12 @@ pub(crate) fn extract_func_args(func: &ast::Function) -> Result<Vec<ast::Expr>>
797797
}
798798
}
799799

800-
/// Evaluate a constant SqlExpr to a SqlValue.
801-
fn eval_constant_expr(expr: &SqlExpr) -> SqlValue {
802-
match expr {
803-
SqlExpr::Literal(v) => v.clone(),
804-
SqlExpr::UnaryOp {
805-
op: UnaryOp::Neg,
806-
expr,
807-
} => match eval_constant_expr(expr) {
808-
SqlValue::Int(i) => SqlValue::Int(-i),
809-
SqlValue::Float(f) => SqlValue::Float(-f),
810-
other => other,
811-
},
812-
SqlExpr::BinaryOp { left, op, right } => {
813-
let l = eval_constant_expr(left);
814-
let r = eval_constant_expr(right);
815-
match (l, op, r) {
816-
(SqlValue::Int(a), BinaryOp::Add, SqlValue::Int(b)) => SqlValue::Int(a + b),
817-
(SqlValue::Int(a), BinaryOp::Sub, SqlValue::Int(b)) => SqlValue::Int(a - b),
818-
(SqlValue::Int(a), BinaryOp::Mul, SqlValue::Int(b)) => SqlValue::Int(a * b),
819-
(SqlValue::Float(a), BinaryOp::Add, SqlValue::Float(b)) => SqlValue::Float(a + b),
820-
(SqlValue::Float(a), BinaryOp::Sub, SqlValue::Float(b)) => SqlValue::Float(a - b),
821-
(SqlValue::Float(a), BinaryOp::Mul, SqlValue::Float(b)) => SqlValue::Float(a * b),
822-
(SqlValue::String(a), BinaryOp::Concat, SqlValue::String(b)) => {
823-
SqlValue::String(format!("{a}{b}"))
824-
}
825-
_ => SqlValue::Null,
826-
}
827-
}
828-
_ => SqlValue::Null,
829-
}
800+
/// Evaluate a constant SqlExpr to a SqlValue. Delegates to the shared
801+
/// `const_fold::fold_constant` helper so that zero-arg scalar functions
802+
/// like `now()` and `current_timestamp` go through the same evaluator
803+
/// as the runtime expression path.
804+
fn eval_constant_expr(expr: &SqlExpr, functions: &FunctionRegistry) -> SqlValue {
805+
super::const_fold::fold_constant(expr, functions).unwrap_or(SqlValue::Null)
830806
}
831807

832808
/// Extract a geometry argument: handles ST_Point(lon, lat), ST_GeomFromGeoJSON('...'),

nodedb/src/control/server/pgwire/ddl/sql_parse.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,82 @@ pub(super) fn parse_sql_value(val: &str) -> nodedb_types::Value {
6767
if let Ok(f) = trimmed.parse::<f64>() {
6868
return nodedb_types::Value::Float(f);
6969
}
70+
// Scalar function call like `now()` or `date_add(now(), '1h')`, or a
71+
// bare identifier like `current_timestamp` that SQL treats as a
72+
// zero-arg function. Route through the shared evaluator so the
73+
// UPSERT fast-path stays aligned with the SQL planner's VALUES path.
74+
// Unknown names fall through to the legacy string behavior.
75+
if let Some(v) = try_eval_scalar_function(trimmed) {
76+
return v;
77+
}
7078
nodedb_types::Value::String(trimmed.to_string())
7179
}
7280

81+
/// Evaluate a scalar function expression like `now()` or a bare SQL
82+
/// keyword like `current_timestamp` via the shared `nodedb_query`
83+
/// evaluator. Returns `None` if the input isn't a recognizable call
84+
/// form or the function is unknown.
85+
fn try_eval_scalar_function(s: &str) -> Option<nodedb_types::Value> {
86+
// Bare identifier: SQL treats `current_timestamp`, `current_date`,
87+
// etc. as zero-arg function references without parentheses.
88+
let is_bare_ident = s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
89+
&& !s.is_empty()
90+
&& !s.chars().next().is_some_and(|c| c.is_ascii_digit());
91+
92+
if is_bare_ident {
93+
let name = s.to_lowercase();
94+
// Only fold if the registry knows this name. Gate via nodedb-sql's
95+
// registry so we don't accidentally evaluate user identifiers.
96+
let registry = nodedb_sql::planner::const_fold::default_registry();
97+
if registry.lookup(&name).is_some() {
98+
let val = nodedb_query::functions::eval_function(&name, &[]);
99+
if !matches!(val, nodedb_types::Value::Null) {
100+
return Some(val);
101+
}
102+
}
103+
return None;
104+
}
105+
106+
// Call form `name(args...)`. Parse via sqlparser + fold via const_fold.
107+
if !s.ends_with(')') || !s.contains('(') {
108+
return None;
109+
}
110+
let stmt_sql = format!("SELECT {s}");
111+
let dialect = sqlparser::dialect::PostgreSqlDialect {};
112+
let stmts = sqlparser::parser::Parser::parse_sql(&dialect, &stmt_sql).ok()?;
113+
let stmt = stmts.into_iter().next()?;
114+
let sqlparser::ast::Statement::Query(query) = stmt else {
115+
return None;
116+
};
117+
let sqlparser::ast::SetExpr::Select(select) = *query.body else {
118+
return None;
119+
};
120+
let item = select.projection.into_iter().next()?;
121+
let ast_expr = match item {
122+
sqlparser::ast::SelectItem::UnnamedExpr(e)
123+
| sqlparser::ast::SelectItem::ExprWithAlias { expr: e, .. } => e,
124+
_ => return None,
125+
};
126+
let sql_expr = nodedb_sql::resolver::expr::convert_expr(&ast_expr).ok()?;
127+
let folded = nodedb_sql::planner::const_fold::fold_constant_default(&sql_expr)?;
128+
Some(sql_value_to_ndb_value(folded))
129+
}
130+
131+
fn sql_value_to_ndb_value(v: nodedb_sql::types::SqlValue) -> nodedb_types::Value {
132+
use nodedb_sql::types::SqlValue;
133+
match v {
134+
SqlValue::Null => nodedb_types::Value::Null,
135+
SqlValue::Bool(b) => nodedb_types::Value::Bool(b),
136+
SqlValue::Int(i) => nodedb_types::Value::Integer(i),
137+
SqlValue::Float(f) => nodedb_types::Value::Float(f),
138+
SqlValue::String(s) => nodedb_types::Value::String(s),
139+
SqlValue::Bytes(b) => nodedb_types::Value::Bytes(b),
140+
SqlValue::Array(a) => {
141+
nodedb_types::Value::Array(a.into_iter().map(sql_value_to_ndb_value).collect())
142+
}
143+
}
144+
}
145+
73146
/// Extract a clause value delimited by known keywords.
74147
///
75148
/// Given `upper = "TYPE INT DEFAULT 0 ASSERT $value > 0"`, `original` (same

0 commit comments

Comments
 (0)