Skip to content
76 changes: 76 additions & 0 deletions nodedb-sql/src/planner/defaults/compiled.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,68 @@ pub fn validate_default_expr(expr: &str, column: &str) -> crate::Result<()> {
CompiledDefault::declare(column, expr).map(|_| ())
}

/// Whether a declared value-producing expression references a row column.
///
/// A column `DEFAULT` and a typeguard `DEFAULT`/`VALUE` are evaluated with no
/// row in scope, so an expression that names a column can never produce a
/// value there. Callers that carry such an expression into a column `DEFAULT`
/// (CONVERT's typeguard path) use this to refuse at declaration time instead
/// of failing the first insert with [`crate::SqlError::UnevaluableDefault`].
///
/// The classification mirrors [`classify`]: a generator (`UUID_V7`,
/// `gen_uuid_v7()`) or a literal/parametric form carries no expression at all,
/// so only the `Expr` branch is parsed — and parsed through the same resolver
/// gate a DEFAULT passes. A second name list or expression walker is not
/// written.
pub fn default_expr_references_columns(expr: &str) -> crate::Result<bool> {
let upper = expr.trim().to_uppercase();
if keyword_generator(&upper).is_some() || parametric_or_literal(expr, &upper)?.is_some() {
return Ok(false);
}
let parsed = crate::parse_expr_string(expr)?;
Ok(expr_references_column(&parsed))
}

/// Recursive column-reference scan over a parsed DEFAULT expression.
fn expr_references_column(expr: &SqlExpr) -> bool {
match expr {
SqlExpr::Column { .. } => true,
SqlExpr::Literal(_) | SqlExpr::Wildcard | SqlExpr::Subquery(_) => false,
SqlExpr::BinaryOp { left, right, .. } => {
expr_references_column(left) || expr_references_column(right)
}
SqlExpr::UnaryOp { expr, .. }
| SqlExpr::IsNull { expr, .. }
| SqlExpr::Cast { expr, .. } => expr_references_column(expr),
SqlExpr::Function { args, .. } => args.iter().any(expr_references_column),
SqlExpr::Case {
operand,
when_then,
else_expr,
} => {
operand.as_deref().is_some_and(expr_references_column)
|| when_then
.iter()
.any(|(w, t)| expr_references_column(w) || expr_references_column(t))
|| else_expr.as_deref().is_some_and(expr_references_column)
}
SqlExpr::InList { expr, list, .. } => {
expr_references_column(expr) || list.iter().any(expr_references_column)
}
SqlExpr::Between {
expr, low, high, ..
} => {
expr_references_column(expr)
|| expr_references_column(low)
|| expr_references_column(high)
}
SqlExpr::Like { expr, pattern, .. } => {
expr_references_column(expr) || expr_references_column(pattern)
}
SqlExpr::ArrayLiteral(items) => items.iter().any(expr_references_column),
}
}

/// Classify a DEFAULT into its compiled form.
fn classify(column: &str, expr: &str) -> crate::Result<DefaultKind> {
let upper = expr.trim().to_uppercase();
Expand Down Expand Up @@ -367,4 +429,18 @@ mod tests {
.expect_err("unknown function refused");
assert!(matches!(error, SqlError::UnevaluableDefault { .. }));
}

/// A generator or a literal is not a column reference. Only the `Expr`
/// branch the classifier parses can carry one.
#[test]
fn only_the_expression_branch_can_reference_a_column() {
for constant in ["UUID_V7", "uuid_v7()", "gen_uuid_v7()", "'active'", "42"] {
assert!(
!default_expr_references_columns(constant).unwrap(),
"{constant} is a constant form"
);
}
assert!(default_expr_references_columns("LOWER(status)").unwrap());
assert!(default_expr_references_columns("status || '-x'").unwrap());
}
}
4 changes: 3 additions & 1 deletion nodedb-sql/src/planner/defaults/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,7 @@ mod compiled;
mod convert;
mod kind;

