vibesql-executor 0.2.0

Query execution engine for vibesql SQL database
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
//! In-place edits to the verbatim `CREATE TABLE` text stored in
//! `TableSchema::sql_source`, applied on `ALTER TABLE`.
//!
//! SQLite does not reconstruct `sqlite_master.sql` from the parsed schema after
//! an ALTER — it edits the *original* `CREATE TABLE` statement text in place,
//! preserving the user's whitespace and formatting everywhere except the parts
//! the ALTER touches (verified against sqlite3 3.51.0):
//!
//! - `ALTER TABLE t ADD COLUMN c INTEGER`
//!   appends `, c INTEGER` immediately before the closing `)` of the column
//!   list, leaving the rest of the text byte-for-byte unchanged.
//! - `ALTER TABLE t RENAME TO t2`
//!   rewrites the table name to the double-quoted new name (`"t2"`), preserving
//!   everything else.
//! - `ALTER TABLE t RENAME COLUMN b TO bb`
//!   rewrites the column name in its definition position, preserving everything
//!   else.
//!
//! - `ALTER TABLE t DROP COLUMN c`
//!   removes the column's definition span (the dropped column's name through the
//!   start of the next column's name, or — for the last column — from the
//!   preceding comma to the end of the column list), preserving everything else.
//!
//! This module implements those in-place edits at the token level (using the
//! parser's lexer for span tracking, the same approach as
//! `crate::trigger_rename`). The type/constraint-changing ALTER variants
//! (ALTER/MODIFY/CHANGE COLUMN, ADD/DROP CONSTRAINT) remain out of scope here:
//! they fall back to invalidating `sql_source` and reconstructing from the
//! (now-synced) catalog schema, which is correct, just lower fidelity. See
//! issues #5625 and #5634.
//!
//! Every function returns `Option<String>`: `Some(edited)` only when the edit
//! is unambiguous and the structure matches expectations; otherwise `None`, so
//! the caller falls back to invalidate-and-reconstruct. The returned text is
//! always still a valid `CREATE TABLE` statement (the edits only insert a
//! comma-separated column def or swap one identifier), so it remains
//! re-parseable on reload — the staleness-safety invariant from issue #5619.

use vibesql_parser::{Keyword, Lexer, Span, Token};

/// Tokenize `sql`, dropping the trailing `Eof`. Returns `None` if the text
/// cannot be tokenized (it always should, since it round-tripped the parser).
fn tokenize(sql: &str) -> Option<Vec<(Token, Span)>> {
    let mut tokens = Lexer::new(sql).tokenize_with_spans().ok()?;
    if matches!(tokens.last(), Some((Token::Eof, _))) {
        tokens.pop();
    }
    Some(tokens)
}

/// Index of the `(` token that opens the column-definition list of a
/// `CREATE TABLE` statement, and the matching closing `)`.
///
/// The opening paren is the first top-level `(` after the table name. Returns
/// `None` if no balanced top-level paren pair is found (e.g. `CREATE TABLE t AS
/// SELECT ...`, which has no column list to edit).
fn column_list_parens(tokens: &[(Token, Span)]) -> Option<(usize, usize)> {
    let open = tokens.iter().position(|(t, _)| matches!(t, Token::LParen))?;
    let mut depth = 0usize;
    for (i, (tok, _)) in tokens.iter().enumerate().skip(open) {
        match tok {
            Token::LParen => depth += 1,
            Token::RParen => {
                depth -= 1;
                if depth == 0 {
                    return Some((open, i));
                }
            }
            _ => {}
        }
    }
    None
}

/// Append a column definition to the verbatim `CREATE TABLE` text, matching
/// SQLite's `ALTER TABLE ... ADD COLUMN`: insert `, <coldef>` immediately
/// before the closing `)` of the column list, preserving all other formatting.
///
/// `coldef` is the verbatim column-definition text from the ALTER statement
/// (e.g. `c INTEGER`, `d TEXT DEFAULT 'x'`). Returns `None` when the text has no
/// editable column list (the caller then reconstructs instead).
pub fn append_column(create_sql: &str, coldef: &str) -> Option<String> {
    let coldef = coldef.trim();
    if coldef.is_empty() {
        return None;
    }
    let tokens = tokenize(create_sql)?;
    let (_open, close) = column_list_parens(&tokens)?;
    let insert_at = tokens[close].1.start;

    let mut out = String::with_capacity(create_sql.len() + coldef.len() + 2);
    out.push_str(&create_sql[..insert_at]);
    out.push_str(", ");
    out.push_str(coldef);
    out.push_str(&create_sql[insert_at..]);
    Some(out)
}

