Skip to main content

nodedb_sql/planner/dml/
update_delete.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! UPDATE, DELETE, and TRUNCATE planning.
4
5use nodedb_types::DatabaseId;
6use sqlparser::ast;
7
8use super::super::dml_helpers::{
9    VectorPrimaryUpdateParams, build_vector_primary_delete_plan,
10    build_vector_primary_truncate_plan, build_vector_primary_update_plan,
11    check_declared_float_ranges_in_assignments, check_declared_int_ranges_in_assignments,
12    extract_point_keys, extract_table_name_from_table_with_joins, is_vector_primary,
13};
14use crate::engine_rules::{self, DeleteParams, TruncateParams, UpdateParams};
15use crate::error::{Result, SqlError};
16use crate::parser::normalize::{SCHEMA_QUALIFIED_MSG, normalize_object_name_checked};
17use crate::planner::declared_type_coerce::coerce_assignments_to_declared_types;
18use crate::resolver::ColumnScope;
19use crate::resolver::columns::{ResolvedTable, TableScope};
20use crate::resolver::expr::convert_expr;
21use crate::types::*;
22
23/// Plan an UPDATE statement.
24pub fn plan_update(stmt: &ast::Statement, catalog: &dyn SqlCatalog) -> Result<Vec<SqlPlan>> {
25    let ast::Statement::Update(update) = stmt else {
26        return Err(SqlError::Parse {
27            detail: "expected UPDATE statement".into(),
28        });
29    };
30
31    if update.limit.is_some() {
32        return Err(SqlError::Unsupported {
33            detail: "UPDATE ... LIMIT is not supported".into(),
34        });
35    }
36
37    // Delegate to the UPDATE...FROM path when a FROM clause is present.
38    if let Some(from_kind) = update.from.as_ref() {
39        return super::update_from::plan_update_from(update, from_kind, catalog);
40    }
41
42    let table_name = extract_table_name_from_table_with_joins(&update.table)?;
43    // Validate an optional target alias even though the non-FROM update plan
44    // does not retain it.
45    crate::parser::normalize::table_name_from_factor(&update.table.relation)?;
46    let info = catalog
47        .get_collection(DatabaseId::DEFAULT, &table_name)?
48        .ok_or_else(|| SqlError::UnknownTable {
49            name: table_name.clone(),
50        })?;
51
52    let target_scope = TableScope::single(ResolvedTable {
53        name: table_name.clone(),
54        alias: None,
55        info: info.clone(),
56    })?;
57
58    let mut assigns = convert_assignments(&update.assignments, &target_scope, &target_scope)?;
59    // Re-type each literal assignment to its declared column type before the
60    // range check reads it — the same order, and for the same reason, as the
61    // INSERT path's `coerce_and_check_rows`. Engines with a typed write path
62    // coerce it themselves; the schemaless-document and key-value engines,
63    // which persist the value they are handed, do not — and an `UPDATE` that
64    // leaves a numeric column holding text is the same silent loss on read as
65    // an `INSERT` that does (see `declared_type_coerce`).
66    coerce_assignments_to_declared_types(&info.columns, &mut assigns, info.primary_key.as_deref())?;
67    check_declared_int_ranges_in_assignments(&info.columns, &assigns)?;
68    check_declared_float_ranges_in_assignments(&info.columns, &assigns)?;
69
70    let filters = match &update.selection {
71        Some(expr) => super::super::select::convert_where_to_filters(expr, &target_scope)?,
72        None => Vec::new(),
73    };
74
75    let target_keys = extract_point_keys(update.selection.as_ref(), &info);
76
77    // Vector-primary collection: the row lives in the HNSW index and its
78    // payload sidecar, never in the document store the engine rules target.
79    if is_vector_primary(&info)
80        && let Some(ref vpc) = info.vector_primary
81    {
82        return build_vector_primary_update_plan(VectorPrimaryUpdateParams {
83            collection: &table_name,
84            info: &info,
85            vpc,
86            assignments: assigns,
87            filters,
88            target_keys,
89            returning: update.returning.is_some(),
90        });
91    }
92
93    let rules = engine_rules::resolve_engine_rules(info.engine);
94    rules.plan_update(UpdateParams {
95        collection: table_name,
96        assignments: assigns,
97        filters,
98        target_keys,
99        returning: update.returning.is_some(),
100    })
101}
102
103/// Convert `update.assignments` into `Vec<(col, SqlExpr)>`.
104pub(super) fn convert_assignments(
105    assignments: &[ast::Assignment],
106    target_scope: &TableScope,
107    value_scope: &TableScope,
108) -> Result<Vec<(String, SqlExpr)>> {
109    assignments
110        .iter()
111        .map(|a| {
112            let col = match &a.target {
113                ast::AssignmentTarget::ColumnName(name) => {
114                    if name.0.len() > 1 {
115                        return Err(SqlError::Unsupported {
116                            detail: format!(
117                                "qualified column name in SET target: {SCHEMA_QUALIFIED_MSG}"
118                            ),
119                        });
120                    }
121                    normalize_object_name_checked(name)?
122                }
123                ast::AssignmentTarget::Tuple(names) => names
124                    .iter()
125                    .map(normalize_object_name_checked)
126                    .collect::<Result<Vec<_>>>()?
127                    .join(","),
128            };
129            for name in col.split(',') {
130                target_scope.check_name(None, name)?;
131            }
132            let val = convert_expr(&a.value, &ColumnScope::Relations(value_scope))?;
133            Ok((col, val))
134        })
135        .collect()
136}
137
138/// Plan a DELETE statement.
139pub fn plan_delete(stmt: &ast::Statement, catalog: &dyn SqlCatalog) -> Result<Vec<SqlPlan>> {
140    let ast::Statement::Delete(delete) = stmt else {
141        return Err(SqlError::Parse {
142            detail: "expected DELETE statement".into(),
143        });
144    };
145
146    if delete.limit.is_some() {
147        return Err(SqlError::Unsupported {
148            detail: "DELETE ... LIMIT is not supported".into(),
149        });
150    }
151    if !delete.order_by.is_empty() {
152        return Err(SqlError::Unsupported {
153            detail: "DELETE ... ORDER BY is not supported".into(),
154        });
155    }
156
157    let from_tables = match &delete.from {
158        ast::FromTable::WithFromKeyword(tables) | ast::FromTable::WithoutKeyword(tables) => tables,
159    };
160    let from = from_tables.first().ok_or_else(|| SqlError::Parse {
161        detail: "DELETE requires a FROM table".into(),
162    })?;
163    let table_name = extract_table_name_from_table_with_joins(from)?;
164    crate::parser::normalize::table_name_from_factor(&from.relation)?;
165    let info = catalog
166        .get_collection(DatabaseId::DEFAULT, &table_name)?
167        .ok_or_else(|| SqlError::UnknownTable {
168            name: table_name.clone(),
169        })?;
170
171    let target_scope = TableScope::single(ResolvedTable {
172        name: table_name.clone(),
173        alias: None,
174        info: info.clone(),
175    })?;
176
177    let filters = match &delete.selection {
178        Some(expr) => super::super::select::convert_where_to_filters(expr, &target_scope)?,
179        None => Vec::new(),
180    };
181
182    let target_keys = extract_point_keys(delete.selection.as_ref(), &info);
183
184    // Vector-primary collection: see `plan_update`.
185    if is_vector_primary(&info)
186        && let Some(ref vpc) = info.vector_primary
187    {
188        return Ok(build_vector_primary_delete_plan(
189            &table_name,
190            &info,
191            vpc,
192            filters,
193            target_keys,
194        ));
195    }
196
197    let rules = engine_rules::resolve_engine_rules(info.engine);
198    rules.plan_delete(DeleteParams {
199        collection: table_name,
200        filters,
201        target_keys,
202    })
203}
204
205/// Plan a TRUNCATE statement.
206///
207/// Each named collection resolves through the catalog: a vector-primary
208/// collection lowers to `SqlPlan::VectorPrimaryTruncate`, every other
209/// engine routes through its `EngineRules::plan_truncate`.
210pub fn plan_truncate_stmt(stmt: &ast::Statement, catalog: &dyn SqlCatalog) -> Result<Vec<SqlPlan>> {
211    let ast::Statement::Truncate(truncate) = stmt else {
212        return Err(SqlError::Parse {
213            detail: "expected TRUNCATE statement".into(),
214        });
215    };
216    let restart_identity = matches!(
217        truncate.identity,
218        Some(sqlparser::ast::TruncateIdentityOption::Restart)
219    );
220    let mut plans = Vec::with_capacity(truncate.table_names.len());
221    for target in &truncate.table_names {
222        let table_name = normalize_object_name_checked(&target.name)?;
223        // An array lives in its own catalog namespace, so its refusal comes
224        // from `ArrayRules` before the collection lookup can miss it.
225        if catalog.array_exists(&table_name) {
226            plans.append(
227                &mut engine_rules::resolve_engine_rules(EngineType::Array).plan_truncate(
228                    TruncateParams {
229                        collection: table_name,
230                        restart_identity,
231                    },
232                )?,
233            );
234            continue;
235        }
236        let info = catalog
237            .get_collection(DatabaseId::DEFAULT, &table_name)?
238            .ok_or_else(|| SqlError::UnknownTable {
239                name: table_name.clone(),
240            })?;
241        // Vector-primary collection: see `plan_update`.
242        if is_vector_primary(&info)
243            && let Some(ref vpc) = info.vector_primary
244        {
245            plans.push(build_vector_primary_truncate_plan(
246                &table_name,
247                vpc,
248                restart_identity,
249            ));
250            continue;
251        }
252        let rules = engine_rules::resolve_engine_rules(info.engine);
253        plans.append(&mut rules.plan_truncate(TruncateParams {
254            collection: table_name,
255            restart_identity,
256        })?);
257    }
258    Ok(plans)
259}