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}