/// Whether `tok` is an identifier-like token whose text matches `name`
/// (case-insensitively for bare identifiers; delimited identifiers are also
/// matched case-insensitively, since SQLite folds the ALTER target name).
fn ident_matches(tok: &Token, name: &str) -> bool {
    match tok {
        Token::Identifier(s) | Token::DelimitedIdentifier(s) => s.eq_ignore_ascii_case(name),
        _ => false,
    }
}

/// Rewrite the table name in the verbatim `CREATE TABLE` text to the
/// double-quoted `new_name`, matching SQLite's `ALTER TABLE ... RENAME TO`
/// (which quotes the new name and preserves all other formatting).
///
/// The table name is the first identifier token after
/// `CREATE [TEMP|TEMPORARY] TABLE [IF NOT EXISTS]`. Returns `None` if it cannot
/// be located.
///
/// Wired into RENAME TO via `super::alter::table_options::execute_rename_table`
/// (issue #5634). The dump statement splitter is quote-aware, so the emitted
/// `"new_name"` identifier round-trips through `.sql` / `.vbsql` reloads.
pub fn rename_table(create_sql: &str, new_name: &str) -> Option<String> {
    let tokens = tokenize(create_sql)?;
    let name_idx = table_name_index(&tokens)?;
    let span = tokens[name_idx].1;
    Some(replace_span(create_sql, span, &quote_ident(new_name)))
}

/// Index of the table-name identifier token in a `CREATE TABLE` statement.
fn table_name_index(tokens: &[(Token, Span)]) -> Option<usize> {
    let mut i = 0;
    // CREATE
    if !matches!(tokens.get(i), Some((Token::Keyword { keyword: Keyword::Create, .. }, _))) {
        return None;
    }
    i += 1;
    // optional TEMP / TEMPORARY
    if matches!(
        tokens.get(i),
        Some((Token::Keyword { keyword: Keyword::Temp | Keyword::Temporary, .. }, _))
    ) {
        i += 1;
    }
    // TABLE
    if !matches!(tokens.get(i), Some((Token::Keyword { keyword: Keyword::Table, .. }, _))) {
        return None;
    }
    i += 1;
    // optional IF NOT EXISTS
    if matches!(tokens.get(i), Some((Token::Keyword { keyword: Keyword::If, .. }, _)))
        && matches!(tokens.get(i + 1), Some((Token::Keyword { keyword: Keyword::Not, .. }, _)))
        && matches!(tokens.get(i + 2), Some((Token::Keyword { keyword: Keyword::Exists, .. }, _)))
    {
        i += 3;
    }
    // table name (may be schema-qualified: skip `schema .`)
    match tokens.get(i) {
        Some((Token::Identifier(_) | Token::DelimitedIdentifier(_), _)) => {}
        _ => return None,
    }
    if matches!(tokens.get(i + 1), Some((Token::Symbol('.'), _))) {
        i += 2; // skip `schema .`, the real name follows
        if !matches!(tokens.get(i), Some((Token::Identifier(_) | Token::DelimitedIdentifier(_), _)))
        {
            return None;
        }
    }
    Some(i)
}

