vibesql_executor/delete/executor.rs
1//! DELETE statement execution
2
3use vibesql_ast::DeleteStmt;
4use vibesql_catalog::TableIdentifier;
5use vibesql_storage::Database;
6use vibesql_types::SqlValue;
7
8use super::integrity::check_no_child_references;
9use crate::{
10 dml_cost::DmlOptimizer,
11 errors::ExecutorError,
12 evaluator::{coercion::coerce_value_to_column_type, ExpressionEvaluator},
13 expression_index_maintenance, partial_index_maintenance,
14 privilege_checker::PrivilegeChecker,
15 sqlite_schema::is_sqlite_schema_table,
16 truncate_validation::can_use_truncate,
17};
18
19/// Executor for DELETE statements
20pub struct DeleteExecutor;
21
22impl DeleteExecutor {
23 /// Execute a DELETE statement
24 ///
25 /// # Arguments
26 ///
27 /// * `stmt` - The DELETE statement AST node
28 /// * `database` - The database to delete from
29 ///
30 /// # Returns
31 ///
32 /// Number of rows deleted or error
33 ///
34 /// # Examples
35 ///
36 /// ```
37 /// use vibesql_ast::{BinaryOperator, DeleteStmt, Expression, WhereClause};
38 /// use vibesql_catalog::{ColumnSchema, TableSchema};
39 /// use vibesql_executor::DeleteExecutor;
40 /// use vibesql_storage::Database;
41 /// use vibesql_types::{DataType, SqlValue};
42 ///
43 /// let mut db = Database::new();
44 ///
45 /// // Create table
46 /// let schema = TableSchema::new(
47 /// "users".to_string(),
48 /// vec![
49 /// ColumnSchema::new("id".to_string(), DataType::Integer, false),
50 /// ColumnSchema::new(
51 /// "name".to_string(),
52 /// DataType::Varchar { max_length: Some(50) },
53 /// false,
54 /// ),
55 /// ],
56 /// );
57 /// db.create_table(schema).unwrap();
58 ///
59 /// // Insert rows
60 /// db.insert_row(
61 /// "users",
62 /// vibesql_storage::Row::new(vec![
63 /// SqlValue::Integer(1),
64 /// SqlValue::Varchar(arcstr::ArcStr::from("Alice")),
65 /// ]),
66 /// )
67 /// .unwrap();
68 /// db.insert_row(
69 /// "users",
70 /// vibesql_storage::Row::new(vec![
71 /// SqlValue::Integer(2),
72 /// SqlValue::Varchar(arcstr::ArcStr::from("Bob")),
73 /// ]),
74 /// )
75 /// .unwrap();
76 ///
77 /// // Delete specific row
78 /// let stmt = DeleteStmt {
79 /// with_clause: None,
80 /// only: false,
81 /// table_name: "users".to_string(),
82 /// quoted: false,
83 /// where_clause: Some(WhereClause::Condition(Expression::BinaryOp {
84 /// left: Box::new(Expression::ColumnRef(vibesql_ast::ColumnIdentifier::simple("id", false))),
85 /// op: BinaryOperator::Equal,
86 /// right: Box::new(Expression::Literal(SqlValue::Integer(1))),
87 /// })),
88 /// order_by: None,
89 /// limit: None,
90 /// offset: None,
91 /// returning: None,
92 /// };
93 ///
94 /// let count = DeleteExecutor::execute(&stmt, &mut db).unwrap();
95 /// assert_eq!(count, 1);
96 /// ```
97 pub fn execute(stmt: &DeleteStmt, database: &mut Database) -> Result<usize, ExecutorError> {
98 // SQLite per-variant RAISE scope handling for the top-level statement
99 // (#5417): see [`crate::raise_scope::run_top_level_dml`].
100 let may_fire = crate::raise_scope::table_may_fire_trigger(database, &stmt.table_name);
101 crate::raise_scope::run_top_level_dml(database, may_fire, |database| {
102 Self::execute_internal(stmt, database, None, None).map(|(count, _)| count)
103 })
104 }
105
106 /// Execute a DELETE statement, capturing RETURNING rows (SQLite 3.35.0+)
107 ///
108 /// Returns the number of deleted rows plus, when the statement carries a
109 /// RETURNING clause, the projected OLD rows (values before deletion) —
110 /// one per deleted row, or one per INSTEAD OF trigger fire for views.
111 ///
112 /// When the statement has no RETURNING clause the second element is `None`.
113 pub fn execute_returning(
114 stmt: &DeleteStmt,
115 database: &mut Database,
116 ) -> Result<(usize, Option<crate::select::SelectResult>), ExecutorError> {
117 // Per-variant RAISE scope for RETURNING DML (#5432, follow-on to #5417):
118 // a RAISE fired from a trigger during a RETURNING DELETE gets the same
119 // statement-savepoint scope as the bare `execute` path; when it aborts
120 // the statement the error propagates (no rows returned). Minimal
121 // entry-point wrap only — no change to `execute_internal` / loop bodies.
122 let may_fire = crate::raise_scope::table_may_fire_trigger(database, &stmt.table_name);
123 crate::raise_scope::run_top_level_dml(database, may_fire, |database| {
124 Self::execute_internal(stmt, database, None, None)
125 })
126 }
127
128 /// Execute a DELETE statement with procedural context
129 /// Supports procedural variables in WHERE clause
130 pub fn execute_with_procedural_context(
131 stmt: &DeleteStmt,
132 database: &mut Database,
133 procedural_context: &crate::procedural::ExecutionContext,
134 ) -> Result<usize, ExecutorError> {
135 // Per-variant RAISE scope for procedural-context DML (#5432): a RAISE
136 // fired from a trigger inside a procedure/script gets the same
137 // statement-savepoint scope as the bare `execute` path. Minimal
138 // entry-point wrap only — no change to `execute_internal` / loop bodies.
139 let may_fire = crate::raise_scope::table_may_fire_trigger(database, &stmt.table_name);
140 crate::raise_scope::run_top_level_dml(database, may_fire, |database| {
141 Self::execute_internal(stmt, database, Some(procedural_context), None)
142 .map(|(count, _)| count)
143 })
144 }
145
146 /// Execute a DELETE statement with trigger context
147 /// This allows DELETE statements within trigger bodies to reference OLD/NEW pseudo-variables
148 pub fn execute_with_trigger_context(
149 stmt: &DeleteStmt,
150 database: &mut Database,
151 trigger_context: &crate::trigger_execution::TriggerContext,
152 ) -> Result<usize, ExecutorError> {
153 Self::execute_internal(stmt, database, None, Some(trigger_context)).map(|(count, _)| count)
154 }
155
156 /// Internal implementation supporting procedural context and trigger context
157 fn execute_internal(
158 stmt: &DeleteStmt,
159 database: &mut Database,
160 procedural_context: Option<&crate::procedural::ExecutionContext>,
161 trigger_context: Option<&crate::trigger_execution::TriggerContext>,
162 ) -> Result<(usize, Option<crate::select::SelectResult>), ExecutorError> {
163 // Note: stmt.only is currently ignored (treated as false)
164 // ONLY keyword is used in table inheritance to exclude derived tables.
165 // Since table inheritance is not yet implemented, we treat all deletes the same.
166
167 // Check if target is sqlite_master/sqlite_schema (read-only system table)
168 if is_sqlite_schema_table(&stmt.table_name) {
169 return Err(ExecutorError::SqliteSystemTableReadOnly {
170 table_name: stmt.table_name.clone(),
171 operation: "modified".to_string(),
172 });
173 }
174
175 // Check DELETE privilege on the table
176 PrivilegeChecker::check_delete(database, &stmt.table_name)?;
177
178 // Check if target is a VIEW with INSTEAD OF triggers
179 if let Some(view_def) = database.catalog.get_view(&stmt.table_name).cloned() {
180 return execute_delete_on_view(
181 database,
182 stmt,
183 &view_def,
184 procedural_context,
185 trigger_context,
186 );
187 }
188
189 // Use TableIdentifier for SQL:1999 case-sensitive lookups when quoted
190 // Handle schema-qualified table names (e.g., "temp.t1")
191 let table_id = if let Some((schema_part, table_part)) = stmt.table_name.split_once('.') {
192 // Schema-qualified name: schema.table
193 TableIdentifier::qualified(schema_part, false, table_part, stmt.quoted)
194 } else {
195 TableIdentifier::new(&stmt.table_name, stmt.quoted)
196 };
197
198 // Check table exists
199 if !database.catalog.table_exists_by_identifier(&table_id) {
200 return Err(ExecutorError::TableNotFound(stmt.table_name.clone()));
201 }
202
203 // Fast path: DELETE FROM table (no WHERE clause)
204 // Use TRUNCATE-style optimization for 100-1000x performance improvement
205 // Only use truncate if there's no ORDER BY or LIMIT (which would restrict which rows to
206 // delete). RETURNING needs the standard scan path so the OLD rows can be captured.
207 if stmt.where_clause.is_none()
208 && stmt.order_by.is_none()
209 && stmt.limit.is_none()
210 && stmt.returning.is_none()
211 && can_use_truncate(database, &stmt.table_name)?
212 {
213 return execute_truncate(database, &stmt.table_name).map(|count| (count, None));
214 }
215
216 // Step 1: Get schema (clone to avoid borrow issues)
217 let schema = database
218 .catalog
219 .get_table_by_identifier(&table_id)
220 .ok_or_else(|| ExecutorError::TableNotFound(stmt.table_name.clone()))?
221 .clone();
222
223 // Use canonical table name from schema for all storage operations
224 // This ensures case-sensitive tables (quoted identifiers) are accessed correctly
225 let table_name = &schema.name;
226
227 // Fast path: Single-row PK delete without triggers/FKs
228 // This avoids ExpressionEvaluator creation and row cloning
229 // RETURNING needs the standard path so the OLD row can be captured
230 if procedural_context.is_none() && trigger_context.is_none() && stmt.returning.is_none() {
231 if let Some(vibesql_ast::WhereClause::Condition(where_expr)) = &stmt.where_clause {
232 if let Some(pk_values) = Self::extract_primary_key_lookup(where_expr, &schema) {
233 // Coerce extracted WHERE-clause literals to match the PK
234 // column affinities. The PK index HashMap is keyed on
235 // stored (already-coerced) values, so a raw literal can
236 // silently miss when types differ — e.g. `WHERE p=1200`
237 // on a TEXT PRIMARY KEY storing "1200". See issue #5145.
238 let pk_values: Vec<SqlValue> =
239 if let Some(pk_indices) = schema.get_primary_key_indices() {
240 pk_values
241 .into_iter()
242 .zip(pk_indices.iter())
243 .map(|(val, &idx)| {
244 coerce_value_to_column_type(val, &schema.columns[idx].data_type)
245 })
246 .collect()
247 } else {
248 pk_values
249 };
250
251 // Check if we can use the super-fast path (no triggers, no FKs)
252 let has_triggers = database
253 .catalog
254 .get_triggers_for_table(table_name, Some(vibesql_ast::TriggerEvent::Delete))
255 .next()
256 .is_some();
257
258 // Fast check: if this table has no PK, FKs can't reference it
259 let has_pk = schema.get_primary_key_indices().is_some();
260 let has_referencing_fks = has_pk
261 && database.catalog.list_tables().iter().any(|t| {
262 database
263 .catalog
264 .get_table(t)
265 .map(|s| {
266 s.foreign_keys
267 .iter()
268 .any(|fk| fk.parent_table.eq_ignore_ascii_case(table_name))
269 })
270 .unwrap_or(false)
271 });
272
273 // Also skip fast path if there are expression indexes that need maintenance
274 let has_expression_indexes = database.has_expression_indexes(table_name);
275 // Skip fast path if there are partial indexes — they need
276 // the executor to evaluate the WHERE predicate against
277 // the row being deleted before removing the entry.
278 let has_partial_indexes = database.has_partial_indexes(table_name);
279 if !has_triggers
280 && !has_referencing_fks
281 && !has_expression_indexes
282 && !has_partial_indexes
283 {
284 // Use the fast path - no triggers, no FKs, no expression indexes, single row PK delete
285 match database.delete_by_pk_fast(table_name, &pk_values) {
286 Ok(deleted) => {
287 let count = if deleted { 1 } else { 0 };
288 // Check all assertions after DELETE completes (SQL:1999 Feature
289 // F671/F672) This ensures
290 // database-wide integrity constraints are maintained
291 crate::advanced_objects::AssertionChecker::check_all_assertions(
292 database,
293 )?;
294 return Ok((count, None));
295 }
296 Err(_) => {
297 // Fall through to standard path on error
298 }
299 }
300 }
301 }
302 }
303 }
304
305 // Step 2: Evaluate WHERE clause and collect rows to delete (two-phase execution)
306 // Get table for scanning
307 let table = database
308 .get_table(table_name)
309 .ok_or_else(|| ExecutorError::TableNotFound(stmt.table_name.clone()))?;
310
311 // Phase 1d follow-up (#5205): capture the active MVCC snapshot
312 // once so both the PK fast path and the WHERE-clause scan agree
313 // on visibility. Off-state collapses to the pre-MVCC live-row
314 // filter; the snapshot argument is ignored by the storage layer.
315 let snapshot = crate::mvcc::read_snapshot(database);
316
317 // Execute CTEs if present (WITH clause support)
318 let cte_results = if let Some(ref cte_list) = stmt.with_clause {
319 Some(crate::select::cte::execute_ctes(cte_list, database, |cte_query, prior_ctes| {
320 let cte_executor = crate::SelectExecutor::new_with_cte(database, prior_ctes);
321 cte_executor.execute(cte_query)
322 })?)
323 } else {
324 None
325 };
326
327 // Create evaluator with database reference for subquery support (EXISTS, NOT EXISTS, IN
328 // with subquery, etc.) and optional procedural/trigger context for variable resolution
329 let mut evaluator = if let Some(ctx) = trigger_context {
330 // Trigger context takes precedence (trigger statements can't have procedural context)
331 ExpressionEvaluator::with_trigger_context(&schema, database, ctx)
332 } else if let Some(ctx) = procedural_context {
333 ExpressionEvaluator::with_procedural_context(&schema, database, ctx)
334 } else if let Some(ref cte_ctx) = cte_results {
335 // Use CTE context for WITH clause support
336 ExpressionEvaluator::with_database_and_cte(&schema, database, cte_ctx)
337 } else {
338 ExpressionEvaluator::with_database(&schema, database)
339 };
340
341 // Check once if any DELETE triggers exist for this table (used for fast-path checks)
342 let has_delete_triggers = database
343 .catalog
344 .get_triggers_for_table(table_name, Some(vibesql_ast::TriggerEvent::Delete))
345 .next()
346 .is_some();
347
348 // Validate WHERE clause columns upfront (catches errors even on empty tables)
349 if let Some(vibesql_ast::WhereClause::Condition(where_expr)) = &stmt.where_clause {
350 Self::validate_where_columns(where_expr, &schema, table_name)?;
351 }
352
353 // Find rows to delete and their indices
354 // Try to use primary key index for fast lookup
355 let mut rows_and_indices_to_delete: Vec<(usize, vibesql_storage::Row)> = Vec::new();
356
357 // For ORDER BY in DELETE, we need to skip PK optimization and do a full scan
358 // because we need all matching rows to sort them properly
359 let has_order_by = stmt.order_by.is_some();
360
361 if !has_order_by {
362 if let Some(vibesql_ast::WhereClause::Condition(where_expr)) = &stmt.where_clause {
363 // Try primary key optimization
364 if let Some(pk_values) = Self::extract_primary_key_lookup(where_expr, &schema) {
365 // Coerce extracted WHERE-clause literals to match the PK
366 // column affinities. The PK index HashMap is keyed on
367 // stored (already-coerced) values, so a raw literal can
368 // silently miss when types differ — e.g. `WHERE p=1200` on
369 // a TEXT PRIMARY KEY storing "1200". See issue #5145.
370 let pk_values: Vec<SqlValue> =
371 if let Some(pk_indices) = schema.get_primary_key_indices() {
372 pk_values
373 .into_iter()
374 .zip(pk_indices.iter())
375 .map(|(val, &idx)| {
376 coerce_value_to_column_type(val, &schema.columns[idx].data_type)
377 })
378 .collect()
379 } else {
380 pk_values
381 };
382
383 if let Some(pk_index) = table.primary_key_index() {
384 if let Some(&row_index) = pk_index.get(&pk_values) {
385 // Phase 1d follow-up (#5205): the PK fast
386 // path must honor MVCC visibility — a row
387 // whose xmax is committed under our snapshot
388 // must not be picked for DELETE. Off-state
389 // collapses to a deletion-bitmap check.
390 if table.is_row_visible(row_index, &snapshot) {
391 rows_and_indices_to_delete
392 .push((row_index, table.scan()[row_index].clone()));
393 }
394 }
395 // If not found, rows_and_indices_to_delete stays empty (no rows to delete)
396 } else {
397 // No PK index, fall through to table scan below
398 Self::collect_rows_with_scan(
399 table,
400 &stmt.where_clause,
401 &mut evaluator,
402 &mut rows_and_indices_to_delete,
403 &snapshot,
404 )?;
405 }
406 } else {
407 // Can't extract PK lookup, fall through to table scan
408 Self::collect_rows_with_scan(
409 table,
410 &stmt.where_clause,
411 &mut evaluator,
412 &mut rows_and_indices_to_delete,
413 &snapshot,
414 )?;
415 }
416 } else {
417 // No WHERE clause - collect all rows
418 Self::collect_rows_with_scan(
419 table,
420 &stmt.where_clause,
421 &mut evaluator,
422 &mut rows_and_indices_to_delete,
423 &snapshot,
424 )?;
425 }
426 } else {
427 // ORDER BY present - must do full scan to get all rows for sorting
428 Self::collect_rows_with_scan(
429 table,
430 &stmt.where_clause,
431 &mut evaluator,
432 &mut rows_and_indices_to_delete,
433 &snapshot,
434 )?;
435 }
436
437 // Apply ORDER BY sorting and LIMIT/OFFSET (SQLite extension)
438 if let Some(ref order_by) = stmt.order_by {
439 Self::apply_order_by_and_limit(
440 &mut rows_and_indices_to_delete,
441 order_by,
442 &stmt.limit,
443 &stmt.offset,
444 &schema,
445 &evaluator,
446 )?;
447 }
448
449 // Cost-based optimization: Log delete cost and check for early compaction recommendation
450 let optimizer = DmlOptimizer::new(database, table_name);
451 if optimizer.should_chunk_delete(rows_and_indices_to_delete.len()) {
452 // Log recommendation for potential chunked delete (informational only)
453 // Actual chunked delete would require transaction support to be safe
454 if std::env::var("DML_COST_DEBUG").is_ok() {
455 eprintln!(
456 "DML_COST_DEBUG: DELETE on {} - {} rows qualifies for chunked delete",
457 stmt.table_name,
458 rows_and_indices_to_delete.len()
459 );
460 }
461 }
462 if optimizer.should_trigger_early_compaction() {
463 // Log early compaction recommendation (informational only)
464 // Table compaction is triggered automatically after >50% deleted rows
465 if std::env::var("DML_COST_DEBUG").is_ok() {
466 eprintln!(
467 "DML_COST_DEBUG: DELETE on {} - early compaction recommended due to high deleted ratio",
468 stmt.table_name
469 );
470 }
471 }
472
473 // Fire BEFORE STATEMENT triggers only if triggers exist AND we're not inside a trigger
474 // context (Statement-level triggers don't fire for deletes within trigger bodies)
475 if has_delete_triggers && trigger_context.is_none() {
476 // Statement-level RAISE(IGNORE) has no sqlite3 analog (SQLite
477 // triggers are always FOR EACH ROW); drop the must-use outcome and
478 // keep the pre-#5418 proceed behavior (#5418).
479 let _stmt_outcome = crate::TriggerFirer::execute_before_statement_triggers(
480 database,
481 table_name,
482 vibesql_ast::TriggerEvent::Delete,
483 )?;
484 }
485
486 // Steps 3-6: Fire BEFORE/AFTER DELETE ROW triggers and physically
487 // delete the rows.
488 //
489 // Issue #5486: SQLite fires row triggers INTERLEAVED per row — for each
490 // affected row R it runs the BEFORE trigger(s) on R, deletes R, then
491 // runs the AFTER trigger(s) on R, *before* moving to R+1. A trigger body
492 // that reads the table mid-statement (e.g. `SELECT sum(a) FROM tbl`)
493 // must therefore see exactly the rows deleted so far. The previous
494 // implementation fired all BEFOREs, then deleted every row, then all
495 // AFTERs, which made such triggers observe the wrong running state.
496 //
497 // When no DELETE triggers exist we keep the batched fast path (no
498 // observable difference, lower overhead). When triggers exist we
499 // interleave per row and defer compaction until the whole loop finishes
500 // (compacting mid-loop would shift the indices of the rows we have not
501 // yet processed).
502 //
503 // Compaction must additionally be deferred whenever an *ancestor*
504 // statement is currently iterating over this same table's physical row
505 // indices via an interleaved per-row DML loop (fkey2-4.3: a recursive
506 // AFTER DELETE trigger cascading through a tree). Compacting here would
507 // shift the ancestor's not-yet-processed indices. The ancestor's loop
508 // (which registered the table via `IterationGuard`) performs the single
509 // compaction once it finishes. A nested DELETE on a *different* table
510 // than any ancestor is iterating compacts normally.
511 let defer_compaction = crate::compaction_guard::is_iterating(table_name);
512 let deleted_count: usize;
513 if has_delete_triggers {
514 #[cfg(feature = "mvcc_enabled")]
515 let mvcc_delete_txn_id = database.transaction_id();
516
517 // Register this table as under interleaved iteration so nested
518 // DELETEs on it (fired by the row triggers below) defer their
519 // compaction to the `compact_if_needed` call after this loop.
520 let _iter_guard = crate::compaction_guard::IterationGuard::new(table_name);
521
522 let mut deleted = 0usize;
523 let mut applied_rows: Vec<(usize, vibesql_storage::Row)> = Vec::new();
524 let mut compacted = false;
525
526 for (idx, row) in rows_and_indices_to_delete.iter() {
527 // A trigger fired for an earlier row may have already deleted
528 // this row mid-statement (cascade / recursive delete, e.g.
529 // trigger1-1.10's `DELETE FROM t WHERE a = old.a + 2`). SQLite
530 // processes such a row only once, so skip it here — its BEFORE/
531 // AFTER triggers do not fire a second time.
532 if database
533 .get_table(table_name)
534 .map(|t| t.is_row_deleted(*idx))
535 .unwrap_or(true)
536 {
537 continue;
538 }
539
540 // Stamp the OLD row with its rowid so `OLD.rowid` (and the
541 // `oid` / `_rowid_` aliases) resolve in the trigger body (#5485).
542 // `scan()` returns the raw stored rows, which may not carry
543 // `row_id`; the physical index is the source of truth (rowid =
544 // idx + 1), and an explicit stored row_id is preserved.
545 let old_row_for_triggers = {
546 let mut r = row.clone();
547 if r.row_id.is_none() {
548 r.row_id = Some((*idx + 1) as u64);
549 }
550 r
551 };
552
553 // BEFORE(R): a RAISE(IGNORE) drops this row entirely (skip it,
554 // continue with the remaining rows).
555 let before_outcome = crate::TriggerFirer::execute_before_triggers(
556 database,
557 table_name,
558 vibesql_ast::TriggerEvent::Delete,
559 Some(&old_row_for_triggers),
560 None,
561 )?;
562 if before_outcome == crate::TriggerOutcome::SkipRow {
563 continue;
564 }
565
566 // Referential integrity for this row (CASCADE / SET NULL /
567 // SET DEFAULT / restrict) — runs as part of processing R, after
568 // its BEFORE trigger and before its physical deletion.
569 check_no_child_references(database, table_name, row)?;
570
571 // WAL + index maintenance for this single row (indices are still
572 // valid: we defer compaction to the end of the loop).
573 database.emit_wal_delete(table_name, *idx as u64, row.values.to_vec());
574 let rows_refs: Vec<(usize, &vibesql_storage::Row)> = vec![(*idx, row)];
575 database.batch_update_indexes_for_delete(table_name, &rows_refs);
576 expression_index_maintenance::maintain_expression_indexes_for_delete(
577 database, table_name, row, *idx,
578 );
579 partial_index_maintenance::maintain_partial_indexes_for_delete(
580 database, table_name, row, *idx,
581 );
582
583 // delete(R): flip the deletion bitmap for just this row so the
584 // AFTER trigger (and the BEFORE trigger of the next row) observes
585 // R as gone. Compaction is deferred (see compact_if_needed below).
586 {
587 let table_mut = database
588 .get_table_mut(table_name)
589 .ok_or_else(|| ExecutorError::TableNotFound(stmt.table_name.clone()))?;
590
591 #[cfg(feature = "mvcc_enabled")]
592 if let Some(id) = mvcc_delete_txn_id {
593 table_mut.stamp_row_xmax_inplace(*idx, id);
594 }
595
596 if table_mut.mark_deleted_inplace(*idx) {
597 deleted += 1;
598 applied_rows.push((*idx, row.clone()));
599 }
600 }
601
602 // Invalidate the database-level columnar cache NOW, between the
603 // bitmap delete and the AFTER trigger (#5523). For row-oriented
604 // tables the columnar aggregate path serves a trigger body's
605 // `SELECT count(*) FROM t` from this LRU-cached snapshot; left
606 // un-invalidated until the loop end, a combined BEFORE+AFTER
607 // DELETE trigger pair would make the AFTER trigger observe the
608 // *pre-delete* count (and the next row's BEFORE trigger a
609 // running count that never decreased). The bitmap/native-columnar
610 // state is already updated by `mark_deleted_inplace`; this drops
611 // the stale row-oriented snapshot so each phase sees the live
612 // count. (Native columnar tables short-circuit this call.)
613 database.invalidate_columnar_cache(table_name);
614
615 // AFTER(R): the row is already deleted; a RAISE(IGNORE) cannot
616 // un-delete it, so SkipRow is a no-op — drop the must-use
617 // outcome (#5418).
618 let _after_outcome = crate::TriggerFirer::execute_after_triggers(
619 database,
620 table_name,
621 vibesql_ast::TriggerEvent::Delete,
622 Some(&old_row_for_triggers),
623 None,
624 )?;
625 }
626
627 // Compact once, after all rows are processed (unless deferred to an
628 // ancestor statement). If it compacts, every row index changed and
629 // the user-defined / expression / partial indexes must be rebuilt.
630 if deleted > 0 {
631 if !defer_compaction {
632 if let Some(table_mut) = database.get_table_mut(table_name) {
633 compacted = table_mut.compact_if_needed();
634 }
635 if compacted {
636 database.rebuild_indexes(table_name);
637 expression_index_maintenance::rebuild_expression_indexes_after_compaction(
638 database, table_name,
639 );
640 partial_index_maintenance::rebuild_partial_indexes_after_compaction(
641 database, table_name,
642 );
643 }
644 }
645 database.invalidate_columnar_cache(table_name);
646 }
647
648 // Restrict the row set to those actually deleted so RETURNING and
649 // the deleted-count reflect SQLite semantics (BEFORE-IGNORE'd rows
650 // are excluded).
651 rows_and_indices_to_delete = applied_rows;
652 deleted_count = deleted;
653 } else {
654 // Fast path: no DELETE triggers — batch delete as before.
655
656 // Extract indices for deletion
657 let mut deleted_indices: Vec<usize> =
658 rows_and_indices_to_delete.iter().map(|(idx, _)| *idx).collect();
659 deleted_indices.sort_unstable();
660
661 // Step 4: Handle referential integrity for each row to be deleted
662 // This may CASCADE deletes, SET NULL, or SET DEFAULT in child tables
663 for (_, row) in &rows_and_indices_to_delete {
664 check_no_child_references(database, table_name, row)?;
665 }
666
667 // Step 5a: Emit WAL entries and remove entries from user-defined indexes
668 // BEFORE deleting rows (while row indices are still valid and we have old values)
669 // First emit WAL entries for each row (needed for recovery replay)
670 for (idx, row) in &rows_and_indices_to_delete {
671 database.emit_wal_delete(table_name, *idx as u64, row.values.to_vec());
672 }
673
674 // Then use batch method for index updates: O(d + m*log n) vs O(d*m*log n)
675 // where d=deletes, m=indexes
676 let rows_refs: Vec<(usize, &vibesql_storage::Row)> =
677 rows_and_indices_to_delete.iter().map(|(idx, row)| (*idx, row)).collect();
678 database.batch_update_indexes_for_delete(table_name, &rows_refs);
679
680 // Maintain expression indexes for each deleted row
681 for (row_index, row) in &rows_and_indices_to_delete {
682 expression_index_maintenance::maintain_expression_indexes_for_delete(
683 database, table_name, row, *row_index,
684 );
685 partial_index_maintenance::maintain_partial_indexes_for_delete(
686 database, table_name, row, *row_index,
687 );
688 }
689
690 // Phase 1c (Issue #5150 / #5136): stamp xmax on every row about
691 // to be bitmap-deleted with the active txn id when the
692 // `mvcc_enabled` feature is on. Fetched BEFORE the mutable borrow
693 // of `table_mut`.
694 #[cfg(feature = "mvcc_enabled")]
695 let mvcc_delete_txn_id = database.transaction_id();
696
697 // Step 5b: Actually delete the rows using fast path (no table scan needed)
698 let table_mut = database
699 .get_table_mut(table_name)
700 .ok_or_else(|| ExecutorError::TableNotFound(stmt.table_name.clone()))?;
701
702 // Stamp xmax in-place on each row BEFORE bitmap-deleting.
703 #[cfg(feature = "mvcc_enabled")]
704 if let Some(id) = mvcc_delete_txn_id {
705 for &idx in &deleted_indices {
706 table_mut.stamp_row_xmax_inplace(idx, id);
707 }
708 }
709
710 let delete_result = if defer_compaction {
711 // Nested inside a trigger body: bitmap-delete without compaction
712 // so the ancestor statement's physical row indices stay valid
713 // (the outermost DELETE compacts once at the end).
714 let mut deleted = 0usize;
715 for &idx in &deleted_indices {
716 if table_mut.mark_deleted_inplace(idx) {
717 deleted += 1;
718 }
719 }
720 vibesql_storage::DeleteResult::new(deleted, false)
721 } else {
722 table_mut.delete_by_indices_batch(&deleted_indices)
723 };
724
725 // If compaction occurred, rebuild user-defined indexes since all row indices changed
726 if delete_result.compacted {
727 database.rebuild_indexes(table_name);
728 // Expression indexes need special handling (expression evaluation)
729 expression_index_maintenance::rebuild_expression_indexes_after_compaction(
730 database, table_name,
731 );
732 // Partial indexes need WHERE-predicate evaluation per row
733 partial_index_maintenance::rebuild_partial_indexes_after_compaction(
734 database, table_name,
735 );
736 }
737
738 if delete_result.deleted_count > 0 {
739 database.invalidate_columnar_cache(table_name);
740 }
741
742 deleted_count = delete_result.deleted_count;
743 }
744
745 // Fire AFTER STATEMENT triggers only if triggers exist AND we're not inside a trigger
746 // context (Statement-level triggers don't fire for deletes within trigger bodies)
747 if has_delete_triggers && trigger_context.is_none() {
748 // Statement-level RAISE(IGNORE) has no sqlite3 analog; drop the
749 // must-use outcome (#5418).
750 let _stmt_outcome = crate::TriggerFirer::execute_after_statement_triggers(
751 database,
752 table_name,
753 vibesql_ast::TriggerEvent::Delete,
754 )?;
755 }
756
757 // Check all assertions after DELETE completes (SQL:1999 Feature F671/F672)
758 // This ensures database-wide integrity constraints are maintained
759 crate::advanced_objects::AssertionChecker::check_all_assertions(database)?;
760
761 // Project RETURNING items against the OLD rows (SQLite 3.35.0+).
762 // Rows are projected in collection order (ORDER BY/LIMIT already
763 // applied); zero deleted rows yields an empty result whose column
764 // names are still derived from the RETURNING items.
765 let returning = if let Some(items) = &stmt.returning {
766 let old_rows: Vec<&vibesql_storage::Row> =
767 rows_and_indices_to_delete.iter().map(|(_, row)| row).collect();
768 Some(crate::dml_returning::project_returning(
769 items,
770 &schema,
771 database,
772 None,
773 &old_rows,
774 cte_results.as_ref(),
775 )?)
776 } else {
777 None
778 };
779
780 Ok((deleted_count, returning))
781 }
782
783 /// Extract primary key value from WHERE expression if it's a simple equality
784 fn extract_primary_key_lookup(
785 where_expr: &vibesql_ast::Expression,
786 schema: &vibesql_catalog::TableSchema,
787 ) -> Option<Vec<vibesql_types::SqlValue>> {
788 use vibesql_ast::{BinaryOperator, Expression};
789
790 // Only handle simple binary equality operations
791 if let Expression::BinaryOp { left, op: BinaryOperator::Equal, right } = where_expr {
792 // Check if left side is a column reference and right side is a literal
793 if let (Expression::ColumnRef(col_id), Expression::Literal(value)) =
794 (left.as_ref(), right.as_ref())
795 {
796 // Check if this column is the primary key
797 let column = col_id.column_canonical();
798 if let Some(pk_indices) = schema.get_primary_key_indices() {
799 if let Some(col_index) = schema.get_column_index(column) {
800 // Only handle single-column primary keys for now
801 if pk_indices.len() == 1 && pk_indices[0] == col_index {
802 return Some(vec![value.clone()]);
803 }
804 }
805 }
806 }
807
808 // Also check the reverse: literal = column
809 if let (Expression::Literal(value), Expression::ColumnRef(col_id)) =
810 (left.as_ref(), right.as_ref())
811 {
812 let column = col_id.column_canonical();
813 if let Some(pk_indices) = schema.get_primary_key_indices() {
814 if let Some(col_index) = schema.get_column_index(column) {
815 if pk_indices.len() == 1 && pk_indices[0] == col_index {
816 return Some(vec![value.clone()]);
817 }
818 }
819 }
820 }
821 }
822
823 None
824 }
825
826 /// Validate that all column references in the WHERE clause exist in the schema
827 /// This catches errors like "DELETE FROM t WHERE nonexistent_col = 5" even on empty tables
828 #[allow(clippy::only_used_in_recursion)] // table_name preserved for future error messages
829 fn validate_where_columns(
830 expr: &vibesql_ast::Expression,
831 schema: &vibesql_catalog::TableSchema,
832 table_name: &str,
833 ) -> Result<(), ExecutorError> {
834 use vibesql_ast::Expression;
835
836 match expr {
837 Expression::ColumnRef(col_id) => {
838 let col_name = col_id.column_canonical();
839 // Check if column exists in schema (case-insensitive)
840 // Also allow ROWID pseudo-column aliases
841 let is_rowid = col_name.eq_ignore_ascii_case("rowid")
842 || col_name.eq_ignore_ascii_case("_rowid_")
843 || col_name.eq_ignore_ascii_case("oid");
844 if !is_rowid
845 && !schema.columns.iter().any(|c| c.name.eq_ignore_ascii_case(col_name))
846 {
847 return Err(ExecutorError::NoSuchColumn { column_ref: col_name.to_string() });
848 }
849 Ok(())
850 }
851 Expression::BinaryOp { left, right, .. } => {
852 Self::validate_where_columns(left, schema, table_name)?;
853 Self::validate_where_columns(right, schema, table_name)
854 }
855 Expression::UnaryOp { expr, .. } => {
856 Self::validate_where_columns(expr, schema, table_name)
857 }
858 Expression::IsNull { expr, .. } => {
859 Self::validate_where_columns(expr, schema, table_name)
860 }
861 Expression::Between { expr, low, high, .. } => {
862 Self::validate_where_columns(expr, schema, table_name)?;
863 Self::validate_where_columns(low, schema, table_name)?;
864 Self::validate_where_columns(high, schema, table_name)
865 }
866 Expression::InList { expr, values, .. } => {
867 Self::validate_where_columns(expr, schema, table_name)?;
868 for item in values {
869 Self::validate_where_columns(item, schema, table_name)?;
870 }
871 Ok(())
872 }
873 Expression::Function { args, .. } => {
874 for arg in args {
875 Self::validate_where_columns(arg, schema, table_name)?;
876 }
877 Ok(())
878 }
879 Expression::AggregateFunction { args, .. } => {
880 for arg in args {
881 Self::validate_where_columns(arg, schema, table_name)?;
882 }
883 Ok(())
884 }
885 Expression::Case { operand, when_clauses, else_result } => {
886 if let Some(op) = operand {
887 Self::validate_where_columns(op, schema, table_name)?;
888 }
889 for case_when in when_clauses {
890 for cond in &case_when.conditions {
891 Self::validate_where_columns(cond, schema, table_name)?;
892 }
893 Self::validate_where_columns(&case_when.result, schema, table_name)?;
894 }
895 if let Some(else_expr) = else_result {
896 Self::validate_where_columns(else_expr, schema, table_name)?;
897 }
898 Ok(())
899 }
900 // Literals and other expressions that don't reference columns
901 Expression::Literal(_)
902 | Expression::Wildcard
903 | Expression::Placeholder(_)
904 | Expression::NumberedPlaceholder(_)
905 | Expression::NamedPlaceholder(_)
906 | Expression::CurrentDate
907 | Expression::CurrentTime { .. }
908 | Expression::CurrentTimestamp { .. } => Ok(()),
909 // Subqueries have their own scope - don't validate against parent table
910 Expression::ScalarSubquery(_)
911 | Expression::In { .. }
912 | Expression::Exists { .. }
913 | Expression::QuantifiedComparison { .. } => Ok(()),
914 // Other expressions - recurse into children if any
915 _ => Ok(()),
916 }
917 }
918
919 /// Collect rows using table scan (fallback when PK optimization can't be used).
920 ///
921 /// Phase 1d follow-up (#5205): uses `scan_visible(snapshot)` so the
922 /// WHERE-clause scan honors MVCC visibility. Off-state reduces to
923 /// `scan_live` (deletion-bitmap filter), preserving pre-MVCC semantics.
924 fn collect_rows_with_scan(
925 table: &vibesql_storage::Table,
926 where_clause: &Option<vibesql_ast::WhereClause>,
927 evaluator: &mut ExpressionEvaluator,
928 rows_and_indices: &mut Vec<(usize, vibesql_storage::Row)>,
929 snapshot: &vibesql_storage::TxnSnapshot,
930 ) -> Result<(), ExecutorError> {
931 for (index, row) in table.scan_visible(snapshot) {
932 // Clear CSE cache before evaluating each row to prevent column values
933 // from being incorrectly cached across different rows
934 evaluator.clear_cse_cache();
935
936 // Set row_id for ROWID pseudo-column support (SQLite compatibility)
937 // SQLite uses 1-indexed rowids, so add 1 to the physical index
938 // This allows WHERE rowid = N to work correctly
939 // Use the row's explicit row_id if set, otherwise compute from physical index
940 let row_id = row.row_id.unwrap_or((index + 1) as u64);
941 evaluator.set_row_index(row_id);
942
943 let should_delete = if let Some(ref where_clause) = where_clause {
944 match where_clause {
945 vibesql_ast::WhereClause::Condition(where_expr) => {
946 // Propagate errors from eval() - don't silently swallow them
947 let result = evaluator.eval(where_expr, row)?;
948 // SQLite treats non-zero numeric values as TRUE
949 is_truthy(&result)
950 }
951 vibesql_ast::WhereClause::CurrentOf(_cursor_name) => {
952 return Err(ExecutorError::UnsupportedFeature(
953 "WHERE CURRENT OF cursor is not yet implemented".to_string(),
954 ));
955 }
956 }
957 } else {
958 true
959 };
960
961 if should_delete {
962 rows_and_indices.push((index, row.clone()));
963 }
964 }
965
966 Ok(())
967 }
968
969 /// Apply ORDER BY sorting and LIMIT/OFFSET to the collected rows
970 /// This implements the SQLite extension for DELETE with ORDER BY LIMIT
971 fn apply_order_by_and_limit(
972 rows_and_indices: &mut Vec<(usize, vibesql_storage::Row)>,
973 order_by: &[vibesql_ast::OrderByItem],
974 limit: &Option<vibesql_ast::Expression>,
975 offset: &Option<vibesql_ast::Expression>,
976 _schema: &vibesql_catalog::TableSchema,
977 evaluator: &ExpressionEvaluator,
978 ) -> Result<(), ExecutorError> {
979 use vibesql_ast::OrderDirection;
980 use vibesql_types::SqlValue;
981
982 // Sort rows by ORDER BY columns
983 rows_and_indices.sort_by(|a, b| {
984 for item in order_by {
985 // Evaluate the ORDER BY expression for both rows
986 let val_a = evaluator.eval(&item.expr, &a.1).unwrap_or(SqlValue::Null);
987 let val_b = evaluator.eval(&item.expr, &b.1).unwrap_or(SqlValue::Null);
988
989 // Compare values with proper NULL handling
990 // NULLS FIRST: nulls come first (default for DESC)
991 // NULLS LAST: nulls come last (default for ASC)
992 let nulls_first = match item.nulls_order {
993 Some(vibesql_ast::NullsOrder::First) => true,
994 Some(vibesql_ast::NullsOrder::Last) => false,
995 None => matches!(item.direction, OrderDirection::Desc),
996 };
997
998 let cmp = match (&val_a, &val_b) {
999 (SqlValue::Null, SqlValue::Null) => std::cmp::Ordering::Equal,
1000 (SqlValue::Null, _) => {
1001 if nulls_first {
1002 std::cmp::Ordering::Less
1003 } else {
1004 std::cmp::Ordering::Greater
1005 }
1006 }
1007 (_, SqlValue::Null) => {
1008 if nulls_first {
1009 std::cmp::Ordering::Greater
1010 } else {
1011 std::cmp::Ordering::Less
1012 }
1013 }
1014 _ => val_a.partial_cmp(&val_b).unwrap_or(std::cmp::Ordering::Equal),
1015 };
1016
1017 // Apply direction
1018 let cmp = match item.direction {
1019 OrderDirection::Desc => cmp.reverse(),
1020 OrderDirection::Asc => cmp,
1021 };
1022
1023 if cmp != std::cmp::Ordering::Equal {
1024 return cmp;
1025 }
1026 }
1027 std::cmp::Ordering::Equal
1028 });
1029
1030 // Evaluate OFFSET expression if present
1031 let offset_val = if let Some(ref offset_expr) = offset {
1032 // Evaluate the offset expression without a row context
1033 // (it should be a constant or simple expression)
1034 let empty_row = vibesql_storage::Row::new(vec![]);
1035 match evaluator.eval(offset_expr, &empty_row)? {
1036 SqlValue::Integer(n) if n >= 0 => n as usize,
1037 SqlValue::Bigint(n) if n >= 0 => n as usize,
1038 SqlValue::Null => 0, // NULL offset treated as 0
1039 _ => {
1040 return Err(ExecutorError::TypeError(
1041 "OFFSET value must be a non-negative integer".to_string(),
1042 ))
1043 }
1044 }
1045 } else {
1046 0
1047 };
1048
1049 // Evaluate LIMIT expression if present
1050 let limit_val = if let Some(ref limit_expr) = limit {
1051 let empty_row = vibesql_storage::Row::new(vec![]);
1052 match evaluator.eval(limit_expr, &empty_row)? {
1053 SqlValue::Integer(n) if n >= 0 => Some(n as usize),
1054 SqlValue::Bigint(n) if n >= 0 => Some(n as usize),
1055 SqlValue::Integer(-1) | SqlValue::Bigint(-1) => None, // -1 means no limit (SQLite extension)
1056 SqlValue::Null => None, // NULL limit treated as no limit
1057 _ => {
1058 return Err(ExecutorError::TypeError(
1059 "LIMIT value must be a non-negative integer".to_string(),
1060 ))
1061 }
1062 }
1063 } else {
1064 None
1065 };
1066
1067 // Apply OFFSET: skip first N rows
1068 if offset_val > 0 {
1069 if offset_val >= rows_and_indices.len() {
1070 rows_and_indices.clear();
1071 } else {
1072 rows_and_indices.drain(..offset_val);
1073 }
1074 }
1075
1076 // Apply LIMIT: keep only first N rows
1077 if let Some(limit) = limit_val {
1078 rows_and_indices.truncate(limit);
1079 }
1080
1081 Ok(())
1082 }
1083}
1084
1085/// Execute TRUNCATE-style fast path for DELETE FROM table (no WHERE)
1086///
1087/// Clears all rows and indexes in a single operation instead of row-by-row deletion.
1088/// Provides 100-1000x performance improvement for full table deletes.
1089///
1090/// # Safety
1091/// Only call this after `can_use_truncate` returns true.
1092fn execute_truncate(database: &mut Database, table_name: &str) -> Result<usize, ExecutorError> {
1093 let table = database
1094 .get_table_mut(table_name)
1095 .ok_or_else(|| ExecutorError::TableNotFound(table_name.to_string()))?;
1096
1097 let row_count = table.row_count();
1098
1099 // Clear all data at once (O(1) operation)
1100 // Note: table.clear() invalidates the table-level columnar cache internally
1101 table.clear();
1102
1103 // Rebuild user-defined indexes (clears them, since the table is now empty).
1104 // Without this, database-level index data retains the deleted rows' keys
1105 // and subsequent INSERTs fail with spurious UNIQUE constraint errors
1106 // (upsert1-710/740/770). The TRUNCATE TABLE executor does the same
1107 // (see truncate/core.rs::execute_truncate).
1108 database.rebuild_indexes(table_name);
1109
1110 // Invalidate the database-level columnar cache since table data changed.
1111 // Both the table-level (via clear()) and database-level invalidations are
1112 // necessary because they manage separate caches at different levels.
1113 if row_count > 0 {
1114 database.invalidate_columnar_cache(table_name);
1115 }
1116
1117 // Check all assertions after DELETE completes (SQL:1999 Feature F671/F672)
1118 // This ensures database-wide integrity constraints are maintained
1119 crate::advanced_objects::AssertionChecker::check_all_assertions(database)?;
1120
1121 Ok(row_count)
1122}
1123
1124/// Execute a DELETE statement with trigger context
1125/// This function is used when executing DELETE statements within trigger bodies
1126/// to support OLD/NEW pseudo-variable references
1127pub fn execute_delete_with_trigger_context(
1128 database: &mut Database,
1129 stmt: &DeleteStmt,
1130 trigger_context: &crate::trigger_execution::TriggerContext,
1131) -> Result<usize, ExecutorError> {
1132 DeleteExecutor::execute_with_trigger_context(stmt, database, trigger_context)
1133}
1134
1135/// Execute DELETE on a VIEW using INSTEAD OF triggers
1136///
1137/// When deleting from a view, we need to fire INSTEAD OF DELETE triggers
1138/// instead of actually deleting data. The triggers typically delete from
1139/// the underlying tables.
1140fn execute_delete_on_view(
1141 database: &mut Database,
1142 stmt: &DeleteStmt,
1143 view_def: &vibesql_catalog::ViewDefinition,
1144 procedural_context: Option<&crate::procedural::ExecutionContext>,
1145 trigger_context: Option<&crate::trigger_execution::TriggerContext>,
1146) -> Result<(usize, Option<crate::select::SelectResult>), ExecutorError> {
1147 use vibesql_ast::TriggerTiming;
1148
1149 // Find INSTEAD OF DELETE triggers for this view
1150 let triggers = crate::TriggerFirer::find_triggers(
1151 database,
1152 &view_def.name,
1153 TriggerTiming::InsteadOf,
1154 vibesql_ast::TriggerEvent::Delete,
1155 );
1156
1157 if triggers.is_empty() {
1158 return Err(ExecutorError::UnsupportedExpression(format!(
1159 "Cannot DELETE from view '{}' without INSTEAD OF trigger",
1160 view_def.name
1161 )));
1162 }
1163
1164 // Build a pseudo-schema for the view
1165 let view_schema = build_view_schema(database, view_def)?;
1166
1167 // Execute the view query to get the rows to potentially delete
1168 let select_executor = crate::SelectExecutor::new(database);
1169 let all_rows = select_executor.execute_with_columns(&view_def.query)?;
1170
1171 // Collect rows to delete first, before firing triggers
1172 // This avoids borrow conflicts with the evaluator
1173 let rows_to_delete: Vec<vibesql_storage::Row> = {
1174 // Create evaluator for WHERE clause (if any)
1175 let evaluator = if let Some(ctx) = trigger_context {
1176 ExpressionEvaluator::with_trigger_context(&view_schema, database, ctx)
1177 } else if let Some(ctx) = procedural_context {
1178 ExpressionEvaluator::with_procedural_context(&view_schema, database, ctx)
1179 } else {
1180 ExpressionEvaluator::with_database(&view_schema, database)
1181 };
1182
1183 // Select rows matching WHERE clause
1184 let mut collected_rows = Vec::new();
1185 for row in &all_rows.rows {
1186 let matches = match &stmt.where_clause {
1187 Some(vibesql_ast::WhereClause::Condition(expr)) => {
1188 let result = evaluator.eval(expr, row)?;
1189 // SQLite treats non-zero numeric values as TRUE
1190 is_truthy(&result)
1191 }
1192 None => true, // No WHERE clause - delete all rows
1193 Some(vibesql_ast::WhereClause::CurrentOf(_)) => {
1194 return Err(ExecutorError::UnsupportedExpression(
1195 "CURRENT OF not supported for view deletes".to_string(),
1196 ));
1197 }
1198 };
1199
1200 if matches {
1201 collected_rows.push(row.clone());
1202 }
1203 }
1204 collected_rows
1205 }; // evaluator dropped here
1206
1207 // Now fire triggers (database can be mutably borrowed).
1208 //
1209 // RAISE(IGNORE) inside an INSTEAD OF DELETE trigger abandons the view
1210 // operation for that row (#5418): the trigger body stops at the RAISE
1211 // (`execute_trigger` returns SkipRow) and we move on to the next row. On
1212 // SkipRow we `break` out of this row's remaining INSTEAD OF triggers,
1213 // following the first-SkipRow-wins convention of the primary DML loops
1214 // (#5415). The common single-trigger case matches sqlite3 3.51 exactly —
1215 // the row's operation is skipped.
1216 let rows_processed = rows_to_delete.len();
1217 for old_row in &rows_to_delete {
1218 for trigger in &triggers {
1219 if crate::TriggerFirer::execute_trigger(database, trigger, Some(old_row), None)?
1220 == crate::TriggerOutcome::SkipRow
1221 {
1222 break;
1223 }
1224 }
1225 }
1226
1227 // Project RETURNING items against the OLD view rows (SQLite 3.35.0+):
1228 // the OLD view row is returned once per trigger fire, regardless of
1229 // what the trigger body actually does.
1230 let returning = if let Some(items) = &stmt.returning {
1231 let old_rows: Vec<&vibesql_storage::Row> = rows_to_delete.iter().collect();
1232 Some(crate::dml_returning::project_returning(
1233 items,
1234 &view_schema,
1235 database,
1236 None,
1237 &old_rows,
1238 None,
1239 )?)
1240 } else {
1241 None
1242 };
1243
1244 Ok((rows_processed, returning))
1245}
1246
1247/// Build a pseudo TableSchema from a view definition
1248fn build_view_schema(
1249 database: &Database,
1250 view_def: &vibesql_catalog::ViewDefinition,
1251) -> Result<vibesql_catalog::TableSchema, ExecutorError> {
1252 // Execute the view's SELECT query to get column names. A table referenced by
1253 // the view body that has since been dropped surfaces here; for a main-schema
1254 // view sqlite3 reports it schema-qualified (`no such table: main.test2`)
1255 // because the view body resolves against the database schema, so qualify the
1256 // bare name. A temp view's body resolves against the temp schema, which
1257 // sqlite3 reports unqualified, so skip the qualifier there. This mirrors the
1258 // read-path guard in select/scan/table.rs (#5584) for read/write parity.
1259 let select_executor = crate::SelectExecutor::new(database);
1260 let select_result = select_executor.execute_with_columns(&view_def.query);
1261 let result = if view_def.is_temp() {
1262 select_result?
1263 } else {
1264 select_result.map_err(ExecutorError::with_main_schema_qualifier)?
1265 };
1266
1267 // Use explicit column names if provided, otherwise derive from SELECT
1268 let column_names: Vec<String> =
1269 if let Some(ref cols) = view_def.columns { cols.clone() } else { result.columns.clone() };
1270
1271 // Build columns with NONE affinity (DataType::Null), mirroring the
1272 // UPDATE-on-view pseudo-schema (#5233/#5260). The pseudo-schema mainly
1273 // provides column names for trigger binding, but its data types feed
1274 // SQLite affinity rules during WHERE evaluation: declaring the columns
1275 // as Varchar gave them TEXT affinity, which converted numeric literals
1276 // to text and made comparisons like `WHERE b=4` never match. NONE
1277 // affinity compares values by their actual types, matching bare
1278 // (undeclared) columns in SQLite.
1279 let columns: Vec<vibesql_catalog::ColumnSchema> = column_names
1280 .into_iter()
1281 .map(|name| vibesql_catalog::ColumnSchema::new(name, vibesql_types::DataType::Null, true))
1282 .collect();
1283
1284 // Mark this as a VIEW pseudo-schema: views have no implicit rowid, so
1285 // `rowid`/`oid`/`_rowid_` references against the view must error rather than
1286 // silently resolve (#5492).
1287 let mut schema = vibesql_catalog::TableSchema::new(view_def.name.clone(), columns);
1288 schema.set_is_view(true);
1289 Ok(schema)
1290}
1291
1292/// Check if a SqlValue is truthy using SQLite truthiness rules.
1293///
1294/// Delegates to the shared helper in `crate::evaluator::operators` so DELETE
1295/// and UPDATE-on-view row selection use identical truthiness semantics.
1296fn is_truthy(value: &SqlValue) -> bool {
1297 crate::evaluator::operators::is_truthy(value)
1298}