Skip to content

Commit 6c59d75

Browse files
committed
feat(sql): support constant queries without a FROM clause
Handle SELECT statements with no FROM clause (e.g. SELECT 1, SELECT 'hello' AS name, SELECT 1 + 2). The planner evaluates constant expressions at planning time and emits a ConstantResult plan that produces a single row without touching any engine. - Add ConstantResult variant to SqlPlan with columns and values - Evaluate literal, unary negation, and binary arithmetic/concat exprs - Convert to a MetaOp::RawResponse physical task (zerompk-encoded) - Dispatch RawResponse in the Data Plane executor without engine I/O - Wire permission check (Read) for RawResponse tasks - Handle ConstantResult in nodedb-lite query engine
1 parent 10a27f5 commit 6c59d75

7 files changed

Lines changed: 116 additions & 2 deletions

File tree

nodedb-lite/src/query/engine.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,15 @@ impl<S: StorageEngine> LiteQueryEngine<S> {
8181

8282
fn execute_plan(&self, plan: &SqlPlan) -> Result<QueryResult, LiteError> {
8383
match plan {
84+
SqlPlan::ConstantResult { columns, values } => {
85+
let row = values.iter().map(sql_value_to_value).collect();
86+
Ok(QueryResult {
87+
columns: columns.clone(),
88+
rows: vec![row],
89+
rows_affected: 0,
90+
})
91+
}
92+
8493
SqlPlan::Scan {
8594
collection, engine, ..
8695
} => self.execute_scan(collection, engine),
@@ -286,6 +295,17 @@ fn sql_value_to_loro(v: &SqlValue) -> loro::LoroValue {
286295
}
287296
}
288297

298+
fn sql_value_to_value(v: &nodedb_sql::types::SqlValue) -> Value {
299+
match v {
300+
nodedb_sql::types::SqlValue::Int(i) => Value::Integer(*i),
301+
nodedb_sql::types::SqlValue::Float(f) => Value::Float(*f),
302+
nodedb_sql::types::SqlValue::String(s) => Value::String(s.clone()),
303+
nodedb_sql::types::SqlValue::Bool(b) => Value::Bool(*b),
304+
nodedb_sql::types::SqlValue::Null => Value::Null,
305+
_ => Value::Null,
306+
}
307+
}
308+
289309
fn loro_value_to_json(v: &loro::LoroValue) -> serde_json::Value {
290310
match v {
291311
loro::LoroValue::Null => serde_json::Value::Null,

nodedb-sql/src/planner/select.rs

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,36 @@ fn plan_select(
5959
// 1. Resolve FROM tables.
6060
let scope = TableScope::resolve_from(catalog, &select.from)?;
6161

62-
// 2. Check for JOINs.
62+
// 2. Handle constant queries (no FROM clause): SELECT 1, SELECT 'hello', etc.
63+
if select.from.is_empty() {
64+
let projection = convert_projection(&select.projection)?;
65+
let mut columns = Vec::new();
66+
let mut values = Vec::new();
67+
for (i, proj) in projection.iter().enumerate() {
68+
match proj {
69+
Projection::Computed { expr, alias } => {
70+
columns.push(alias.clone());
71+
values.push(eval_constant_expr(expr));
72+
}
73+
Projection::Column(name) => {
74+
columns.push(name.clone());
75+
values.push(SqlValue::Null);
76+
}
77+
_ => {
78+
columns.push(format!("col{i}"));
79+
values.push(SqlValue::Null);
80+
}
81+
}
82+
}
83+
return Ok(SqlPlan::ConstantResult { columns, values });
84+
}
85+
86+
// 3. Check for JOINs.
6387
if let Some(plan) = try_plan_join(select, &scope, catalog, functions)? {
6488
return Ok(plan);
6589
}
6690

67-
// 3. Single-table query.
91+
// 4. Single-table query.
6892
let table = scope.single_table().ok_or_else(|| SqlError::Unsupported {
6993
detail: "multi-table FROM without JOIN".into(),
7094
})?;
@@ -589,6 +613,38 @@ fn extract_func_args(func: &ast::Function) -> Result<Vec<ast::Expr>> {
589613
}
590614
}
591615

616+
/// Evaluate a constant SqlExpr to a SqlValue.
617+
fn eval_constant_expr(expr: &SqlExpr) -> SqlValue {
618+
match expr {
619+
SqlExpr::Literal(v) => v.clone(),
620+
SqlExpr::UnaryOp {
621+
op: UnaryOp::Neg,
622+
expr,
623+
} => match eval_constant_expr(expr) {
624+
SqlValue::Int(i) => SqlValue::Int(-i),
625+
SqlValue::Float(f) => SqlValue::Float(-f),
626+
other => other,
627+
},
628+
SqlExpr::BinaryOp { left, op, right } => {
629+
let l = eval_constant_expr(left);
630+
let r = eval_constant_expr(right);
631+
match (l, op, r) {
632+
(SqlValue::Int(a), BinaryOp::Add, SqlValue::Int(b)) => SqlValue::Int(a + b),
633+
(SqlValue::Int(a), BinaryOp::Sub, SqlValue::Int(b)) => SqlValue::Int(a - b),
634+
(SqlValue::Int(a), BinaryOp::Mul, SqlValue::Int(b)) => SqlValue::Int(a * b),
635+
(SqlValue::Float(a), BinaryOp::Add, SqlValue::Float(b)) => SqlValue::Float(a + b),
636+
(SqlValue::Float(a), BinaryOp::Sub, SqlValue::Float(b)) => SqlValue::Float(a - b),
637+
(SqlValue::Float(a), BinaryOp::Mul, SqlValue::Float(b)) => SqlValue::Float(a * b),
638+
(SqlValue::String(a), BinaryOp::Concat, SqlValue::String(b)) => {
639+
SqlValue::String(format!("{a}{b}"))
640+
}
641+
_ => SqlValue::Null,
642+
}
643+
}
644+
_ => SqlValue::Null,
645+
}
646+
}
647+
592648
fn extract_column_name(expr: &ast::Expr) -> Result<String> {
593649
match expr {
594650
ast::Expr::Identifier(ident) => Ok(normalize_ident(ident)),

nodedb-sql/src/types.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@
66
/// The top-level plan produced by the SQL planner.
77
#[derive(Debug, Clone)]
88
pub enum SqlPlan {
9+
// ── Constant ──
10+
/// Query with no FROM clause: SELECT 1, SELECT 'hello' AS name, etc.
11+
/// Produces a single row with evaluated constant expressions.
12+
ConstantResult {
13+
columns: Vec<String>,
14+
values: Vec<SqlValue>,
15+
},
16+
917
// ── Reads ──
1018
Scan {
1119
collection: String,

nodedb/src/bridge/physical_plan/meta.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ pub enum MetaOp {
7070
source_collection: String,
7171
},
7272

73+
/// Pre-computed response payload. The Data Plane echoes it back without
74+
/// touching any engine. Used for constant queries (SELECT 1 AS value).
75+
RawResponse { payload: Vec<u8> },
76+
7377
/// Purge ALL data for a tenant across every engine and cache.
7478
///
7579
/// Deletes documents, indexes, vectors, graph edges, timeseries,

nodedb/src/control/planner/sql_plan_convert.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,25 @@ fn convert_one(
4141
ctx: &ConvertContext,
4242
) -> crate::Result<Vec<PhysicalTask>> {
4343
match plan {
44+
SqlPlan::ConstantResult { columns, values } => {
45+
// Build a single-row result as Value::Object → zerompk.
46+
let mut map = std::collections::HashMap::new();
47+
for (col, val) in columns.iter().zip(values.iter()) {
48+
map.insert(col.clone(), sql_value_to_nodedb_value(val));
49+
}
50+
let row = nodedb_types::Value::Object(map);
51+
let payload =
52+
nodedb_types::value_to_msgpack(&row).map_err(|e| crate::Error::Serialization {
53+
format: "msgpack".into(),
54+
detail: format!("constant result: {e}"),
55+
})?;
56+
Ok(vec![PhysicalTask {
57+
tenant_id,
58+
vshard_id: VShardId::from_collection(""),
59+
plan: PhysicalPlan::Meta(MetaOp::RawResponse { payload }),
60+
}])
61+
}
62+
4463
SqlPlan::Scan {
4564
collection,
4665
engine,

nodedb/src/control/security/identity.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,9 @@ pub fn required_permission(plan: &crate::bridge::envelope::PhysicalPlan) -> Perm
281281
| VectorOp::Rebuild { .. },
282282
) => Permission::Alter,
283283

284+
// Pre-computed responses (constant queries like SELECT 1).
285+
PhysicalPlan::Meta(MetaOp::RawResponse { .. }) => Permission::Read,
286+
284287
// Control operations.
285288
PhysicalPlan::Meta(MetaOp::Cancel { .. }) => Permission::Admin,
286289

nodedb/src/data/executor/dispatch/other.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,10 @@ impl CoreLoop {
363363
}
364364
}
365365

366+
PhysicalPlan::Meta(MetaOp::RawResponse { payload }) => {
367+
self.response_with_payload(task, payload.clone())
368+
}
369+
366370
PhysicalPlan::Meta(MetaOp::QueryLastValue {
367371
collection,
368372
series_id,

0 commit comments

Comments
 (0)