/// Rewrite a column name in its *definition position* within the verbatim
/// `CREATE TABLE` text, matching SQLite's `ALTER TABLE ... RENAME COLUMN`.
///
/// Only the identifier that names the column at the start of a column definition
/// (the first token of the column list, or the first token after a top-level
/// comma inside the column list) is rewritten — references elsewhere (e.g. in a
/// table-level constraint) are conservatively left untouched. Returns `None`
/// when the definition cannot be located unambiguously, so the caller falls back
/// to reconstruction. `new_name` is emitted bare when it is a safe identifier,
/// otherwise double-quoted.
pub fn rename_column(create_sql: &str, old_col: &str, new_col: &str) -> Option<String> {
    let tokens = tokenize(create_sql)?;
    let (open, close) = column_list_parens(&tokens)?;

    // Walk the column list at paren depth 1, tracking the start of each
    // definition (top-level comma boundaries).
    let mut depth = 0usize;
    let mut at_def_start = false;
    let mut target: Option<usize> = None;
    for (i, (tok, _)) in tokens.iter().enumerate().take(close).skip(open) {
        match tok {
            Token::LParen => {
                if depth == 1 {
                    at_def_start = false;
                }
                depth += 1;
                if depth == 1 {
                    at_def_start = true; // first token inside the column list
                }
            }
            Token::RParen => depth -= 1,
            Token::Comma if depth == 1 => at_def_start = true,
            _ if depth == 1 => {
                if at_def_start {
                    at_def_start = false;
                    if ident_matches(tok, old_col) {
                        if target.is_some() {
                            // Two definitions match: ambiguous; bail.
                            return None;
                        }
                        target = Some(i);
                    }
                }
            }
            _ => {}
        }
    }

    let idx = target?;
    let span = tokens[idx].1;
    let replacement =
        if is_safe_bare_identifier(new_col) { new_col.to_string() } else { quote_ident(new_col) };
    Some(replace_span(create_sql, span, &replacement))
}

/// The byte position where a top-level definition begins inside the column
/// list: the index of the *name token* of a column definition, paired with
/// whether that definition is a column (vs. a table-level constraint, which we
/// leave alone).
struct DefStart {
    /// Index into the token slice of this definition's first token.
    tok_idx: usize,
    /// True when the definition is a column (its first token is an identifier);
    /// false for a table-level constraint (`PRIMARY KEY`, `UNIQUE`, `CHECK`,
    /// `FOREIGN KEY`, `CONSTRAINT …`).
    is_column: bool,
}

/// Collect the start of every top-level definition in the column list (columns
/// and table-level constraints), in source order. Depth-1 only; nested parens
/// (e.g. type sizes, `CHECK(...)`) are skipped.
fn definition_starts(tokens: &[(Token, Span)], open: usize, close: usize) -> Vec<DefStart> {
    let mut defs = Vec::new();
    let mut depth = 0usize;
    let mut at_def_start = false;
    for (i, (tok, _)) in tokens.iter().enumerate().take(close).skip(open) {
        match tok {
            Token::LParen => {
                depth += 1;
                if depth == 1 {
                    at_def_start = true; // first token inside the column list
                }
            }
            Token::RParen => depth -= 1,
            Token::Comma if depth == 1 => at_def_start = true,
            _ if depth == 1 && at_def_start => {
                at_def_start = false;
                let is_column = matches!(tok, Token::Identifier(_) | Token::DelimitedIdentifier(_));
                defs.push(DefStart { tok_idx: i, is_column });
            }
            _ => {}
        }
    }
    defs
}

/// Remove a column definition from the verbatim `CREATE TABLE` text in place,
/// matching SQLite's `ALTER TABLE ... DROP COLUMN` byte-for-byte (verified
/// against sqlite3 3.51.0).
///
/// SQLite deletes the span from the start of the dropped column's *name* to the
/// start of the next column's name. For the last column (no following column) it
/// instead walks backward from the column name to the preceding `,` and deletes
/// from there to the end of the column list (the closing `)` or the first
/// table-level constraint). This preserves the user's surrounding whitespace
/// exactly as SQLite does.
///
/// Returns `None` (so the caller falls back to reconstruction) when the column
/// cannot be located unambiguously, when it is the only/first-of-one column, or
/// when the structure is otherwise unexpected.
pub fn drop_column(create_sql: &str, col: &str) -> Option<String> {
    let tokens = tokenize(create_sql)?;
    let (open, close) = column_list_parens(&tokens)?;
    let defs = definition_starts(&tokens, open, close);

    // Index within `defs` of the target column.
    let mut target: Option<usize> = None;
    for (di, def) in defs.iter().enumerate() {
        if def.is_column && ident_matches(&tokens[def.tok_idx].0, col) {
            if target.is_some() {
                return None; // ambiguous duplicate column name
            }
            target = Some(di);
        }
    }
    let di = target?;

    // Never drop the only remaining column.
    let column_count = defs.iter().filter(|d| d.is_column).count();
    if column_count <= 1 {
        return None;
    }

    let name_start = tokens[defs[di].tok_idx].1.start;

    // The "next column" is the next *column* definition after this one (a table
    // constraint does not count — see the table-constraint cases verified
    // against sqlite3). If there is one, delete `[name_start, next_name_start)`.
    let next_col = defs[di + 1..].iter().find(|d| d.is_column);

    let bytes = create_sql.as_bytes();
    let (del_start, del_end) = if let Some(next) = next_col {
        (name_start, tokens[next.tok_idx].1.start)
    } else {
        // Last column (no following *column*; a table-level constraint may
        // still follow). SQLite deletes from the comma preceding this column up
        // to its `addColOffset` — the point where ADD COLUMN would insert, which
        // is the comma preceding the first table-level constraint, or the
        // closing `)` when there are no constraints. So:
        //   - end: if a table constraint follows, the comma just before it;
        //     otherwise the closing `)`.
        //   - start: walk back to the preceding `,` (matching SQLite's
        //     `while(*z!=',') z--`).
        let end = match defs[di + 1..].first() {
            Some(constraint) => {
                // Walk back from the constraint token over whitespace to the
                // separating comma; that comma (and the column text before it)
                // is what gets removed.
                let mut e = tokens[constraint.tok_idx].1.start;
                while e > 0 && bytes[e - 1] != b',' {
                    e -= 1;
                }
                if e == 0 || bytes[e - 1] != b',' {
                    return None;
                }
                e - 1
            }
            None => tokens[close].1.start,
        };
        let mut start = name_start;
        while start > 0 && bytes[start - 1] != b',' {
            start -= 1;
        }
        if start == 0 || bytes[start - 1] != b',' {
            // No preceding comma found (shouldn't happen for a non-first column,
            // but guards the first-column-with-no-next pathological case).
            return None;
        }
        (start - 1, end)
    };

    let mut out = String::with_capacity(create_sql.len());
    out.push_str(&create_sql[..del_start]);
    out.push_str(&create_sql[del_end..]);
    Some(out)
}

