Skip to main content

vibesql_executor/insert/
execution.rs

1use vibesql_catalog::TableIdentifier;
2use vibesql_storage::statistics::CostEstimator;
3
4use crate::{
5    dml_cost::DmlOptimizer, errors::ExecutorError, expression_index_maintenance,
6    partial_index_maintenance, privilege_checker::PrivilegeChecker,
7    sqlite_schema::is_sqlite_schema_table, sqlite_stat::is_sqlite_stat1_table,
8};
9
10/// Outcome of an INSERT statement execution.
11#[derive(Debug)]
12pub struct InsertOutcome {
13    /// Total affected rows: direct inserts plus rows taken through the
14    /// `ON CONFLICT DO UPDATE` arm. Matches SQLite's `changes()`.
15    pub affected_rows: usize,
16    /// Rows handled via the `ON CONFLICT DO UPDATE` arm (subset of
17    /// `affected_rows`). SQLite's `PRAGMA count_changes` reports
18    /// `affected_rows - upsert_updated_rows` for INSERT (direct inserts
19    /// only), while `changes()` includes the update-arm rows.
20    pub upsert_updated_rows: usize,
21    /// Projected RETURNING rows when the statement carries a RETURNING
22    /// clause; `None` otherwise.
23    pub returning: Option<crate::select::SelectResult>,
24}
25
26/// Execute an INSERT statement
27/// Returns number of rows inserted
28pub fn execute_insert(
29    db: &mut vibesql_storage::Database,
30    stmt: &vibesql_ast::InsertStmt,
31) -> Result<usize, ExecutorError> {
32    execute_insert_internal(db, stmt, None, None).map(|outcome| outcome.affected_rows)
33}
34
35/// Execute an INSERT statement, capturing RETURNING rows (SQLite 3.35.0+)
36///
37/// Returns an [`InsertOutcome`] carrying the number of affected rows, the
38/// number of rows handled via the upsert `DO UPDATE` arm, and, when the
39/// statement carries a RETURNING clause, the projected NEW rows (values as
40/// actually inserted, including defaults, generated columns, and auto
41/// INTEGER PRIMARY KEY) — one per affected row, in insertion order. Rows
42/// skipped by `OR IGNORE` / `ON CONFLICT DO NOTHING` do not appear. For
43/// `ON DUPLICATE KEY UPDATE`, the post-UPDATE row is returned.
44pub fn execute_insert_returning(
45    db: &mut vibesql_storage::Database,
46    stmt: &vibesql_ast::InsertStmt,
47) -> Result<InsertOutcome, ExecutorError> {
48    execute_insert_internal(db, stmt, None, None)
49}
50
51/// Execute an INSERT statement with procedural context
52/// Returns number of rows inserted
53pub fn execute_insert_with_procedural_context(
54    db: &mut vibesql_storage::Database,
55    stmt: &vibesql_ast::InsertStmt,
56    procedural_context: &crate::procedural::ExecutionContext,
57) -> Result<usize, ExecutorError> {
58    execute_insert_internal(db, stmt, Some(procedural_context), None)
59        .map(|outcome| outcome.affected_rows)
60}
61
62/// Execute an INSERT statement with trigger context
63/// This allows INSERT statements within trigger bodies to reference OLD/NEW pseudo-variables
64/// Returns number of rows inserted
65pub fn execute_insert_with_trigger_context(
66    db: &mut vibesql_storage::Database,
67    stmt: &vibesql_ast::InsertStmt,
68    trigger_context: &crate::trigger_execution::TriggerContext,
69) -> Result<usize, ExecutorError> {
70    execute_insert_internal(db, stmt, None, Some(trigger_context))
71        .map(|outcome| outcome.affected_rows)
72}
73
74/// Result of applying SQLite rowid (INTEGER) affinity to a value supplied for
75/// the rowid / INTEGER PRIMARY KEY position on INSERT.
76enum RowidAffinity {
77    /// The value coerced to a concrete integer rowid.
78    Value(i64),
79    /// The value was NULL (or DEFAULT) — auto-assign the next rowid.
80    Auto,
81}
82
83/// Apply SQLite's rowid (INTEGER) affinity to an already-evaluated value.
84///
85/// SQLite coerces the value supplied for a rowid / INTEGER PRIMARY KEY into an
86/// integer before storing it (and before triggers observe `NEW.rowid`):
87/// - integers pass through;
88/// - a TEXT value that is losslessly an integer (e.g. `'45'`) is coerced;
89/// - a REAL value that is losslessly an integer (e.g. `45.0`, `-42.0`) is
90///   coerced; a value with a fractional part (e.g. `42.4`) cannot be a rowid
91///   and raises `datatype mismatch`;
92/// - NULL means auto-assign;
93/// - anything else (non-numeric TEXT, BLOB, fractional REAL) raises
94///   `datatype mismatch`, matching sqlite3 3.51.
95///
96/// sqlite3 3.51.0 accepts any integer rowid, including zero and negatives
97/// (`INSERT INTO t(rowid) VALUES(-42.0)` stores rowid -42; triggerC-4.1.4), so
98/// no positivity restriction is applied — only the affinity coercion and the
99/// lossless-integer check.
100fn coerce_rowid_affinity(
101    value: &vibesql_types::SqlValue,
102) -> Result<RowidAffinity, ExecutorError> {
103    use vibesql_types::SqlValue;
104
105    let datatype_mismatch =
106        || ExecutorError::UnsupportedExpression("datatype mismatch".to_string());
107
108    // Coerce a lossless-integer f64 to i64, else datatype mismatch.
109    let from_float = |f: f64| -> Result<i64, ExecutorError> {
110        if f.fract() == 0.0 && f >= i64::MIN as f64 && f <= i64::MAX as f64 {
111            Ok(f as i64)
112        } else {
113            Err(datatype_mismatch())
114        }
115    };
116
117    let i = match value {
118        SqlValue::Null => return Ok(RowidAffinity::Auto),
119        SqlValue::Integer(i) => *i,
120        SqlValue::Bigint(i) => *i,
121        SqlValue::Smallint(i) => *i as i64,
122        SqlValue::Varchar(s) | SqlValue::Character(s) => {
123            let trimmed = s.trim();
124            if let Ok(i) = trimmed.parse::<i64>() {
125                i
126            } else if let Ok(f) = trimmed.parse::<f64>() {
127                from_float(f)?
128            } else {
129                return Err(datatype_mismatch());
130            }
131        }
132        SqlValue::Float(f) => from_float(*f as f64)?,
133        SqlValue::Real(f) | SqlValue::Double(f) | SqlValue::Numeric(f) => from_float(*f)?,
134        _ => return Err(datatype_mismatch()),
135    };
136
137    Ok(RowidAffinity::Value(i))
138}
139
140/// Internal implementation of INSERT execution
141fn execute_insert_internal(
142    db: &mut vibesql_storage::Database,
143    stmt: &vibesql_ast::InsertStmt,
144    procedural_context: Option<&crate::procedural::ExecutionContext>,
145    trigger_context: Option<&crate::trigger_execution::TriggerContext>,
146) -> Result<InsertOutcome, ExecutorError> {
147    // Build full table name for error messages and privilege checks
148    let full_table_name = match &stmt.schema_name {
149        Some(schema) => format!("{}.{}", schema, stmt.table_name),
150        None => stmt.table_name.clone(),
151    };
152
153    // Check if target is sqlite_master/sqlite_schema (read-only system table)
154    if is_sqlite_schema_table(&stmt.table_name) {
155        return Err(ExecutorError::SqliteSystemTableReadOnly {
156            table_name: stmt.table_name.clone(),
157            operation: "modified".to_string(),
158        });
159    }
160
161    // Check if target is sqlite_stat1 (special writable statistics table)
162    // RETURNING is not supported on this virtual statistics table.
163    if is_sqlite_stat1_table(&stmt.table_name) {
164        return execute_insert_sqlite_stat1(db, stmt).map(|count| InsertOutcome {
165            affected_rows: count,
166            upsert_updated_rows: 0,
167            returning: None,
168        });
169    }
170
171    // Check INSERT privilege on the table
172    PrivilegeChecker::check_insert(db, &full_table_name)?;
173
174    // Materialize statement-level WITH-clause CTEs once, up front, so they
175    // are visible to subqueries in VALUES rows, the INSERT ... SELECT
176    // source, the upsert DO UPDATE arm, and RETURNING expressions —
177    // matching SQLite semantics (issue #5359). CTE names shadow same-named
178    // catalog tables/views and resolve ASCII case-insensitively (#5350).
179    let cte_results: Option<std::collections::HashMap<String, crate::select::cte::CteResult>> =
180        if let Some(ref cte_list) = stmt.with_clause {
181            Some(crate::select::cte::execute_ctes(cte_list, db, |cte_query, prior_ctes| {
182                let cte_executor = crate::SelectExecutor::new_with_cte(db, prior_ctes);
183                cte_executor
184                    .execute_with_columns(cte_query)
185                    .map(|result| result.rows.into_iter().collect())
186            })?)
187        } else {
188            None
189        };
190
191    // Check if target is a VIEW with INSTEAD OF triggers
192    if let Some(view_def) = db.catalog.get_view(&stmt.table_name).cloned() {
193        // SQLite: the upsert syntax cannot target a view, even when INSTEAD
194        // OF INSERT triggers exist (upsert1-910).
195        if stmt.on_conflict.is_some() {
196            return Err(ExecutorError::SqliteCompatError("cannot UPSERT a view".to_string()));
197        }
198        return execute_insert_on_view(
199            db,
200            stmt,
201            &view_def,
202            procedural_context,
203            trigger_context,
204            cte_results.as_ref(),
205        )
206        .map(|(count, returning)| InsertOutcome {
207            affected_rows: count,
208            upsert_updated_rows: 0,
209            returning,
210        });
211    }
212
213    // Get table schema from catalog (clone to avoid borrow issues)
214    // Use TableIdentifier for SQL:1999 case-sensitive lookups when quoted
215    // For schema-qualified names, use TableIdentifier::qualified to preserve
216    // the individual quoted status of schema and table parts
217    let table_id = match &stmt.schema_name {
218        Some(schema_name) => TableIdentifier::qualified(
219            schema_name,
220            stmt.schema_quoted,
221            &stmt.table_name,
222            stmt.table_quoted,
223        ),
224        None => TableIdentifier::new(&stmt.table_name, stmt.table_quoted),
225    };
226    let schema = db
227        .catalog
228        .get_table_by_identifier(&table_id)
229        .ok_or_else(|| ExecutorError::TableNotFound(full_table_name.clone()))?
230        .clone();
231
232    // Use canonical table name from identifier for storage layer operations
233    // For schema-qualified inserts (e.g., INSERT INTO "mySchema"."users"), this produces
234    // the full qualified name so storage looks up in the correct schema.
235    // For unqualified inserts (e.g., INSERT INTO tab1), this produces just the table name -
236    // the storage layer's fallback logic will find it and this matches how indexes
237    // are registered (with unqualified table names).
238    let storage_table_name = table_id.canonical().to_string();
239
240    // Use the schema's table name for catalog operations (matches how table was created)
241    let table_name = &schema.name;
242
243    // Schema-aware trigger firing (triggerD-3.1/3.2): resolve which schema this
244    // INSERT's target table actually lives in (temp shadows main for unqualified
245    // names; an explicit `main.`/`temp.` qualifier pins the schema). Only triggers
246    // bound to this schema will fire, so a `main` trigger does not fire on a
247    // same-named `temp` table and vice versa. `None` => could not resolve, in
248    // which case firing falls back to the legacy schema-unaware match.
249    let dml_schema: Option<String> = db.catalog.resolve_table_schema_name(&full_table_name);
250
251    // Validate an explicit ON CONFLICT (cols) target up front. SQLite does
252    // this at prepare time, even when no row actually conflicts: unknown
253    // columns raise "no such column" and known columns without a matching
254    // PRIMARY KEY / UNIQUE constraint / unique index raise the canonical
255    // "ON CONFLICT clause does not match..." error (upsert1-110/120/300).
256    if let Some(ref on_conflict) = stmt.on_conflict {
257        // Targets the AST cannot represent exactly (currently non-BINARY
258        // COLLATE) never match (upsert1-130; see issue #5269).
259        if on_conflict.target_inexact {
260            return Err(ExecutorError::SqliteCompatError(
261                "ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint"
262                    .to_string(),
263            ));
264        }
265        if let Some(ref target) = on_conflict.conflict_target {
266            super::on_conflict_update::validate_conflict_target(
267                db,
268                table_name,
269                &schema,
270                target,
271                on_conflict.target_where.as_ref(),
272            )?;
273        }
274    }
275
276    // Determine target column indices and types, including rowid pseudo-column support
277    let resolved_columns =
278        super::validation::resolve_target_columns_with_rowid(&schema, table_name, &stmt.columns)?;
279    let target_column_info = &resolved_columns.columns;
280    let rowid_position = resolved_columns.rowid_position;
281
282    // Get the rows to insert based on the source
283    let rows_to_insert = match &stmt.source {
284        vibesql_ast::InsertSource::Values(values) => {
285            // For VALUES, we already have the rows as expressions
286            values.clone()
287        }
288        vibesql_ast::InsertSource::DefaultValues => {
289            // For DEFAULT VALUES, insert a single row with DEFAULT for all columns
290            // The number of expressions must match target_column_info.len()
291            // Each Expression::Default will be evaluated to the column's default value
292            let default_row = vec![vibesql_ast::Expression::Default; target_column_info.len()];
293            vec![default_row]
294        }
295        vibesql_ast::InsertSource::Select(select_stmt) => {
296            // Try bulk transfer optimization first (Phase 1-3)
297            // This provides 10-50x performance improvement for compatible schemas
298            // Note: bulk transfer doesn't support CTEs, so skip if with_clause is present.
299            // RETURNING also gates this fast path: it returns early with only a
300            // count, but RETURNING must project the actually-inserted NEW rows.
301            if stmt.columns.is_empty() && stmt.with_clause.is_none() && stmt.returning.is_none() {
302                // Only attempt bulk transfer for INSERT INTO table SELECT (no column list)
303                if let Some(count) =
304                    super::bulk_transfer::try_bulk_transfer(db, table_name, select_stmt)?
305                {
306                    // Fast path succeeded, return early
307                    return Ok(InsertOutcome {
308                        affected_rows: count,
309                        upsert_updated_rows: 0,
310                        returning: None,
311                    });
312                }
313            }
314
315            // Fall back to normal path: execute SELECT and convert to expressions
316            // If we have a with_clause (CTEs), reuse the results materialized above.
317            //
318            // When this INSERT runs inside a trigger body and the source is a
319            // from-less SELECT that references OLD/NEW (e.g.
320            // `INSERT INTO log SELECT OLD.a || ',' || OLD.b;`, trigger5-1.1),
321            // the SELECT needs the firing row's pseudo-variable context — the
322            // same context the WHEN clause and body DML statements already get.
323            // Without it the evaluator rejects the OLD/NEW column references
324            // with "Column reference requires FROM clause" (#5470).
325            let select_result = if let Some(ref ctes) = cte_results {
326                // Create executor with CTE results
327                let select_executor = crate::SelectExecutor::new_with_cte(db, ctes);
328                select_executor.execute_with_columns(select_stmt)?
329            } else if let Some(ctx) = trigger_context {
330                let select_executor = crate::SelectExecutor::new_with_trigger_context(db, ctx);
331                select_executor.execute_with_columns(select_stmt)?
332            } else {
333                let select_executor = crate::SelectExecutor::new(db);
334                select_executor.execute_with_columns(select_stmt)?
335            };
336
337            // Validate column count - if rowid is a pseudo-column (not INTEGER PRIMARY KEY),
338            // SELECT should return one extra column for the rowid value.
339            // For INTEGER PRIMARY KEY columns, the rowid value is already part of target_column_info.
340            let expected_select_columns = if resolved_columns.rowid_is_pseudo_column {
341                target_column_info.len() + 1
342            } else {
343                target_column_info.len()
344            };
345            if select_result.columns.len() != expected_select_columns {
346                return Err(ExecutorError::InsertColumnCountMismatch {
347                    table_name: table_name.to_string(),
348                    expected: expected_select_columns,
349                    provided: select_result.columns.len(),
350                    has_explicit_columns: !stmt.columns.is_empty(),
351                });
352            }
353
354            // Convert SelectResult to Vec<Vec<Expression>> format
355            // Each row becomes a Vec<Expression> with literals
356            select_result
357                .rows
358                .into_iter()
359                .map(|row| row.values.into_iter().map(vibesql_ast::Expression::Literal).collect())
360                .collect()
361        }
362    };
363
364    // Validate each row has correct number of values
365    // If a pseudo-column rowid is specified (rowid, _rowid_, oid), the expected count
366    // includes the rowid column. If rowid comes from an INTEGER PRIMARY KEY column,
367    // it's already in target_column_info.
368    let expected_value_count = if resolved_columns.rowid_is_pseudo_column {
369        target_column_info.len() + 1
370    } else {
371        target_column_info.len()
372    };
373    super::validation::validate_row_column_counts(
374        &rows_to_insert,
375        expected_value_count,
376        table_name,
377        !stmt.columns.is_empty(),
378    )?;
379
380    // Estimate DML cost for query analysis and optimization decisions
381    // This helps with profiling and can inform future batch size decisions
382    if std::env::var("DML_COST_DEBUG").is_ok() {
383        if let Some(index_info) = db.get_table_index_info(&storage_table_name) {
384            // Get table statistics for cost estimation (use cached if available, or fallback to
385            // estimate)
386            if let Some(table) = db.get_table(&storage_table_name) {
387                let table_stats = table.get_statistics().cloned().unwrap_or_else(|| {
388                    vibesql_storage::TableStatistics::estimate_from_row_count(table.row_count())
389                });
390                let cost_estimator = CostEstimator::default();
391                let estimated_cost =
392                    cost_estimator.estimate_insert(rows_to_insert.len(), &table_stats, &index_info);
393                eprintln!(
394                    "DML_COST_DEBUG: INSERT {} rows into {} - estimated_cost: {:.2} (hash_indexes: {}, btree_indexes: {}, columnar: {})",
395                    rows_to_insert.len(),
396                    stmt.table_name,
397                    estimated_cost,
398                    index_info.hash_index_count,
399                    index_info.btree_index_count,
400                    index_info.is_native_columnar
401                );
402            }
403        }
404    }
405
406    // For multi-row INSERT, validate all rows first, then insert all
407    // This ensures atomicity: all rows succeed or all fail (unless IGNORE is used)
408    let mut validated_rows: Vec<(Vec<vibesql_types::SqlValue>, Option<u64>)> = Vec::new();
409    let mut primary_key_values: Vec<Vec<vibesql_types::SqlValue>> = Vec::new(); // Track PK values for duplicate checking within batch
410    let mut unique_constraint_values = if schema.get_unique_constraint_indices().is_empty() {
411        Vec::new()
412    } else {
413        vec![Vec::new(); schema.get_unique_constraint_indices().len()]
414    }; // Track UNIQUE values for each constraint
415    // Track previously-validated full rows for self-referential FK lookups
416    // (fkey1-5.1: `INSERT INTO t VALUES (1,NULL),(2,1),(3,2)` where t.parent
417    // references t.x — row N must find its parent among rows 0..N-1).
418    let mut batch_full_rows: Vec<Vec<vibesql_types::SqlValue>> = Vec::new();
419
420    // Check if IGNORE conflict clause is set - if so, skip rows with constraint violations
421    // Also treat ON CONFLICT ... DO NOTHING as equivalent to IGNORE
422    let or_ignore = matches!(stmt.conflict_clause, Some(vibesql_ast::ConflictClause::Ignore));
423    let use_ignore = or_ignore
424        || matches!(
425            &stmt.on_conflict,
426            Some(vibesql_ast::OnConflictClause {
427                action: vibesql_ast::OnConflictAction::DoNothing,
428                ..
429            })
430        );
431
432    // `ON CONFLICT (cols) [WHERE ...] DO NOTHING` with an explicit target
433    // (and without INSERT OR IGNORE) only suppresses conflicts on the
434    // *targeted* constraint; conflicts on other constraints surface as
435    // normal UNIQUE errors (upsert1-201). Untargeted DO NOTHING and
436    // INSERT OR IGNORE keep the suppress-everything behavior.
437    let do_nothing_target: Option<(
438        &[vibesql_ast::ConflictTargetItem],
439        Option<&vibesql_ast::Expression>,
440    )> = match &stmt.on_conflict {
441        Some(vibesql_ast::OnConflictClause {
442            conflict_target: Some(items),
443            target_where,
444            action: vibesql_ast::OnConflictAction::DoNothing,
445            ..
446        }) if !or_ignore => Some((items.as_slice(), target_where.as_ref())),
447        _ => None,
448    };
449
450    // Track the first auto-generated ID for LAST_INSERT_ROWID() support
451    // Per MySQL semantics, for multi-row inserts, LAST_INSERT_ID() returns
452    // the first auto-generated value, not the last
453    let mut first_generated_id: Option<i64> = None;
454
455    // Track the maximum INTEGER PRIMARY KEY value assigned within this batch
456    // to handle multi-row INSERTs with NULL values correctly (SQLite semantics)
457    let mut batch_max_ipk: Option<i64> = None;
458
459    // Get INTEGER PRIMARY KEY column index if present
460    let ipk_col_idx = schema.get_integer_primary_key_index();
461
462    // Get table's current max rowid for auto-assignment (SQLite semantics)
463    // When inserting with explicit rowid column, NULL rowids should be assigned values
464    // greater than both:
465    // 1. The table's current max rowid
466    // 2. Explicit rowids processed so far in the batch (NOT future rows)
467    let table_max_rowid =
468        db.get_table(&storage_table_name).map(|t| t.row_count() as u64).unwrap_or(0);
469
470    // Track maximum rowid seen so far (updated as we process each row)
471    let mut batch_max_rowid = table_max_rowid;
472
473    for value_exprs in &rows_to_insert {
474        // Build a complete row with values for all columns
475        // Start with NULL for all columns, then fill in provided values
476        let mut full_row_values = vec![vibesql_types::SqlValue::Null; schema.columns.len()];
477
478        // Extract rowid value if present (SQLite compatibility)
479        // For NULL rowids, auto-assign using batch_max_rowid + 1
480        let explicit_rowid = if let Some(rowid_pos) = rowid_position {
481            // Get the rowid expression
482            let rowid_expr = &value_exprs[rowid_pos];
483
484            // Apply a coerced rowid affinity result, updating batch_max_rowid
485            // for subsequent NULL/DEFAULT auto-assignments. SQLite's INTEGER
486            // affinity is applied to the rowid value before storage (and before
487            // triggers observe NEW.rowid): a TEXT/REAL value that is losslessly
488            // an integer is coerced (e.g. '45' -> 45, -42.0 -> -42), a value
489            // with a fractional part (e.g. 42.4) or a non-numeric TEXT/BLOB
490            // raises "datatype mismatch" (triggerC-4.1.x).
491            let mut apply_affinity = |affinity: RowidAffinity| -> u64 {
492                match affinity {
493                    RowidAffinity::Value(i) => {
494                        let rowid = i as u64;
495                        if i > 0 {
496                            batch_max_rowid = batch_max_rowid.max(rowid);
497                        }
498                        rowid
499                    }
500                    RowidAffinity::Auto => {
501                        batch_max_rowid += 1;
502                        batch_max_rowid
503                    }
504                }
505            };
506
507            match rowid_expr {
508                // Literal value: apply rowid (INTEGER) affinity directly.
509                vibesql_ast::Expression::Literal(val) => {
510                    Some(apply_affinity(coerce_rowid_affinity(val)?))
511                }
512                // DEFAULT means auto-assign, like NULL.
513                vibesql_ast::Expression::Default => Some(apply_affinity(RowidAffinity::Auto)),
514                // Negative numeric literal (parsed as unary minus over a literal):
515                // negate, then apply rowid affinity. This handles -42 (integer)
516                // AND -42.0 (real) uniformly (triggerC-4.1.4).
517                vibesql_ast::Expression::UnaryOp {
518                    op: vibesql_ast::UnaryOperator::Minus,
519                    expr,
520                } if matches!(expr.as_ref(), vibesql_ast::Expression::Literal(_)) => {
521                    let vibesql_ast::Expression::Literal(inner) = expr.as_ref() else {
522                        unreachable!("guarded by matches! above")
523                    };
524                    let negated = match inner {
525                        vibesql_types::SqlValue::Integer(i) => vibesql_types::SqlValue::Integer(-*i),
526                        vibesql_types::SqlValue::Bigint(i) => vibesql_types::SqlValue::Bigint(-*i),
527                        vibesql_types::SqlValue::Smallint(i) => {
528                            vibesql_types::SqlValue::Smallint(-*i)
529                        }
530                        vibesql_types::SqlValue::Float(f) => vibesql_types::SqlValue::Float(-*f),
531                        vibesql_types::SqlValue::Real(f) => vibesql_types::SqlValue::Real(-*f),
532                        vibesql_types::SqlValue::Double(f) => vibesql_types::SqlValue::Double(-*f),
533                        vibesql_types::SqlValue::Numeric(f) => vibesql_types::SqlValue::Numeric(-*f),
534                        _ => {
535                            return Err(ExecutorError::UnsupportedExpression(
536                                "datatype mismatch".to_string(),
537                            ));
538                        }
539                    };
540                    Some(apply_affinity(coerce_rowid_affinity(&negated)?))
541                }
542                _ => {
543                    // For complex expressions (CASE, functions, subqueries, trigger
544                    // pseudo-variables, procedural variables, etc.), evaluate them to get
545                    // the resulting value.
546                    //
547                    // When inserting into an INTEGER PRIMARY KEY column, the column's value
548                    // is treated as the rowid and routed through this expression-evaluation
549                    // path. If the INSERT runs inside a trigger body, the expression may
550                    // reference NEW.x / OLD.x pseudo-variables (e.g.
551                    // `INSERT INTO audit(id, ...) VALUES (NEW.id, ...)` where audit.id is an
552                    // INTEGER PRIMARY KEY). We must thread the trigger / procedural context
553                    // into the evaluator so those references resolve, matching the non-IPK
554                    // column path in `evaluate_insert_expression_with_trigger_context`
555                    // (issue #5397).
556                    //
557                    // rowid expressions don't reference columns from a current row, so a
558                    // dummy row is sufficient.
559                    let dummy_schema =
560                        vibesql_catalog::TableSchema::new("__rowid_expr__".to_string(), vec![]);
561                    let dummy_row = vibesql_storage::Row::new(vec![]);
562                    let mut evaluator = if let Some(ctx) = trigger_context {
563                        crate::ExpressionEvaluator::with_trigger_context(
564                            ctx.table_schema,
565                            db,
566                            ctx,
567                        )
568                    } else if let Some(ctx) = procedural_context {
569                        crate::ExpressionEvaluator::with_procedural_context(&dummy_schema, db, ctx)
570                    } else {
571                        crate::ExpressionEvaluator::with_database(&dummy_schema, db)
572                    };
573                    if let Some(ref ctes) = cte_results {
574                        evaluator = evaluator.with_cte_context(ctes);
575                    }
576                    let val = evaluator.eval(rowid_expr, &dummy_row)?;
577
578                    // Apply rowid (INTEGER) affinity to the evaluated value,
579                    // matching the literal path (triggerC-4.1.x).
580                    Some(apply_affinity(coerce_rowid_affinity(&val)?))
581                }
582            }
583        } else {
584            None
585        };
586
587        // Filter out the rowid value when iterating over column values
588        // Only filter if it's a pseudo-column (rowid, _rowid_, oid) that's not in the schema
589        // For INTEGER PRIMARY KEY columns, the value IS part of the columns list
590        let column_values: Vec<_> = if let Some(rowid_pos) =
591            rowid_position.filter(|_| resolved_columns.rowid_is_pseudo_column)
592        {
593            value_exprs
594                .iter()
595                .enumerate()
596                .filter(|(idx, _)| *idx != rowid_pos)
597                .map(|(_, expr)| expr)
598                .collect()
599        } else {
600            value_exprs.iter().collect()
601        };
602
603        // Track which columns have been assigned (SQLite uses first occurrence for duplicates)
604        let mut assigned_columns = std::collections::HashSet::new();
605
606        for (expr, (col_idx, data_type)) in column_values.iter().zip(target_column_info.iter()) {
607            // SQLite behavior: if a column is specified multiple times, use the first value
608            // Skip duplicate column assignments
609            if assigned_columns.contains(col_idx) {
610                continue;
611            }
612            assigned_columns.insert(*col_idx);
613
614            // Evaluate expression (literals, DEFAULT, procedural variables, and trigger
615            // pseudo-variables)
616            let value = super::defaults::evaluate_insert_expression_with_trigger_context(
617                expr,
618                &schema.columns[*col_idx],
619                procedural_context,
620                trigger_context,
621                Some(db),
622                cte_results.as_ref(),
623            )?;
624
625            // Type check and coerce: ensure value matches column type
626            let coerced_value = super::validation::coerce_value(value, data_type)?;
627
628            // INTEGER PRIMARY KEY validation: only accept Integer or Null (for auto-generation)
629            // SQLite rejects non-integer values with "datatype mismatch"
630            if ipk_col_idx == Some(*col_idx) {
631                match &coerced_value {
632                    vibesql_types::SqlValue::Integer(_) | vibesql_types::SqlValue::Null => {
633                        // Valid for INTEGER PRIMARY KEY
634                    }
635                    _ => {
636                        return Err(crate::errors::ExecutorError::SqliteCompatError(
637                            "datatype mismatch".to_string(),
638                        ));
639                    }
640                }
641            }
642
643            full_row_values[*col_idx] = coerced_value;
644        }
645
646        // Apply DEFAULT values for unspecified columns
647        // This returns the first generated sequence value (if any)
648        // Use storage_table_name for correct table lookup (handles schema-qualified tables)
649        // Pass batch_max_ipk to handle multi-row INSERTs with NULL INTEGER PRIMARY KEY
650        let generated_id = super::defaults::apply_default_values_with_batch_context(
651            &schema,
652            &mut full_row_values,
653            db,
654            &storage_table_name,
655            batch_max_ipk,
656        )?;
657
658        // Apply generated/computed column values (AS(expression) syntax)
659        super::defaults::apply_generated_columns(&schema, &mut full_row_values, db)?;
660
661        // Track the first generated ID across all rows
662        if first_generated_id.is_none() {
663            first_generated_id = generated_id;
664        }
665
666        // Update batch_max_ipk if this row has an INTEGER PRIMARY KEY value
667        // Also track explicit INTEGER PRIMARY KEY values for last_insert_rowid()
668        // SQLite semantics: last_insert_rowid() returns the rowid of the most recently
669        // inserted row, whether auto-generated or explicitly provided
670        if let Some(idx) = ipk_col_idx {
671            if let Some(vibesql_types::SqlValue::Integer(val)) = full_row_values.get(idx) {
672                batch_max_ipk = Some(batch_max_ipk.map_or(*val, |prev| prev.max(*val)));
673                // Track first explicit INTEGER PRIMARY KEY value for last_insert_rowid()
674                if first_generated_id.is_none() {
675                    first_generated_id = Some(*val);
676                }
677            }
678        }
679
680        // Validate all constraints in a single pass and extract index keys
681        // Skip PK/UNIQUE duplicate checks if using REPLACE conflict clause, ON DUPLICATE KEY
682        // UPDATE, or ON CONFLICT clause. Also skip for IGNORE since we'll handle violations
683        // by skipping the row.
684        //
685        // Exception: a *targeted* DO NOTHING (without OR IGNORE) must NOT
686        // skip duplicate checks — rows that conflict on the targeted
687        // constraint are skipped before validation (below), so any remaining
688        // duplicate is on a non-targeted constraint and must raise a normal
689        // UNIQUE error (SQLite semantics, upsert1-201).
690        let skip_duplicate_checks =
691            matches!(stmt.conflict_clause, Some(vibesql_ast::ConflictClause::Replace))
692                || matches!(stmt.conflict_clause, Some(vibesql_ast::ConflictClause::Ignore))
693                || stmt.on_duplicate_key_update.is_some()
694                || (stmt.on_conflict.is_some() && do_nothing_target.is_none());
695        let validator = super::row_validator::RowValidator::new(
696            db,
697            &schema,
698            &storage_table_name,
699            &primary_key_values,
700            &unique_constraint_values,
701            &batch_full_rows,
702            skip_duplicate_checks,
703        );
704
705        // For IGNORE, we need to check for constraint violations before adding to validated_rows
706        // If there's a violation, skip this row instead of returning an error
707        if use_ignore {
708            let would_violate = if let Some((target, target_where)) = do_nothing_target {
709                // Targeted DO NOTHING: only conflicts on the targeted
710                // constraint are suppressed (expression- and partial-index
711                // aware; checks earlier batch rows too — upsert1-320).
712                super::on_conflict_update::row_conflicts_on_target(
713                    db,
714                    table_name,
715                    &schema,
716                    &full_row_values,
717                    target,
718                    target_where,
719                    &batch_full_rows,
720                )?
721            } else {
722                // OR IGNORE / untargeted DO NOTHING: skip the row on any
723                // constraint violation.
724                check_would_violate_constraints(
725                    db,
726                    &schema,
727                    &storage_table_name,
728                    &full_row_values,
729                    &primary_key_values,
730                    &unique_constraint_values,
731                )
732            };
733            if would_violate {
734                // Skip this row - don't add to validated_rows
735                continue;
736            }
737        }
738
739        let validation_result = validator.validate(&full_row_values)?;
740        drop(validator); // release the immutable &Database borrow before the queue push below
741
742        // Track PK values for batch duplicate checking (using pre-extracted keys)
743        if let Some(pk_values) = validation_result.primary_key {
744            primary_key_values.push(pk_values);
745        }
746
747        // Track UNIQUE values for batch duplicate checking (using pre-extracted keys)
748        for (constraint_idx, unique_values) in validation_result.unique_keys.into_iter().enumerate()
749        {
750            if let Some(values) = unique_values {
751                unique_constraint_values[constraint_idx].push(values);
752            }
753        }
754
755        // Phase C2 of #5085: queue any FK violations that were deferred
756        // (INITIALLY DEFERRED constraint or PRAGMA defer_foreign_keys=ON)
757        // onto the active transaction's deferred-FK queue.
758        for v in validation_result.deferred_fk_violations {
759            db.queue_deferred_fk_violation(v);
760        }
761
762        // Track row for self-referential FK lookups by later rows in the
763        // same batch (see fkey1-5.1 note above).
764        batch_full_rows.push(full_row_values.clone());
765
766        // Store validated row for insertion (with optional explicit rowid)
767        validated_rows.push((full_row_values, explicit_rowid));
768    }
769
770    // All rows validated successfully, now insert them
771
772    // Check once if any INSERT triggers exist for this table (used for batch optimization)
773    let has_insert_triggers = db
774        .catalog
775        .get_triggers_for_table_in_schema(
776            table_name,
777            Some(vibesql_ast::TriggerEvent::Insert),
778            dml_schema.as_deref(),
779        )
780        .next()
781        .is_some();
782
783    // Fire BEFORE STATEMENT triggers only if triggers exist AND we're not inside a trigger context
784    // (Statement-level triggers don't fire for inserts within trigger bodies)
785    if has_insert_triggers && trigger_context.is_none() {
786        // Statement-level RAISE(IGNORE) has no sqlite3 analog (SQLite triggers
787        // are always FOR EACH ROW); drop the must-use outcome and keep the
788        // pre-#5418 proceed behavior (#5418).
789        let _stmt_outcome = crate::TriggerFirer::execute_before_statement_triggers_in_schema(
790            db,
791            table_name,
792            vibesql_ast::TriggerEvent::Insert,
793            dml_schema.as_deref(),
794        )?;
795    }
796
797    let mut rows_inserted = 0;
798
799    // Rows taken through the upsert ON CONFLICT DO UPDATE arm. These count
800    // toward changes() (rows_inserted) but are excluded from the direct-insert
801    // count that PRAGMA count_changes reports for INSERT (issue #5283).
802    let mut upsert_updated_rows = 0;
803
804    // RETURNING (SQLite 3.35.0+): collect the rows as ACTUALLY inserted (or
805    // updated by ON DUPLICATE KEY UPDATE). The slow path can rewrite the
806    // IPK/rowid after validation (REPLACE reserved-rowid interplay), so rows
807    // are captured at the insert_row call sites, not from validated_rows.
808    let mut returned_rows: Vec<vibesql_storage::Row> = Vec::new();
809    let capture_returning = stmt.returning.is_some();
810
811    // Check if any assertions exist - needed for rollback support
812    let has_assertions = db.catalog.get_all_assertions().next().is_some();
813
814    // Track row count before inserts for assertion rollback
815    let row_count_before_all = if has_assertions {
816        Some(
817            db.get_table(&storage_table_name)
818                .ok_or_else(|| ExecutorError::TableNotFound(stmt.table_name.clone()))?
819                .row_count(),
820        )
821    } else {
822        None
823    };
824
825    let use_batch_insert = stmt.on_duplicate_key_update.is_none()
826        && !matches!(stmt.conflict_clause, Some(vibesql_ast::ConflictClause::Replace))
827        && !matches!(
828            &stmt.on_conflict,
829            Some(vibesql_ast::OnConflictClause {
830                action: vibesql_ast::OnConflictAction::DoUpdate { .. },
831                ..
832            })
833        )
834        && !has_insert_triggers;
835
836    // Helper to create a Row with optional explicit rowid
837    let make_row = |(values, rowid): (Vec<vibesql_types::SqlValue>, Option<u64>)| match rowid {
838        Some(id) => vibesql_storage::Row::with_row_id(values, id),
839        None => vibesql_storage::Row::new(values),
840    };
841
842    if use_batch_insert && validated_rows.len() > 1 {
843        // Fast path: Use batch insert for multiple rows without triggers
844        // Use cost-based batch sizing to optimize for tables with many indexes
845        let optimizer = DmlOptimizer::new(db, table_name);
846        let optimal_batch_size = optimizer.optimal_insert_batch_size(validated_rows.len());
847
848        // Track initial row count for expression index maintenance
849        let initial_row_count =
850            db.get_table(&storage_table_name).map(|t| t.row_count()).unwrap_or(0);
851
852        // If optimal batch size is smaller than total rows, insert in batches
853        if optimal_batch_size < validated_rows.len() {
854            // Chunked batch insert for high-cost tables
855            let mut row_offset = initial_row_count;
856            for chunk in validated_rows.chunks(optimal_batch_size) {
857                let rows: Vec<vibesql_storage::Row> =
858                    chunk.iter().map(|(v, rowid)| make_row((v.clone(), *rowid))).collect();
859
860                // Partial-aware unique-constraint check (storage skips
861                // partial UNIQUE indexes; the executor must do this itself).
862                for row in &rows {
863                    partial_index_maintenance::check_partial_unique_for_insert(
864                        db,
865                        &storage_table_name,
866                        row,
867                    )?;
868                }
869
870                let chunk_inserted =
871                    db.insert_rows_batch(&storage_table_name, rows.clone()).map_err(|e| {
872                        ExecutorError::UnsupportedExpression(format!("Storage error: {}", e))
873                    })?;
874
875                if capture_returning {
876                    returned_rows.extend(rows.iter().cloned());
877                }
878
879                // Maintain expression indexes for each inserted row
880                for (i, row) in rows.iter().enumerate() {
881                    expression_index_maintenance::maintain_expression_indexes_for_insert(
882                        db,
883                        &storage_table_name,
884                        row,
885                        row_offset + i,
886                    );
887                    partial_index_maintenance::maintain_partial_indexes_for_insert(
888                        db,
889                        &storage_table_name,
890                        row,
891                        row_offset + i,
892                    );
893                }
894                row_offset += chunk_inserted;
895                rows_inserted += chunk_inserted;
896            }
897        } else {
898            // Single batch insert for low-cost tables
899            let rows: Vec<vibesql_storage::Row> =
900                validated_rows.into_iter().map(make_row).collect();
901
902            // Partial-aware unique-constraint check (storage skips partial
903            // UNIQUE indexes; the executor must do this itself).
904            for row in &rows {
905                partial_index_maintenance::check_partial_unique_for_insert(
906                    db,
907                    &storage_table_name,
908                    row,
909                )?;
910            }
911
912            rows_inserted =
913                db.insert_rows_batch(&storage_table_name, rows.clone()).map_err(|e| {
914                    ExecutorError::UnsupportedExpression(format!("Storage error: {}", e))
915                })?;
916
917            if capture_returning {
918                returned_rows.extend(rows.iter().cloned());
919            }
920
921            // Maintain expression indexes for each inserted row
922            for (i, row) in rows.iter().enumerate() {
923                expression_index_maintenance::maintain_expression_indexes_for_insert(
924                    db,
925                    &storage_table_name,
926                    row,
927                    initial_row_count + i,
928                );
929                partial_index_maintenance::maintain_partial_indexes_for_insert(
930                    db,
931                    &storage_table_name,
932                    row,
933                    initial_row_count + i,
934                );
935            }
936        }
937    } else {
938        // Slow path: Insert rows one by one (needed for triggers, special clauses)
939        for (mut full_row_values, mut explicit_rowid) in validated_rows {
940            // Check if ON DUPLICATE KEY UPDATE is specified
941            if let Some(ref assignments) = stmt.on_duplicate_key_update {
942                // Try to update an existing row if there's a conflict
943                let update_result = super::duplicate_key_update::handle_duplicate_key_update(
944                    db,
945                    table_name,
946                    &schema,
947                    &full_row_values,
948                    assignments,
949                )?;
950
951                if let Some(updated_row_id) = update_result {
952                    // Row was updated, count it
953                    rows_inserted += 1;
954
955                    // RETURNING: SQLite/MySQL return the post-UPDATE row for
956                    // upserts that take the update arm.
957                    if capture_returning {
958                        if let Some(updated_row) = db
959                            .get_table(table_name)
960                            .and_then(|table| table.scan().get(updated_row_id))
961                        {
962                            returned_rows.push(updated_row.clone());
963                        }
964                    }
965                    continue;
966                }
967                // No conflict, fall through to insert
968            } else if let Some(vibesql_ast::OnConflictClause {
969                ref conflict_target,
970                ref target_where,
971                action:
972                    vibesql_ast::OnConflictAction::DoUpdate { ref assignments, ref where_clause },
973                ..
974            }) = stmt.on_conflict
975            {
976                // SQLite upsert: ON CONFLICT [(cols)] DO UPDATE SET ... [WHERE ...]
977                match super::on_conflict_update::handle_on_conflict_update(
978                    db,
979                    table_name,
980                    &schema,
981                    &full_row_values,
982                    conflict_target.as_deref(),
983                    target_where.as_ref(),
984                    assignments,
985                    where_clause.as_ref(),
986                    cte_results.as_ref(),
987                )? {
988                    super::on_conflict_update::UpsertAction::Updated(updated_row_id) => {
989                        // Row was updated, count it toward affected rows
990                        rows_inserted += 1;
991                        upsert_updated_rows += 1;
992
993                        // RETURNING: SQLite returns the post-UPDATE row for
994                        // upserts that take the update arm.
995                        if capture_returning {
996                            if let Some(updated_row) = db
997                                .get_table(table_name)
998                                .and_then(|table| table.scan().get(updated_row_id))
999                            {
1000                                returned_rows.push(updated_row.clone());
1001                            }
1002                        }
1003                        continue;
1004                    }
1005                    super::on_conflict_update::UpsertAction::Skipped => {
1006                        // DO UPDATE ... WHERE was false/NULL: the row is
1007                        // neither inserted nor updated (SQLite semantics).
1008                        continue;
1009                    }
1010                    super::on_conflict_update::UpsertAction::NoConflict => {
1011                        // No conflict on the targeted constraint; fall through
1012                        // to a normal insert. Conflicts on OTHER constraints
1013                        // still surface as UNIQUE errors (upsert1-201).
1014                    }
1015                }
1016            } else if matches!(stmt.conflict_clause, Some(vibesql_ast::ConflictClause::Replace)) {
1017                // SQLite REPLACE semantics: allocate the rowid for the new row BEFORE firing
1018                // BEFORE DELETE triggers. This ensures that any INSERT within those triggers
1019                // that tries to allocate the same rowid will fail with a UNIQUE constraint
1020                // violation on rowid.
1021                //
1022                // Compute the rowid for the new row and reserve it.
1023                // Track whether the rowid is explicitly specified (INTEGER PRIMARY KEY)
1024                // or auto-allocated, as this affects how conflicts are handled.
1025                let is_explicit = explicit_rowid.is_some();
1026                let reserved_rowid = if let Some(rowid) = explicit_rowid {
1027                    rowid
1028                } else {
1029                    // Auto-allocate: next available rowid is physical row count + 1
1030                    // (physical includes deleted rows since rowid is based on physical index)
1031                    let current_physical = db
1032                        .get_table(&storage_table_name)
1033                        .map(|t| t.physical_row_count() as u64)
1034                        .unwrap_or(0);
1035                    current_physical + 1
1036                };
1037
1038                // Reserve the rowid before firing triggers
1039                db.reserve_rowid(&storage_table_name, reserved_rowid, is_explicit);
1040
1041                // If REPLACE conflict clause, delete conflicting rows first
1042                // This also fires DELETE triggers which may create new constraints violations.
1043                // handle_replace_conflicts() releases the reserved rowid after BEFORE DELETE
1044                // triggers but before AFTER DELETE triggers.
1045                let replace_result = super::replace::handle_replace_conflicts(
1046                    db,
1047                    table_name,
1048                    &storage_table_name,
1049                    &schema,
1050                    &full_row_values,
1051                );
1052
1053                // On error, ensure the reserved rowid is released and propagate the error.
1054                // Note: The reserved rowid may already be released if the error occurred
1055                // after BEFORE DELETE triggers, but release_reserved_rowid is idempotent.
1056                if let Err(e) = replace_result {
1057                    db.release_reserved_rowid(&storage_table_name);
1058                    return Err(e);
1059                }
1060
1061                // After REPLACE conflict handling (which fires triggers), re-validate constraints.
1062                // Triggers may have inserted new rows that conflict with the row we want to insert.
1063                // Pass empty batch values since we're only checking against existing table data.
1064                // Note: The reserved rowid has already been released by handle_replace_conflicts()
1065                // after BEFORE DELETE triggers, so no need to release on error here.
1066                super::constraints::enforce_primary_key_constraint(
1067                    db,
1068                    &schema,
1069                    table_name,
1070                    &full_row_values,
1071                    &[], // No batch values to check against
1072                )?;
1073
1074                super::constraints::enforce_unique_constraints(
1075                    db,
1076                    &schema,
1077                    table_name,
1078                    &full_row_values,
1079                    &[], // No batch values to check against
1080                )?;
1081
1082                super::constraints::enforce_unique_indexes(
1083                    db,
1084                    &schema,
1085                    table_name,
1086                    &full_row_values,
1087                )?;
1088
1089                // Use the reserved rowid for the REPLACE INSERT to match SQLite semantics.
1090                // This ensures the new row gets the rowid that was reserved before triggers fired.
1091                explicit_rowid = Some(reserved_rowid);
1092
1093                // Release the reservation now that all delete triggers have fired.
1094                // The REPLACE INSERT will use explicit_rowid which already has the reserved value.
1095                // This prevents the REPLACE INSERT from failing on its own reservation.
1096                db.release_reserved_rowid(&storage_table_name);
1097            }
1098
1099            // Fire BEFORE INSERT triggers only if triggers exist
1100            let row_to_insert = make_row((full_row_values.clone(), explicit_rowid));
1101            if has_insert_triggers {
1102                // RAISE(IGNORE) in a BEFORE INSERT trigger abandons this row:
1103                // skip the insert and continue with the next row (SQLite).
1104                if crate::TriggerFirer::execute_before_triggers_in_schema(
1105                    db,
1106                    table_name,
1107                    vibesql_ast::TriggerEvent::Insert,
1108                    None,
1109                    Some(&row_to_insert),
1110                    dml_schema.as_deref(),
1111                )? == crate::trigger_execution::TriggerOutcome::SkipRow
1112                {
1113                    continue;
1114                }
1115            }
1116
1117            // Get physical row count before insert to enable rollback and rowid calculation
1118            let table_ref = db
1119                .get_table(&storage_table_name)
1120                .ok_or_else(|| ExecutorError::TableNotFound(full_table_name.clone()))?;
1121            let row_count_before = table_ref.row_count();
1122            let physical_row_count_before = table_ref.physical_row_count();
1123
1124            // SQLite REPLACE semantics: Check if the rowid we're about to use is reserved.
1125            // During REPLACE, the rowid for the new row is allocated BEFORE firing triggers.
1126            // The behavior depends on the type of reservation:
1127            // - Auto-allocated reservation: BEFORE DELETE trigger INSERTs fail on rowid conflict
1128            // - Explicit reservation: AFTER DELETE trigger auto-INSERTs skip to next rowid
1129            //   (SQLite reuses freed rowids, but VibeSQL doesn't, so we skip instead)
1130            let mut final_explicit_rowid = explicit_rowid;
1131            let rowid_to_use = explicit_rowid.unwrap_or((physical_row_count_before + 1) as u64);
1132            if let Some((reserved_rowid, is_explicit_reservation)) =
1133                db.get_reserved_rowid_info(&storage_table_name)
1134            {
1135                if rowid_to_use == reserved_rowid {
1136                    if !is_explicit_reservation {
1137                        // Auto-allocated REPLACE rowid: trigger INSERT must fail
1138                        // Use actual column name for INTEGER PRIMARY KEY columns
1139                        let col_name = ipk_col_idx
1140                            .and_then(|idx| schema.columns.get(idx))
1141                            .map(|col| col.name.as_str())
1142                            .unwrap_or("rowid");
1143                        return Err(ExecutorError::SqliteCompatError(format!(
1144                            "UNIQUE constraint failed: {}.{}",
1145                            table_name, col_name
1146                        )));
1147                    } else if explicit_rowid.is_none() {
1148                        // Explicit REPLACE rowid: auto-allocated trigger INSERT skips to next rowid
1149                        // This approximates SQLite's rowid reuse behavior
1150                        let new_rowid = reserved_rowid + 1;
1151                        final_explicit_rowid = Some(new_rowid);
1152
1153                        // Also update the INTEGER PRIMARY KEY column value to match the new rowid
1154                        // The column value was auto-generated earlier but now we're using a different rowid
1155                        if let Some(ipk_idx) = ipk_col_idx {
1156                            full_row_values[ipk_idx] =
1157                                vibesql_types::SqlValue::Integer(new_rowid as i64);
1158                        }
1159                    } else {
1160                        // Explicit REPLACE rowid and explicit trigger INSERT with same rowid: fail
1161                        // Use actual column name for INTEGER PRIMARY KEY columns
1162                        let col_name = ipk_col_idx
1163                            .and_then(|idx| schema.columns.get(idx))
1164                            .map(|col| col.name.as_str())
1165                            .unwrap_or("rowid");
1166                        return Err(ExecutorError::SqliteCompatError(format!(
1167                            "UNIQUE constraint failed: {}.{}",
1168                            table_name, col_name
1169                        )));
1170                    }
1171                }
1172            }
1173
1174            // Insert the row
1175            let row = make_row((full_row_values, final_explicit_rowid));
1176            // Partial-aware unique-constraint check (storage skips partial
1177            // UNIQUE indexes; the executor must do that check itself).
1178            partial_index_maintenance::check_partial_unique_for_insert(
1179                db,
1180                &storage_table_name,
1181                &row,
1182            )?;
1183            db.insert_row(&storage_table_name, row.clone()).map_err(|e| {
1184                ExecutorError::UnsupportedExpression(format!("Storage error: {}", e))
1185            })?;
1186
1187            // RETURNING: capture the row exactly as inserted (after any
1188            // REPLACE reserved-rowid / IPK rewrites above).
1189            if capture_returning {
1190                returned_rows.push(row.clone());
1191            }
1192
1193            // Maintain expression indexes for this insert
1194            expression_index_maintenance::maintain_expression_indexes_for_insert(
1195                db,
1196                &storage_table_name,
1197                &row,
1198                row_count_before,
1199            );
1200            partial_index_maintenance::maintain_partial_indexes_for_insert(
1201                db,
1202                &storage_table_name,
1203                &row,
1204                row_count_before,
1205            );
1206
1207            // Fire AFTER INSERT triggers only if triggers exist.
1208            //
1209            // On a trigger error we simply propagate it: the statement-scope
1210            // machinery in `raise_scope::run_top_level_dml` (which wraps every
1211            // top-level INSERT) handles undoing the right amount of work, exactly
1212            // matching sqlite3 3.51 (#5464, #5474):
1213            //   - RAISE(ABORT)/RAISE(ROLLBACK)/any non-RAISE trigger error → the
1214            //     statement savepoint (in an explicit txn) or implicit
1215            //     transaction (auto-commit) rolls back the *whole* statement,
1216            //     removing this offending row AND every earlier row.
1217            //   - RAISE(FAIL) → the partial changes the statement already applied
1218            //     are KEPT, including this offending row (which was inserted
1219            //     before its AFTER trigger fired). SQLite keeps it; so do we.
1220            //
1221            // Previously this site unconditionally tombstoned the just-inserted
1222            // offending row on any trigger error. That was redundant for
1223            // ABORT/non-RAISE (rollback already removes it) and *wrong* for FAIL
1224            // (it hid a row SQLite keeps — visible only as a bitmap tombstone in
1225            // raw `Table::scan()`, missing from any live SELECT). See #5474.
1226            if has_insert_triggers {
1227                // Stamp the NEW row with its allocated rowid so `NEW.rowid` (and
1228                // the `oid` / `_rowid_` aliases) resolve in the AFTER INSERT
1229                // trigger body (#5485). For an explicit `INSERT INTO t(rowid,...)`
1230                // the row already carries `row_id`; for an auto-allocated rowid
1231                // the row was created with `row_id == None`, and the actual rowid
1232                // is `rowid_to_use` (physical_row_count_before + 1).
1233                let after_row = {
1234                    let mut r = row.clone();
1235                    if r.row_id.is_none() {
1236                        r.row_id = Some(rowid_to_use);
1237                    }
1238                    r
1239                };
1240                // A `TriggerOutcome::SkipRow` (RAISE(IGNORE) in an AFTER trigger)
1241                // has no SQLite-observable effect here: the row is already
1242                // inserted and RAISE(IGNORE) only abandons the rest of the
1243                // trigger program, leaving the row in place. So we only act on
1244                // the error (RAISE / non-RAISE) path; drop the Ok outcome.
1245                let _after_outcome = crate::TriggerFirer::execute_after_triggers_in_schema(
1246                    db,
1247                    table_name,
1248                    vibesql_ast::TriggerEvent::Insert,
1249                    None,
1250                    Some(&after_row),
1251                    dml_schema.as_deref(),
1252                )?;
1253            }
1254
1255            rows_inserted += 1;
1256        }
1257    }
1258
1259    // Fire AFTER STATEMENT triggers only if triggers exist AND we're not inside a trigger context
1260    // (Statement-level triggers don't fire for inserts within trigger bodies)
1261    if has_insert_triggers && trigger_context.is_none() {
1262        // Statement-level RAISE(IGNORE) has no sqlite3 analog; drop the
1263        // must-use outcome (#5418).
1264        let _stmt_outcome = crate::TriggerFirer::execute_after_statement_triggers_in_schema(
1265            db,
1266            table_name,
1267            vibesql_ast::TriggerEvent::Insert,
1268            dml_schema.as_deref(),
1269        )?;
1270    }
1271
1272    // Update LAST_INSERT_ROWID if any auto-generated values were produced
1273    // SQLite: last_insert_rowid() is NOT updated for WITHOUT ROWID tables
1274    // (see SQLite documentation R-47220-63683)
1275    if let Some(id) = first_generated_id {
1276        if !schema.without_rowid {
1277            db.set_last_insert_rowid(id);
1278        }
1279    }
1280
1281    // Invalidate the database-level columnar cache since table data changed.
1282    // Note: The table-level cache is already invalidated by insert_row()/insert_rows_batch().
1283    // Both invalidations are necessary because they manage separate caches:
1284    // - Table-level cache: used by Table::scan_columnar() for SIMD filtering
1285    // - Database-level cache: used by Database::get_columnar() for cached access
1286    if rows_inserted > 0 {
1287        db.invalidate_columnar_cache(&storage_table_name);
1288    }
1289
1290    // Check all assertions after INSERT completes (SQL:1999 Feature F671/F672)
1291    // This ensures database-wide integrity constraints are maintained
1292    if let Err(assertion_error) =
1293        crate::advanced_objects::AssertionChecker::check_all_assertions(db)
1294    {
1295        // Rollback: Delete the rows we just inserted
1296        if let Some(start_index) = row_count_before_all {
1297            if rows_inserted > 0 {
1298                // Delete rows starting from start_index (the rows we inserted)
1299                if let Some(table_mut) = db.get_table_mut(&storage_table_name) {
1300                    use std::cell::Cell;
1301                    let current_index = Cell::new(0);
1302                    // Delete all rows from start_index onwards (the newly inserted rows)
1303                    let _ = table_mut.delete_where(|_row| {
1304                        let index = current_index.get();
1305                        current_index.set(index + 1);
1306                        index >= start_index
1307                    });
1308                }
1309
1310                // Rebuild indexes since we modified the table (handles compaction)
1311                db.rebuild_indexes(&storage_table_name);
1312                db.invalidate_columnar_cache(&storage_table_name);
1313            }
1314        }
1315        return Err(assertion_error);
1316    }
1317
1318    // Project the RETURNING clause against the rows actually inserted/updated
1319    // (one result row per affected row, in insertion order). Zero affected
1320    // rows still yield an empty result with the derived column headers.
1321    let returning_result = if let Some(items) = &stmt.returning {
1322        let row_refs: Vec<&vibesql_storage::Row> = returned_rows.iter().collect();
1323        Some(crate::dml_returning::project_returning(
1324            items,
1325            &schema,
1326            db,
1327            None,
1328            &row_refs,
1329            cte_results.as_ref(),
1330        )?)
1331    } else {
1332        None
1333    };
1334
1335    Ok(InsertOutcome {
1336        affected_rows: rows_inserted,
1337        upsert_updated_rows,
1338        returning: returning_result,
1339    })
1340}
1341
1342/// Check if inserting a row would violate any constraints (for IGNORE conflict resolution)
1343/// Returns true if any constraint would be violated
1344fn check_would_violate_constraints(
1345    db: &vibesql_storage::Database,
1346    schema: &vibesql_catalog::TableSchema,
1347    table_name: &str,
1348    row_values: &[vibesql_types::SqlValue],
1349    batch_pk_values: &[Vec<vibesql_types::SqlValue>],
1350    batch_unique_values: &[Vec<Vec<vibesql_types::SqlValue>>],
1351) -> bool {
1352    // Check NOT NULL constraints
1353    for (col_idx, col) in schema.columns.iter().enumerate() {
1354        if !col.nullable && row_values[col_idx] == vibesql_types::SqlValue::Null {
1355            return true;
1356        }
1357    }
1358
1359    // Check PRIMARY KEY uniqueness
1360    if let Some(pk_indices) = schema.get_primary_key_indices() {
1361        let new_pk_values: Vec<vibesql_types::SqlValue> =
1362            pk_indices.iter().map(|&idx| row_values[idx].clone()).collect();
1363
1364        // Skip if any PK value is NULL (multiple NULLs are allowed for non-INTEGER PRIMARY KEY)
1365        if !new_pk_values.contains(&vibesql_types::SqlValue::Null) {
1366            // Check against batch
1367            if batch_pk_values.contains(&new_pk_values) {
1368                return true;
1369            }
1370
1371            // Check against existing table data
1372            if let Some(table) = db.get_table(table_name) {
1373                if let Some(pk_index) = table.primary_key_index() {
1374                    if pk_index.contains_key(&new_pk_values) {
1375                        return true;
1376                    }
1377                }
1378            }
1379        }
1380    }
1381
1382    // Check UNIQUE constraints
1383    let unique_constraint_indices = schema.get_unique_constraint_indices();
1384    for (constraint_idx, unique_indices) in unique_constraint_indices.iter().enumerate() {
1385        let new_unique_values: Vec<vibesql_types::SqlValue> =
1386            unique_indices.iter().map(|&idx| row_values[idx].clone()).collect();
1387
1388        // Skip if any value is NULL
1389        if new_unique_values.contains(&vibesql_types::SqlValue::Null) {
1390            continue;
1391        }
1392
1393        // Check against batch
1394        if constraint_idx < batch_unique_values.len()
1395            && batch_unique_values[constraint_idx].contains(&new_unique_values)
1396        {
1397            return true;
1398        }
1399
1400        // Check against existing table data
1401        if let Some(table) = db.get_table(table_name) {
1402            let unique_indexes = table.unique_indexes();
1403            if constraint_idx < unique_indexes.len()
1404                && unique_indexes[constraint_idx].contains_key(&new_unique_values)
1405            {
1406                return true;
1407            }
1408        }
1409    }
1410
1411    // Check user-defined UNIQUE indexes
1412    if let Some(table) = db.get_table(table_name) {
1413        // Lazily-built evaluator context for expression-index components and
1414        // partial-index predicates (issue #5278: expect_column_name() on an
1415        // expression component used to panic here).
1416        let candidate_row = vibesql_storage::Row::new(row_values.to_vec());
1417        let evaluator = crate::evaluator::ExpressionEvaluator::new(schema);
1418
1419        for index_name in db.list_indexes_for_table(table_name) {
1420            if let Some(index_metadata) = db.get_index(&index_name) {
1421                if !index_metadata.unique {
1422                    continue;
1423                }
1424
1425                // Partial index: a row that doesn't satisfy the predicate is
1426                // never added to the index, so it cannot conflict through it.
1427                if let Some(predicate) = index_metadata.where_clause.as_deref() {
1428                    let satisfied = evaluator
1429                        .eval(predicate, &candidate_row)
1430                        .map(|v| crate::partial_index_maintenance::is_predicate_truthy(&v))
1431                        .unwrap_or(false);
1432                    if !satisfied {
1433                        continue;
1434                    }
1435                }
1436
1437                // Build key values for this index (evaluating expression
1438                // components against the candidate row; evaluation failures
1439                // become NULL, matching expression-index maintenance).
1440                let mut key_values = Vec::new();
1441                for index_col in &index_metadata.columns {
1442                    if let Some(name) = index_col.column_name() {
1443                        if let Some(col_idx) = schema.get_column_index(name) {
1444                            key_values.push(row_values[col_idx].clone());
1445                        }
1446                    } else if let Some(expr) = index_col.get_expression() {
1447                        key_values.push(
1448                            evaluator
1449                                .eval(expr, &candidate_row)
1450                                .unwrap_or(vibesql_types::SqlValue::Null),
1451                        );
1452                    }
1453                }
1454
1455                // Skip if any value is NULL
1456                if key_values.contains(&vibesql_types::SqlValue::Null) {
1457                    continue;
1458                }
1459
1460                // Check if key exists in index
1461                if let Some(index_data) = db.get_index_data(&index_name) {
1462                    if index_data.contains_key(&key_values) {
1463                        return true;
1464                    }
1465                }
1466            }
1467        }
1468        // Use `table` to suppress the unused variable warning in the `let Some(table)` pattern.
1469        // This is a read-only check, so we just need to ensure the table exists.
1470        let _ = table.row_count();
1471    }
1472
1473    // Check CHECK constraints
1474    if !schema.check_constraints.is_empty() {
1475        let row = vibesql_storage::Row::new(row_values.to_vec());
1476        let evaluator = crate::evaluator::ExpressionEvaluator::new(schema);
1477
1478        for (_constraint_name, check_expr) in &schema.check_constraints {
1479            if let Ok(result) = evaluator.eval(check_expr, &row) {
1480                if result == vibesql_types::SqlValue::Boolean(false) {
1481                    return true;
1482                }
1483            }
1484        }
1485    }
1486
1487    // Check FOREIGN KEY constraints
1488    for fk in &schema.foreign_keys {
1489        let fk_values: Vec<vibesql_types::SqlValue> =
1490            fk.column_indices.iter().map(|&idx| row_values[idx].clone()).collect();
1491
1492        // Skip if any FK value is NULL
1493        if fk_values.iter().any(|v| v.is_null()) {
1494            continue;
1495        }
1496
1497        // Check if referenced key exists in parent table
1498        if let Some(parent_table) = db.get_table(&fk.parent_table) {
1499            let key_exists = parent_table.scan().iter().any(|parent_row| {
1500                fk.parent_column_indices
1501                    .iter()
1502                    .zip(&fk_values)
1503                    .all(|(&parent_idx, fk_val)| parent_row.get(parent_idx) == Some(fk_val))
1504            });
1505
1506            if !key_exists {
1507                return true;
1508            }
1509        }
1510    }
1511
1512    false
1513}
1514
1515/// Execute INSERT on a VIEW using INSTEAD OF triggers
1516///
1517/// When inserting into a view, we need to fire INSTEAD OF INSERT triggers
1518/// instead of actually inserting data. The triggers typically insert into
1519/// the underlying tables.
1520fn execute_insert_on_view(
1521    db: &mut vibesql_storage::Database,
1522    stmt: &vibesql_ast::InsertStmt,
1523    view_def: &vibesql_catalog::ViewDefinition,
1524    procedural_context: Option<&crate::procedural::ExecutionContext>,
1525    trigger_context: Option<&crate::trigger_execution::TriggerContext>,
1526    cte_results: Option<&std::collections::HashMap<String, crate::select::cte::CteResult>>,
1527) -> Result<(usize, Option<crate::select::SelectResult>), ExecutorError> {
1528    use vibesql_ast::TriggerTiming;
1529
1530    // Find INSTEAD OF INSERT triggers for this view
1531    let triggers = crate::TriggerFirer::find_triggers(
1532        db,
1533        &view_def.name,
1534        TriggerTiming::InsteadOf,
1535        vibesql_ast::TriggerEvent::Insert,
1536    );
1537
1538    if triggers.is_empty() {
1539        return Err(ExecutorError::UnsupportedExpression(format!(
1540            "Cannot INSERT into view '{}' without INSTEAD OF trigger",
1541            view_def.name
1542        )));
1543    }
1544
1545    // Build a pseudo-schema for the view to evaluate values and resolve column names
1546    // We derive column info from the view's SELECT query
1547    let view_schema = build_view_schema(db, view_def)?;
1548
1549    // Get the rows to insert based on the source
1550    let rows_to_insert = match &stmt.source {
1551        vibesql_ast::InsertSource::Values(values) => values.clone(),
1552        vibesql_ast::InsertSource::DefaultValues => {
1553            // For DEFAULT VALUES on a view, create a single row with DEFAULT for all target columns
1554            let target_col_count = if stmt.columns.is_empty() {
1555                view_schema.columns.len()
1556            } else {
1557                stmt.columns.len()
1558            };
1559            let default_row = vec![vibesql_ast::Expression::Default; target_col_count];
1560            vec![default_row]
1561        }
1562        vibesql_ast::InsertSource::Select(select_stmt) => {
1563            // Execute SELECT and convert to expressions, with the enclosing
1564            // statement's CTEs (if any) visible to the source query.
1565            let select_result = if let Some(ctes) = cte_results {
1566                crate::SelectExecutor::new_with_cte(db, ctes).execute_with_columns(select_stmt)?
1567            } else {
1568                crate::SelectExecutor::new(db).execute_with_columns(select_stmt)?
1569            };
1570            select_result
1571                .rows
1572                .into_iter()
1573                .map(|row| row.values.into_iter().map(vibesql_ast::Expression::Literal).collect())
1574                .collect()
1575        }
1576    };
1577
1578    // Determine target column indices from the statement's column list
1579    // If no columns specified, use all view columns in order
1580    let target_columns: Vec<(usize, &vibesql_catalog::ColumnSchema)> = if stmt.columns.is_empty() {
1581        view_schema.columns.iter().enumerate().collect()
1582    } else {
1583        stmt.columns
1584            .iter()
1585            .map(|col_name| {
1586                view_schema
1587                    .columns
1588                    .iter()
1589                    .enumerate()
1590                    .find(|(_, c)| c.name.to_uppercase() == col_name.to_uppercase())
1591                    .ok_or_else(|| ExecutorError::ColumnNotFound {
1592                        column_name: col_name.clone(),
1593                        table_name: view_def.name.clone(),
1594                        searched_tables: vec![view_def.name.clone()],
1595                        available_columns: view_schema
1596                            .columns
1597                            .iter()
1598                            .map(|c| c.name.clone())
1599                            .collect(),
1600                    })
1601            })
1602            .collect::<Result<Vec<_>, _>>()?
1603    };
1604
1605    // Collect all new rows first, before firing triggers
1606    // This avoids borrow conflicts with the evaluator
1607    let new_rows: Vec<vibesql_storage::Row> = {
1608        let dummy_row = vibesql_storage::Row::new(vec![]);
1609        let mut evaluator = if let Some(ctx) = trigger_context {
1610            crate::evaluator::ExpressionEvaluator::with_trigger_context(&view_schema, db, ctx)
1611        } else if let Some(ctx) = procedural_context {
1612            crate::evaluator::ExpressionEvaluator::with_procedural_context(&view_schema, db, ctx)
1613        } else {
1614            crate::evaluator::ExpressionEvaluator::with_database(&view_schema, db)
1615        };
1616        if let Some(ctes) = cte_results {
1617            evaluator = evaluator.with_cte_context(ctes);
1618        }
1619
1620        let mut collected_rows = Vec::new();
1621        for value_exprs in &rows_to_insert {
1622            // Validate column count
1623            if value_exprs.len() != target_columns.len() {
1624                return Err(ExecutorError::InsertColumnCountMismatch {
1625                    table_name: view_def.name.clone(),
1626                    expected: target_columns.len(),
1627                    provided: value_exprs.len(),
1628                    has_explicit_columns: !stmt.columns.is_empty(),
1629                });
1630            }
1631
1632            // Build a row with values for all view columns
1633            let mut row_values = vec![vibesql_types::SqlValue::Null; view_schema.columns.len()];
1634
1635            for (expr, (col_idx, _col)) in value_exprs.iter().zip(target_columns.iter()) {
1636                // Evaluate expression - for INSERT, these are typically literals
1637                let value = evaluator.eval(expr, &dummy_row)?;
1638                row_values[*col_idx] = value;
1639            }
1640
1641            collected_rows.push(vibesql_storage::Row::new(row_values));
1642        }
1643        collected_rows
1644    }; // evaluator dropped here
1645
1646    // RETURNING: project the NEW view rows (one per INSTEAD OF trigger fire),
1647    // mirroring the UPDATE/DELETE view semantics. Projected before the
1648    // triggers run since the database must be borrowed mutably below.
1649    let returning_result = if let Some(items) = &stmt.returning {
1650        let row_refs: Vec<&vibesql_storage::Row> = new_rows.iter().collect();
1651        Some(crate::dml_returning::project_returning(
1652            items,
1653            &view_schema,
1654            db,
1655            None,
1656            &row_refs,
1657            cte_results,
1658        )?)
1659    } else {
1660        None
1661    };
1662
1663    // Now fire triggers (database can be mutably borrowed).
1664    //
1665    // RAISE(IGNORE) inside an INSTEAD OF INSERT trigger abandons the view
1666    // operation for that row (#5418): the trigger body stops at the RAISE
1667    // (`execute_trigger` returns SkipRow) and we move on to the next row. On
1668    // SkipRow we `break` out of this row's remaining INSTEAD OF triggers,
1669    // following the first-SkipRow-wins convention of the primary DML loops
1670    // (#5415). Verified against sqlite3 3.51: a RAISE(IGNORE) before the body's
1671    // `INSERT INTO base` skips that base insert while later rows proceed.
1672    let rows_processed = new_rows.len();
1673    for row in new_rows {
1674        for trigger in &triggers {
1675            if crate::TriggerFirer::execute_trigger(db, trigger, None, Some(&row))?
1676                == crate::trigger_execution::TriggerOutcome::SkipRow
1677            {
1678                break;
1679            }
1680        }
1681    }
1682
1683    Ok((rows_processed, returning_result))
1684}
1685
1686/// Build a pseudo TableSchema from a view definition
1687fn build_view_schema(
1688    db: &vibesql_storage::Database,
1689    view_def: &vibesql_catalog::ViewDefinition,
1690) -> Result<vibesql_catalog::TableSchema, ExecutorError> {
1691    // Execute the view's SELECT query to get column names. A table referenced by
1692    // the view body that has since been dropped surfaces here; for a main-schema
1693    // view sqlite3 reports it schema-qualified (`no such table: main.test2`,
1694    // trigger4-3.1/3.2) because the view body resolves against the database
1695    // schema, so qualify the bare name. A temp view's body resolves against the
1696    // temp schema, which sqlite3 reports unqualified, so skip the qualifier
1697    // there. This mirrors the read-path guard in select/scan/table.rs (#5584)
1698    // for read/write parity.
1699    let select_executor = crate::SelectExecutor::new(db);
1700    let select_result = select_executor.execute_with_columns(&view_def.query);
1701    let result = if view_def.is_temp() {
1702        select_result?
1703    } else {
1704        select_result.map_err(ExecutorError::with_main_schema_qualifier)?
1705    };
1706
1707    // Use explicit column names if provided, otherwise derive from SELECT
1708    let column_names: Vec<String> =
1709        if let Some(ref cols) = view_def.columns { cols.clone() } else { result.columns.clone() };
1710
1711    // Build columns with a generic data type (we just need names for trigger binding)
1712    let columns: Vec<vibesql_catalog::ColumnSchema> = column_names
1713        .into_iter()
1714        .map(|name| {
1715            vibesql_catalog::ColumnSchema::new(
1716                name,
1717                vibesql_types::DataType::Varchar { max_length: None },
1718                true,
1719            )
1720        })
1721        .collect();
1722
1723    Ok(vibesql_catalog::TableSchema::new(view_def.name.clone(), columns))
1724}
1725
1726/// Execute INSERT into sqlite_stat1 virtual table
1727///
1728/// This special handler allows users to manually insert statistics for query optimizer tuning,
1729/// matching SQLite's behavior where sqlite_stat1 is writable.
1730///
1731/// sqlite_stat1 schema: (tbl TEXT, idx TEXT, stat TEXT)
1732fn execute_insert_sqlite_stat1(
1733    db: &mut vibesql_storage::Database,
1734    stmt: &vibesql_ast::InsertStmt,
1735) -> Result<usize, ExecutorError> {
1736    use vibesql_ast::InsertSource;
1737
1738    // Get column indices for tbl, idx, stat
1739    // Expected columns: tbl (required), idx (nullable), stat (required)
1740    let columns = &stmt.columns;
1741    let (tbl_idx, idx_idx, stat_idx) = if columns.is_empty() {
1742        // Default order: tbl, idx, stat
1743        (0usize, Some(1usize), 2usize)
1744    } else {
1745        // Find column positions
1746        let tbl_idx = columns.iter().position(|c| c.eq_ignore_ascii_case("tbl"));
1747        let idx_idx = columns.iter().position(|c| c.eq_ignore_ascii_case("idx"));
1748        let stat_idx = columns.iter().position(|c| c.eq_ignore_ascii_case("stat"));
1749
1750        let tbl_idx = tbl_idx.ok_or_else(|| {
1751            ExecutorError::Other("sqlite_stat1 INSERT requires 'tbl' column".to_string())
1752        })?;
1753        let stat_idx = stat_idx.ok_or_else(|| {
1754            ExecutorError::Other("sqlite_stat1 INSERT requires 'stat' column".to_string())
1755        })?;
1756
1757        (tbl_idx, idx_idx, stat_idx)
1758    };
1759
1760    // Process VALUES clause
1761    let values = match &stmt.source {
1762        InsertSource::Values(v) => v,
1763        InsertSource::Select(_) => {
1764            return Err(ExecutorError::Other(
1765                "INSERT INTO sqlite_stat1 ... SELECT is not supported".to_string(),
1766            ));
1767        }
1768        InsertSource::DefaultValues => {
1769            return Err(ExecutorError::Other(
1770                "INSERT INTO sqlite_stat1 DEFAULT VALUES is not supported".to_string(),
1771            ));
1772        }
1773    };
1774
1775    let mut rows_inserted = 0;
1776
1777    for row in values {
1778        // Extract tbl value
1779        let tbl = extract_string_value(&row[tbl_idx]).ok_or_else(|| {
1780            ExecutorError::Other(format!("sqlite_stat1.tbl must be TEXT, got {:?}", row[tbl_idx]))
1781        })?;
1782
1783        // Extract idx value (nullable)
1784        let idx = if let Some(idx_pos) = idx_idx {
1785            if idx_pos < row.len() {
1786                extract_string_value(&row[idx_pos])
1787            } else {
1788                None
1789            }
1790        } else {
1791            None
1792        };
1793
1794        // Extract stat value
1795        let stat = extract_string_value(&row[stat_idx]).ok_or_else(|| {
1796            ExecutorError::Other(format!("sqlite_stat1.stat must be TEXT, got {:?}", row[stat_idx]))
1797        })?;
1798
1799        // Insert into database's sqlite_stat1 storage
1800        db.insert_sqlite_stat1(tbl, idx, stat);
1801        rows_inserted += 1;
1802    }
1803
1804    Ok(rows_inserted)
1805}
1806
1807/// Extract a string value from an expression (for sqlite_stat1 INSERT)
1808fn extract_string_value(expr: &vibesql_ast::Expression) -> Option<String> {
1809    use vibesql_ast::Expression;
1810    use vibesql_types::SqlValue;
1811
1812    match expr {
1813        Expression::Literal(SqlValue::Varchar(s)) => Some(s.to_string()),
1814        Expression::Literal(SqlValue::Null) => None,
1815        // Handle string literals that may not be wrapped in SqlValue
1816        _ => None,
1817    }
1818}