pub use compiled::{ColumnDefaults, CompiledDefault, validate_default_expr};
pub use compiled::{
ColumnDefaults, CompiledDefault, default_expr_references_columns, validate_default_expr,
};
pub use convert::default_value_to_sql;
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,11 @@ pub(super) async fn alter_table_add_column(
),
));
}
// A DEFAULT passes the same gate `CREATE` applies: evaluable, and a
// literal the declared type can hold.
// A DEFAULT passes the same gate `CREATE` applies: evaluable, constant, and
// a literal the declared type can hold. The gate reads the parsed default
// text, never the whole definition: the type parser finds the clause by
// substring, so a column name that contains the word `default` would
// otherwise be read as that clause.
if let Some(expr) = &column.default {
validate_column_default(
&DeclaredColumn {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ pub(crate) fn parse_origin_column_def(s: &str) -> crate::Result<nodedb_types::co
let is_pk = find_ascii_case_insensitive(s, "PRIMARY KEY").is_some();
let nullable = !is_not_null && !is_pk;

let default = if let Some(pos) = find_ascii_case_insensitive(s, "DEFAULT ") {
let default = if let Some(pos) = find_ascii_case_insensitive_from(s, " DEFAULT", type_start) {
let after_default = s[pos + 8..].trim();
let end = keywords
.iter()
Expand Down
56 changes: 49 additions & 7 deletions nodedb/src/control/server/shared/ddl/neutral/column_default.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,32 +68,74 @@ pub(super) fn validate_column_defaults(columns: &[(String, String)]) -> Result<(

/// Refuse one declared column `DEFAULT` the server cannot evaluate, or that
/// the column's declared type cannot hold.
pub(super) fn validate_column_default(
column: &DeclaredColumn<'_>,
expr: &str,
) -> Result<(), DdlError> {
validate_column_default_clause("DEFAULT", column, expr)
}

/// Refuse a value-producing clause whose value must be constant.
///
/// A column `DEFAULT` is const-folded once, with no row in scope, so an
/// expression that names another column can never produce a value there.
/// Refusing it at this gate — the one every producer of a column `DEFAULT`
/// calls — keeps the refusal at the declaration instead of the first insert's
/// `UnevaluableDefault`.
pub(super) fn validate_constant_clause_expr(
clause: &str,
owner: &str,
expr: &str,
) -> Result<(), DdlError> {
validate_clause_expr(clause, owner, expr)?;
let references_column = nodedb_sql::planner::defaults::default_expr_references_columns(expr)
.map_err(|error| clause_error(clause, owner, &error))?;
if references_column {
return Err(DdlError::new(
sqlstate::SYNTAX_ERROR,
format!(
"{clause} for '{owner}' references another column; it is evaluated with no row \
in scope, so give a constant expression"
),
));
}
Ok(())
}

/// Refuse a carried guard clause the server cannot evaluate as the column
/// `DEFAULT` it becomes, or that the column's declared type cannot hold.
///
/// `clause` names the keyword the author wrote, so a typeguard `VALUE` reports
/// itself rather than borrowing the `DEFAULT` wording.
///
/// The expression is classified and parsed, never evaluated, so a
/// `DEFAULT nextval('s')` column never advances its sequence at DDL time. A
/// literal is then coerced to the declared type and range-checked exactly as
/// an INSERT coerces the materialized value; a generator or an expression has
/// no value to check until it is evaluated.
///
/// An unregistered function name raises SQLSTATE `42883`, a literal the
/// declared type cannot represent `42804`, a literal past the declared
/// numeric width `22003`, and every other rejection `42601`.
pub(super) fn validate_column_default(
/// An unregistered function name raises SQLSTATE `42883`, a column-referencing
/// expression `42601`, a literal the declared type cannot represent `42804`, a
/// literal past the declared numeric width `22003`, and every other rejection
/// `42601`.
pub(super) fn validate_column_default_clause(
clause: &str,
column: &DeclaredColumn<'_>,
expr: &str,
) -> Result<(), DdlError> {
validate_constant_clause_expr(clause, column.name, expr)?;
let compiled = CompiledDefault::declare(column.name, expr)
.map_err(|error| clause_error("DEFAULT", column.name, &error))?;
.map_err(|error| clause_error(clause, column.name, &error))?;
let Some(literal) = compiled.literal() else {
return Ok(());
};
let mut info = declared_column_info(column.name, column.declared_type);
info.is_primary_key = column.primary_key;
let value = default_value_to_sql(column.name, literal.clone())
.map_err(|error| clause_error("DEFAULT", column.name, &error))?;
.map_err(|error| clause_error(clause, column.name, &error))?;
coerce_write_literal(&info, value)
.map(|_| ())
.map_err(|error| clause_error("DEFAULT", column.name, &error))
.map_err(|error| clause_error(clause, column.name, &error))
}

/// Refuse one declared value-producing clause the server cannot evaluate.
Expand Down
65 changes: 65 additions & 0 deletions nodedb/src/control/server/shared/ddl/neutral/convert/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ use crate::control::state::SharedState;
use nodedb_physical::physical_plan::MetaOp;

use super::super::super::result::{DdlError, DdlResult};
use super::super::column_default::validate_constant_clause_expr;
use super::column_defs::parse_convert_sql;
use super::support::err;
use super::typeguard_columns::typeguards_to_column_defs;
Expand All @@ -47,6 +48,27 @@ pub async fn convert_collection(
let columns: Option<Vec<nodedb_types::columnar::ColumnDef>> = match target_type.as_str() {
"document_strict" | "kv" => {
let cols = if let Some(cols) = explicit_columns {
// The list defines the schema; no guard is carried onto it. A
// guard the list covers and that names another column has no
// column DEFAULT equivalent — evaluated with no row in scope it
// would fail every insert, and dropping it would lose the
// guard's meaning silently. Refuse it, naming field and clause.
for guard in &coll.type_guards {
if !cols.iter().any(|c| c.name == guard.field) {
continue;
}
let carried = guard
.default_expr
.as_deref()
.map(|e| ("DEFAULT", e))
.or(guard.value_expr.as_deref().map(|e| ("VALUE", e)));
if let Some((clause, expr)) = carried {
// The one gate refuses an unregistered function name
// and a column-referencing expression alike, naming
// the field and the clause the author wrote.
validate_constant_clause_expr(clause, &guard.field, expr)?;
}
}
cols
} else if !coll.type_guards.is_empty() {
typeguards_to_column_defs(&coll.type_guards)?
Expand All @@ -61,6 +83,49 @@ pub async fn convert_collection(
_ => None,
};

// Preserve the source collection's declared identity through the
// conversion. Without this, a schemaless source declared `id TEXT PRIMARY
// KEY` converts to a strict schema whose columns carry no primary key, and
// every insert after the conversion fails `no resolved primary key`. A
// column list that omits the source key is refused rather than silently
// minting new row identities.
let mut columns = columns;
if let Some(cols) = columns.as_mut() {
let source_pk = match &coll.collection_type {
nodedb_types::CollectionType::Document(nodedb_types::DocumentMode::Strict(schema)) => {
schema
.columns
.iter()
.find(|c| c.primary_key)
.map(|c| c.name.clone())
}
nodedb_types::CollectionType::KeyValue(config) => config
.schema
.columns
.iter()
.find(|c| c.primary_key)
.map(|c| c.name.clone()),
nodedb_types::CollectionType::Document(nodedb_types::DocumentMode::Schemaless) => {
coll.declared_primary_key.clone()
}
nodedb_types::CollectionType::Columnar(_) => None,
};
if let Some(ref pk) = source_pk {
match cols.iter_mut().find(|c| &c.name == pk) {
Some(col) => col.primary_key = true,
None => {
return Err(err(
"42601",
format!(
"converted schema must keep the source primary key column \
'{pk}'; it is absent from the column list"
),
));
}
}
}
}

let schema_json_for_dp = if let Some(ref cols) = columns {
sonic_rs::to_string(cols).map_err(|e| err("XX000", format!("schema serialization: {e}")))?
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
//! reads the collection's active typeguards instead.

use super::super::super::result::DdlError;
use super::super::column_default::{DeclaredColumn, validate_column_default};
use super::super::column_default::{DeclaredColumn, validate_column_default_clause};
use super::support::err;
use super::type_map::typeguard_type_to_column_type;

Expand All @@ -16,8 +16,9 @@ use super::type_map::typeguard_type_to_column_type;
/// REQUIRED fields become NOT NULL. DEFAULT expressions carry over.
///
/// A carried-over expression becomes a column `DEFAULT`, so it passes the gate
/// every declared column `DEFAULT` passes. An unregistered function name
/// raises `42883`; every other rejection raises `42601`.
/// every declared column `DEFAULT` passes: an unregistered function name
/// raises `42883`, a literal the resolved type cannot hold `42804` or `22003`,
/// and every other rejection `42601`.
pub(super) fn typeguards_to_column_defs(
guards: &[nodedb_types::TypeGuardFieldDef],
) -> Result<Vec<nodedb_types::columnar::ColumnDef>, DdlError> {
Expand Down Expand Up @@ -49,11 +50,22 @@ pub(super) fn typeguards_to_column_defs(
};
// A guard carries either DEFAULT or VALUE, never both. Strict schema
// has one materialization slot, so both land on the column `DEFAULT`.
if let Some(expr) = guard.default_expr.clone().or(guard.value_expr.clone()) {
// The resolved type's own spelling stands in for the declaration:
// a guard names no numeric width, so the canonical name resolves
// to the same width-less type the column will carry.
validate_column_default(
let carried = guard
.default_expr
.clone()
.map(|expr| ("DEFAULT", expr))
.or(guard.value_expr.clone().map(|expr| ("VALUE", expr)));
if let Some((clause, expr)) = carried {
// The one gate refuses an unregistered function name, a
// column-referencing expression and a literal the declared type
// cannot hold alike, naming the field and the clause the author
// wrote. A guard VALUE evaluates per row against the document; the
// column DEFAULT it becomes does not. The resolved type's own
// spelling stands in for the declaration: a guard names no numeric
// width, so the canonical name resolves to the same width-less
// type the column will carry.
validate_column_default_clause(
clause,
&DeclaredColumn {
name: &col.name,
declared_type: &col.column_type.to_string(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,22 +138,37 @@ pub(super) async fn try_string(
// before the parse gate). Replicate that exactly here, before the parse
// gate, so the prefix recognition and syntax messages stay byte-identical.
if upper.starts_with("CREATE TYPEGUARD ") || upper.starts_with("CREATE OR REPLACE TYPEGUARD ") {
return Some(typeguard::create_typeguard(state, identity, sql));
return Some(typeguard::create_typeguard(
state,
identity,
database_id,
sql,
));
}
if upper.starts_with("ALTER TYPEGUARD ") {
return Some(typeguard::alter_typeguard(state, identity, sql));
return Some(typeguard::alter_typeguard(
state,
identity,
database_id,
sql,
));
}
if upper.starts_with("DROP TYPEGUARD ") {
return Some(typeguard::drop_typeguard(state, identity, sql));
return Some(typeguard::drop_typeguard(state, identity, database_id, sql));
}
if upper.starts_with("VALIDATE TYPEGUARD ON ") {
return Some(typeguard::validate_typeguard(state, identity, sql).await);
return Some(typeguard::validate_typeguard(state, identity, database_id, sql).await);
}
if upper.starts_with("SHOW TYPEGUARD ON ") {
return Some(typeguard::show_typeguard(state, identity, sql));
return Some(typeguard::show_typeguard(state, identity, database_id, sql));
}
if upper == "SHOW TYPEGUARDS" || upper.starts_with("SHOW TYPEGUARDS") {
return Some(typeguard::show_typeguards(state, identity, sql));
return Some(typeguard::show_typeguards(
state,
identity,
database_id,
sql,
));
}

None
Expand Down
Loading
Loading