/// Extract the verbatim column-definition text from an
/// `ALTER TABLE ... ADD [COLUMN] <coldef>` statement, so it can be appended to
/// the stored `CREATE TABLE` text exactly as the user typed it (matching
/// SQLite, which appends the original ALTER column text byte-for-byte).
///
/// Returns the substring from the first token after `ADD [COLUMN]` through the
/// last non-`;` token, trimmed. Returns `None` if the `ADD` clause cannot be
/// located.
pub fn extract_add_column_text(alter_sql: &str) -> Option<String> {
    let tokens = tokenize(alter_sql)?;
    let add_pos = tokens
        .iter()
        .position(|(t, _)| matches!(t, Token::Keyword { keyword: Keyword::Add, .. }))?;
    let mut start_tok = add_pos + 1;
    // Optional COLUMN keyword.
    if matches!(tokens.get(start_tok), Some((Token::Keyword { keyword: Keyword::Column, .. }, _))) {
        start_tok += 1;
    }
    let first = tokens.get(start_tok)?;
    let start = first.1.start;
    // Find the end: last token that is not a trailing semicolon.
    let mut end = first.1.end;
    for (tok, span) in tokens.iter().skip(start_tok) {
        if matches!(tok, Token::Semicolon) {
            break;
        }
        end = span.end;
    }
    let text = alter_sql.get(start..end)?.trim();
    if text.is_empty() {
        None
    } else {
        Some(text.to_string())
    }
}

/// Replace the byte range `span` in `sql` with `replacement`.
fn replace_span(sql: &str, span: Span, replacement: &str) -> String {
    let mut out = String::with_capacity(sql.len() + replacement.len());
    out.push_str(&sql[..span.start]);
    out.push_str(replacement);
    out.push_str(&sql[span.end..]);
    out
}

/// Double-quote an identifier (SQLite escapes embedded `"` by doubling).
fn quote_ident(name: &str) -> String {
    format!("\"{}\"", name.replace('"', "\"\""))
}

