Skip to main content

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}