Skip to content

Commit ec4748f

Browse files
committed
refactor(sql): split dml_helpers into a directory module
Convert the single dml_helpers.rs file into a directory of single-concern modules (value_convert, range_check, insert_columns, ast_extract, vector_primary_insert, kv_insert) to keep each file under the per-concern size limit as the declared-width range checks grow. Purely mechanical: no behavior changes.
1 parent a63b68f commit ec4748f

8 files changed

Lines changed: 571 additions & 486 deletions

File tree

nodedb-sql/src/planner/dml_helpers.rs

Lines changed: 0 additions & 486 deletions
This file was deleted.
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
//! AST extraction helpers shared by the DML planners: pulling a bare table
4+
//! name out of a join tree, and pulling primary-key point-lookup keys out of
5+
//! a `WHERE` clause.
6+
7+
use sqlparser::ast;
8+
9+
use super::value_convert::expr_to_sql_value;
10+
use crate::error::{Result, SqlError};
11+
use crate::parser::normalize::{normalize_ident, normalize_object_name_checked};
12+
use crate::types::*;
13+
14+
pub(crate) fn extract_table_name_from_table_with_joins(
15+
table: &ast::TableWithJoins,
16+
) -> Result<String> {
17+
match &table.relation {
18+
ast::TableFactor::Table { name, .. } => Ok(normalize_object_name_checked(name)?),
19+
_ => Err(SqlError::Unsupported {
20+
detail: "non-table target in DML".into(),
21+
}),
22+
}
23+
}
24+
25+
/// Extract point-operation keys from WHERE clause (WHERE pk = literal OR pk IN (...)).
26+
pub fn extract_point_keys(selection: Option<&ast::Expr>, info: &CollectionInfo) -> Vec<SqlValue> {
27+
let pk = match &info.primary_key {
28+
Some(pk) => pk.clone(),
29+
None => return Vec::new(),
30+
};
31+
32+
let expr = match selection {
33+
Some(e) => e,
34+
None => return Vec::new(),
35+
};
36+
37+
let mut keys = Vec::new();
38+
collect_pk_equalities(expr, &pk, &mut keys);
39+
keys
40+
}
41+
42+
fn collect_pk_equalities(expr: &ast::Expr, pk: &str, keys: &mut Vec<SqlValue>) {
43+
match expr {
44+
ast::Expr::BinaryOp {
45+
left,
46+
op: ast::BinaryOperator::Eq,
47+
right,
48+
} => {
49+
if is_column(left, pk)
50+
&& let Ok(v) = expr_to_sql_value(right)
51+
{
52+
keys.push(v);
53+
} else if is_column(right, pk)
54+
&& let Ok(v) = expr_to_sql_value(left)
55+
{
56+
keys.push(v);
57+
}
58+
}
59+
ast::Expr::BinaryOp {
60+
left,
61+
op: ast::BinaryOperator::Or,
62+
right,
63+
} => {
64+
collect_pk_equalities(left, pk, keys);
65+
collect_pk_equalities(right, pk, keys);
66+
}
67+
ast::Expr::InList {
68+
expr: inner,
69+
list,
70+
negated: false,
71+
} if is_column(inner, pk) => {
72+
for item in list {
73+
if let Ok(v) = expr_to_sql_value(item) {
74+
keys.push(v);
75+
}
76+
}
77+
}
78+
_ => {}
79+
}
80+
}
81+
82+
fn is_column(expr: &ast::Expr, name: &str) -> bool {
83+
match expr {
84+
ast::Expr::Identifier(ident) => normalize_ident(ident) == name,
85+
// Three or more parts: schema.table.col — never matches a plain pk name.
86+
ast::Expr::CompoundIdentifier(parts) if parts.len() >= 3 => false,
87+
ast::Expr::CompoundIdentifier(parts) if parts.len() == 2 => {
88+
normalize_ident(&parts[1]) == name
89+
}
90+
_ => false,
91+
}
92+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
//! Effective-column resolution for positional `VALUES`-clause inserts.
4+
5+
use sqlparser::ast;
6+
7+
use crate::error::{Result, SqlError};
8+
use crate::types::*;
9+
10+
/// Resolve the effective column list for a `VALUES`-clause INSERT/UPSERT.
11+
///
12+
/// A *positional* insert — `INSERT INTO t VALUES (...)` with no explicit
13+
/// column list — must still bind each value to the collection's declared
14+
/// column names. Left alone, `convert_value_rows` falls back to synthetic
15+
/// `col0`, `col1`, ... names for every value (see its `col{i}` fallback
16+
/// below): the row stores fine, but named projections and WHERE predicates
17+
/// can never find it again (#202).
18+
///
19+
/// Named inserts (`columns` already non-empty) and schemaless collections
20+
/// (`info.columns` empty — there is no declared order to bind to) pass
21+
/// through unchanged; the `col{i}` fallback remains the last resort for
22+
/// those.
23+
///
24+
/// Fewer values than declared columns binds by position against the
25+
/// leading columns, consistent with a partial named insert (e.g.
26+
/// `INSERT INTO t (id) VALUES (1)` on a wider table also only binds
27+
/// `id`). More values than declared columns is rejected outright:
28+
/// inventing a `colN` slot for the overflow would reproduce the exact
29+
/// unaddressable-column failure this fix closes.
30+
pub(crate) fn resolve_insert_columns(
31+
columns: Vec<String>,
32+
info: &CollectionInfo,
33+
rows: &[Vec<ast::Expr>],
34+
) -> Result<Vec<String>> {
35+
if !columns.is_empty() || info.columns.is_empty() {
36+
return Ok(columns);
37+
}
38+
39+
let declared: Vec<String> = info.columns.iter().map(|c| c.name.clone()).collect();
40+
if let Some(row) = rows.iter().find(|row| row.len() > declared.len()) {
41+
return Err(SqlError::InsertColumnArityMismatch {
42+
collection: info.name.clone(),
43+
given: row.len(),
44+
declared: declared.len(),
45+
});
46+
}
47+
Ok(declared)
48+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
//! Plan construction for the KV engine's `VALUES`-clause insert paths
4+
//! (plain `INSERT`, `UPSERT`, and `INSERT ... ON CONFLICT DO UPDATE`).
5+
6+
use sqlparser::ast;
7+
8+
use super::range_check::check_declared_int_ranges;
9+
use super::value_convert::expr_to_sql_value;
10+
use crate::error::{Result, SqlError};
11+
use crate::planner::declared_type_coerce::{
12+
coerce_assignments_to_declared_types, coerce_row_to_declared_types,
13+
};
14+
use crate::types::*;
15+
16+
/// Build a `SqlPlan::KvInsert` from a VALUES clause. Shared by plain INSERT,
17+
/// UPSERT, and `INSERT ... ON CONFLICT (key) DO UPDATE` — the three paths
18+
/// differ only in `intent` and `on_conflict_updates`, never in how entries
19+
/// are extracted from the row exprs.
20+
///
21+
/// `pk_col` is the schema-defined primary-key column name from
22+
/// `CollectionInfo::primary_key`. When supplied, that column is used as
23+
/// the KV key regardless of whether it is named `"key"`. Falls back to
24+
/// the literal name `"key"` when `pk_col` is `None` (legacy / generic
25+
/// KV collections that use the built-in key/value column convention).
26+
pub(crate) fn build_kv_insert_plan(
27+
table_name: String,
28+
columns: &[String],
29+
rows_ast: &[Vec<ast::Expr>],
30+
intent: KvInsertIntent,
31+
mut on_conflict_updates: Vec<(String, SqlExpr)>,
32+
pk_col: Option<&str>,
33+
declared_columns: &[ColumnInfo],
34+
) -> Result<Vec<SqlPlan>> {
35+
// Positional KV insert (no column list): the key/value split below is
36+
// driven entirely by matching column *names* against `key_col_name`/
37+
// `"ttl"`. With an empty `columns` list there is no key to bind to, so
38+
// every row would silently become an empty-keyed, empty-valued entry
39+
// (all colliding). Reject rather than corrupt.
40+
if columns.is_empty() {
41+
return Err(SqlError::PositionalKvInsertUnsupported {
42+
collection: table_name,
43+
});
44+
}
45+
let key_col_name = pk_col.unwrap_or("key");
46+
let key_idx = columns.iter().position(|c| c == key_col_name);
47+
let ttl_idx = columns.iter().position(|c| c == "ttl");
48+
// When using a named primary-key column (e.g. `k STRING PRIMARY KEY`), we
49+
// store the key bytes in the KV key slot AND also keep the column in the
50+
// value map. This allows scan filters on the primary-key column (e.g.
51+
// `WHERE k = 'x'`) and projection (e.g. `SELECT k FROM ...`) to work
52+
// without teaching the KV scan handler to inspect the raw key bytes.
53+
// The only column we exclude from the value map is the built-in `"key"`
54+
// sentinel (used by raw key/value KV collections) and `"ttl"`.
55+
let exclude_from_value: std::collections::HashSet<usize> = {
56+
let mut s = std::collections::HashSet::new();
57+
// Exclude the raw "key" sentinel column (not a named PK column).
58+
if key_col_name == "key"
59+
&& let Some(idx) = key_idx
60+
{
61+
s.insert(idx);
62+
}
63+
if let Some(idx) = ttl_idx {
64+
s.insert(idx);
65+
}
66+
s
67+
};
68+
// Resolve every row's literals once, then coerce each cell to its declared
69+
// column type. Unlike the strict, columnar, and timeseries engines, KV has
70+
// no typed write path: its engine stores the bytes it is handed and the
71+
// declared schema exists only here, in the catalog. Without this the
72+
// stored cell's type is whatever the literal happened to resolve to — a
73+
// fractional literal is an exact `Decimal`, which serializes as a msgpack
74+
// string — while `RowDescription` advertises the declared numeric type, and
75+
// the read path can only encode SQL NULL for it. See
76+
// `declared_type_coerce` for the full rationale.
77+
let mut coerced_rows: Vec<Vec<(String, SqlValue)>> = Vec::with_capacity(rows_ast.len());
78+
for row_exprs in rows_ast {
79+
let mut row: Vec<(String, SqlValue)> = Vec::with_capacity(columns.len());
80+
for (i, col) in columns.iter().enumerate() {
81+
let Some(expr) = row_exprs.get(i) else { break };
82+
row.push((col.clone(), expr_to_sql_value(expr)?));
83+
}
84+
// The key column is exempt — see `coerce_rows_to_declared_types`.
85+
coerce_row_to_declared_types(declared_columns, &mut row, Some(key_col_name))?;
86+
coerced_rows.push(row);
87+
}
88+
// KV returns early from every INSERT/UPSERT entry point, so the declared
89+
// width check lives here rather than at the call sites — otherwise a
90+
// fourth KV entry point could be added without it. It runs on the coerced
91+
// values so a literal that only becomes an integer through coercion is
92+
// still range-checked against its declared width.
93+
check_declared_int_ranges(declared_columns, &coerced_rows)?;
94+
// `ON CONFLICT DO UPDATE SET col = <literal>` writes through the same
95+
// untyped KV path as the inserted row, so its literals need the same
96+
// declared-type coercion.
97+
coerce_assignments_to_declared_types(
98+
declared_columns,
99+
&mut on_conflict_updates,
100+
Some(key_col_name),
101+
)?;
102+
103+
let mut entries = Vec::with_capacity(coerced_rows.len());
104+
let mut ttl_secs: u64 = 0;
105+
for row in &coerced_rows {
106+
let key_val = match key_idx.and_then(|idx| row.get(idx)) {
107+
Some((_, value)) => value.clone(),
108+
None => SqlValue::String(String::new()),
109+
};
110+
if let Some((_, value)) = ttl_idx.and_then(|idx| row.get(idx)) {
111+
match value {
112+
SqlValue::Int(n) => ttl_secs = (*n).max(0) as u64,
113+
SqlValue::Float(f) => ttl_secs = f.max(0.0) as u64,
114+
_ => {}
115+
}
116+
}
117+
let value_cols: Vec<(String, SqlValue)> = row
118+
.iter()
119+
.enumerate()
120+
.filter(|(i, _)| !exclude_from_value.contains(i))
121+
.map(|(_, cell)| cell.clone())
122+
.collect();
123+
entries.push((key_val, value_cols));
124+
}
125+
Ok(vec![SqlPlan::KvInsert {
126+
collection: table_name,
127+
entries,
128+
ttl_secs,
129+
intent,
130+
on_conflict_updates,
131+
}])
132+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
//! DML planning helpers, split by concern:
4+
//! - [`value_convert`] — `ast::Expr` -> `SqlValue` conversion
5+
//! - [`range_check`] — declared-width coercion + range validation
6+
//! - [`insert_columns`] — positional-insert column resolution
7+
//! - [`ast_extract`] — table-name / primary-key point-lookup extraction
8+
//! - [`vector_primary_insert`] — vector-primary collection insert plans
9+
//! - [`kv_insert`] — KV engine insert plans
10+
11+
mod ast_extract;
12+
mod insert_columns;
13+
mod kv_insert;
14+
mod range_check;
15+
mod value_convert;
16+
mod vector_primary_insert;
17+
18+
pub use ast_extract::extract_point_keys;
19+
pub(super) use ast_extract::extract_table_name_from_table_with_joins;
20+
pub(super) use insert_columns::resolve_insert_columns;
21+
pub(super) use kv_insert::build_kv_insert_plan;
22+
pub(super) use range_check::{check_declared_int_ranges_in_assignments, coerce_and_check_rows};
23+
pub(super) use value_convert::{convert_value_rows, expr_to_sql_value};
24+
pub(super) use vector_primary_insert::build_vector_primary_insert_plan;

0 commit comments

Comments
 (0)