/// Whether `name` can be emitted as a bare (unquoted) identifier: ASCII
/// alphanumeric/underscore, not starting with a digit, and non-empty. Used so a
/// plain column rename produces `bb` rather than `"bb"`.
fn is_safe_bare_identifier(name: &str) -> bool {
    let mut chars = name.chars();
    match chars.next() {
        Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
        _ => return false,
    }
    chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn append_column_matches_sqlite() {
        let sql = "CREATE TABLE t (\n  a   INTEGER PRIMARY KEY,\n  b   TEXT\n)";
        let out = append_column(sql, "c INTEGER").unwrap();
        assert_eq!(out, "CREATE TABLE t (\n  a   INTEGER PRIMARY KEY,\n  b   TEXT\n, c INTEGER)");
    }

    #[test]
    fn append_column_preserves_default_text() {
        let sql = "CREATE TABLE t (a INTEGER)";
        let out = append_column(sql, "d TEXT DEFAULT 'x'").unwrap();
        assert_eq!(out, "CREATE TABLE t (a INTEGER, d TEXT DEFAULT 'x')");
    }

    #[test]
    fn append_column_no_column_list_returns_none() {
        // CREATE TABLE ... AS SELECT has no editable column list.
        assert!(append_column("CREATE TABLE t AS SELECT 1", "c INTEGER").is_none());
    }

    #[test]
    fn rename_table_quotes_new_name() {
        let sql = "CREATE TABLE t (\n  a   INTEGER PRIMARY KEY,\n  b   TEXT\n)";
        let out = rename_table(sql, "t2").unwrap();
        assert_eq!(out, "CREATE TABLE \"t2\" (\n  a   INTEGER PRIMARY KEY,\n  b   TEXT\n)");
    }

    #[test]
    fn rename_table_handles_quoted_original() {
        let sql = "CREATE TABLE \"My Table\" (x int)";
        let out = rename_table(sql, "t3").unwrap();
        assert_eq!(out, "CREATE TABLE \"t3\" (x int)");
    }

    #[test]
    fn rename_table_if_not_exists() {
        let sql = "CREATE TABLE IF NOT EXISTS t (x int)";
        let out = rename_table(sql, "t2").unwrap();
        assert_eq!(out, "CREATE TABLE IF NOT EXISTS \"t2\" (x int)");
    }

    #[test]
    fn rename_column_in_definition_position() {
        let sql = "CREATE TABLE t (\n  a   INTEGER PRIMARY KEY,\n  b   TEXT\n)";
        let out = rename_column(sql, "b", "bb").unwrap();
        assert_eq!(out, "CREATE TABLE t (\n  a   INTEGER PRIMARY KEY,\n  bb   TEXT\n)");
    }

    #[test]
    fn rename_column_first_column() {
        let sql = "CREATE TABLE t (a INTEGER, b TEXT)";
        let out = rename_column(sql, "a", "aa").unwrap();
        assert_eq!(out, "CREATE TABLE t (aa INTEGER, b TEXT)");
    }

    #[test]
    fn rename_column_quotes_unsafe_name() {
        let sql = "CREATE TABLE t (a INTEGER, b TEXT)";
        let out = rename_column(sql, "b", "new col").unwrap();
        assert_eq!(out, "CREATE TABLE t (a INTEGER, \"new col\" TEXT)");
    }

    #[test]
    fn rename_column_missing_returns_none() {
        let sql = "CREATE TABLE t (a INTEGER, b TEXT)";
        assert!(rename_column(sql, "zzz", "qqq").is_none());
    }

    // DROP COLUMN — byte-for-byte against sqlite3 3.51.0 (verified manually).

    #[test]
    fn drop_column_last_multiline_matches_sqlite() {
        // sqlite3: removes `,\n  c   INTEGER\n` (preceding comma to the `)`).
        let sql = "CREATE TABLE t (\n  a   INTEGER PRIMARY KEY,\n  b   TEXT,\n  c   INTEGER\n)";
        let out = drop_column(sql, "c").unwrap();
        assert_eq!(out, "CREATE TABLE t (\n  a   INTEGER PRIMARY KEY,\n  b   TEXT)");
    }

    #[test]
    fn drop_column_first_matches_sqlite() {
        let sql = "CREATE TABLE t (a INTEGER, b TEXT, c INTEGER)";
        let out = drop_column(sql, "a").unwrap();
        assert_eq!(out, "CREATE TABLE t (b TEXT, c INTEGER)");
    }

    #[test]
    fn drop_column_middle_matches_sqlite() {
        let sql = "CREATE TABLE t (a INTEGER, b TEXT, c INTEGER)";
        let out = drop_column(sql, "b").unwrap();
        assert_eq!(out, "CREATE TABLE t (a INTEGER, c INTEGER)");
    }

    #[test]
    fn drop_column_middle_multiline_matches_sqlite() {
        let sql = "CREATE TABLE t (\n  a INTEGER,\n  b TEXT,\n  c INTEGER\n)";
        let out = drop_column(sql, "b").unwrap();
        assert_eq!(out, "CREATE TABLE t (\n  a INTEGER,\n  c INTEGER\n)");
    }

    #[test]
    fn drop_column_first_with_spaces_matches_sqlite() {
        let sql = "CREATE TABLE t (a INTEGER , b TEXT , c INTEGER)";
        let out = drop_column(sql, "a").unwrap();
        assert_eq!(out, "CREATE TABLE t (b TEXT , c INTEGER)");
    }

    #[test]
    fn drop_column_last_with_spaces_matches_sqlite() {
        let sql = "CREATE TABLE t (a INTEGER , b TEXT , c INTEGER)";
        let out = drop_column(sql, "c").unwrap();
        assert_eq!(out, "CREATE TABLE t (a INTEGER , b TEXT )");
    }

    #[test]
    fn drop_column_before_table_constraint_matches_sqlite() {
        // sqlite3: the column before a table-level constraint is the "last"
        // column; its preceding comma through the constraint's start is removed.
        let sql = "CREATE TABLE t (a INTEGER, b TEXT, c INTEGER, UNIQUE(a))";
        let out = drop_column(sql, "c").unwrap();
        assert_eq!(out, "CREATE TABLE t (a INTEGER, b TEXT, UNIQUE(a))");
    }

    #[test]
    fn drop_column_middle_with_table_constraint_matches_sqlite() {
        let sql = "CREATE TABLE t (a INTEGER, b TEXT, c INTEGER, UNIQUE(c))";
        let out = drop_column(sql, "b").unwrap();
        assert_eq!(out, "CREATE TABLE t (a INTEGER, c INTEGER, UNIQUE(c))");
    }

    #[test]
    fn drop_column_missing_returns_none() {
        let sql = "CREATE TABLE t (a INTEGER, b TEXT)";
        assert!(drop_column(sql, "zzz").is_none());
    }

    #[test]
    fn drop_column_only_column_returns_none() {
        let sql = "CREATE TABLE t (a INTEGER)";
        assert!(drop_column(sql, "a").is_none());
    }

    #[test]
    fn drop_column_quoted_name_target() {
        let sql = "CREATE TABLE t (\"a\" INTEGER, b TEXT)";
        let out = drop_column(sql, "a").unwrap();
        assert_eq!(out, "CREATE TABLE t (b TEXT)");
    }

    #[test]
    fn rename_table_result_reloads_through_splitter() {
        // The in-place RENAME TO output is double-quoted; ensure the result is
        // still a single well-formed CREATE TABLE statement (re-parseable on
        // reload, issue #5619/#5634).
        let sql = "CREATE TABLE t (a INTEGER, b TEXT)";
        let out = rename_table(sql, "t2").unwrap();
        assert_eq!(out, "CREATE TABLE \"t2\" (a INTEGER, b TEXT)");
    }

    #[test]
    fn extract_add_column_text_with_column_keyword() {
        assert_eq!(
            extract_add_column_text("ALTER TABLE t ADD COLUMN c INTEGER").as_deref(),
            Some("c INTEGER")
        );
    }

    #[test]
    fn extract_add_column_text_without_column_keyword() {
        assert_eq!(
            extract_add_column_text("ALTER TABLE t ADD c INTEGER").as_deref(),
            Some("c INTEGER")
        );
    }

    #[test]
    fn extract_add_column_text_preserves_default_and_strips_semicolon() {
        assert_eq!(
            extract_add_column_text("ALTER TABLE t ADD COLUMN d TEXT DEFAULT 'x';").as_deref(),
            Some("d TEXT DEFAULT 'x'")
        );
    }

    #[test]
    fn append_then_extract_matches_sqlite_end_to_end() {
        // ALTER TABLE t ADD COLUMN d TEXT DEFAULT 'x' on the post-ADD-c text.
        let create = "CREATE TABLE t (\n  a   INTEGER PRIMARY KEY,\n  b   TEXT\n, c INTEGER)";
        let coldef =
            extract_add_column_text("ALTER TABLE t ADD COLUMN d TEXT DEFAULT 'x'").unwrap();
        let out = append_column(create, &coldef).unwrap();
        assert_eq!(
            out,
            "CREATE TABLE t (\n  a   INTEGER PRIMARY KEY,\n  b   TEXT\n, c INTEGER, d TEXT DEFAULT 'x')"
        );
    }
}