1#![cfg_attr(
14 any(target_arch = "wasm32", not(feature = "native")),
15 allow(dead_code, unused_imports)
16)]
17
18use std::collections::{HashMap, HashSet};
19#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
20use std::hash::BuildHasher;
21#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
22use std::path::Path;
23use std::sync::Arc;
24
25use fsqlite_ast::{
26 ColumnConstraintKind, CreateTableBody, CreateTableStatement, DefaultValue, Expr,
27 GeneratedStorage, Literal, SortDirection, Statement, TableConstraintKind, TriggerTiming,
28 UnaryOp,
29};
30#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
31use fsqlite_btree::BtreeCursorOps;
32#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
33use fsqlite_btree::cursor::TransactionPageIo;
34use fsqlite_error::{FrankenError, Result};
35#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
36use fsqlite_pager::{MvccPager, SimplePager, TransactionHandle, TransactionMode};
37use fsqlite_parser::Parser;
38use fsqlite_types::StrictColumnType;
39#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
40use fsqlite_types::cx::Cx;
41#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
42use fsqlite_types::record::{
43 RecordProfileScope, enter_record_profile_scope, parse_record, serialize_record,
44};
45use fsqlite_types::value::SqliteValue;
46
47use crate::connection::{
48 ImplicitAutoindexSlot, codegen_error_to_franken, collect_primary_key_desc_flags,
49 column_def_is_exact_integer, implicit_autoindex_layout,
50 validate_builtin_persisted_index_expr_functions,
51};
52#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
53use crate::connection::{eval_join_expr, is_sqlite_truthy};
54use fsqlite_types::{DATABASE_HEADER_SIZE, DatabaseHeader, PageNumber, PageSize};
55use fsqlite_vdbe::codegen::{
56 CheckConstraint, ColumnInfo, FkActionType, FkDef, IndexSchema, TableSchema,
57 bind_explicit_index, without_rowid_pk_indices,
58};
59use fsqlite_vdbe::engine::MemDatabase;
60#[cfg(all(not(target_arch = "wasm32"), feature = "native", unix))]
61use fsqlite_vfs::UnixVfs as PlatformVfs;
62#[cfg(all(not(target_arch = "wasm32"), feature = "native", target_os = "windows"))]
63use fsqlite_vfs::WindowsVfs as PlatformVfs;
64#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
65use fsqlite_vfs::{FileIdentity, host_fs};
66
67#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
69const SQLITE_MAGIC: &[u8; 16] = b"SQLite format 3\0";
70
71#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
73const DEFAULT_PAGE_SIZE: PageSize = PageSize::DEFAULT;
74
75pub type SqliteMasterEntry = (String, String, String, u32, Option<String>);
78
79#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
87fn index_sql_for_persistence<S, F>(
88 index_name: &str,
89 table_name: &str,
90 original_ddl: &HashMap<String, String, S>,
91 synthesize: F,
92) -> Option<String>
93where
94 S: BuildHasher,
95 F: FnOnce() -> String,
96{
97 if let Some(original) = original_ddl.get(&index_name.to_ascii_lowercase()) {
98 return Some(original.clone());
99 }
100 if parse_autoindex_ordinal(index_name, table_name).is_some() {
101 return None;
102 }
103 Some(synthesize())
104}
105
106fn parse_autoindex_ordinal(index_name: &str, table_name: &str) -> Option<usize> {
107 let index_name_lower = index_name.to_ascii_lowercase();
108 let prefix = format!("sqlite_autoindex_{}_", table_name.to_ascii_lowercase());
109 let suffix = index_name_lower.strip_prefix(&prefix)?;
110 if suffix.is_empty() || suffix.starts_with('0') || !suffix.chars().all(|ch| ch.is_ascii_digit())
111 {
112 return None;
113 }
114 suffix.parse::<usize>().ok()
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub(crate) enum BoundImplicitAutoindexStorage {
119 TableRoot,
122 IndexRoot(i32),
124}
125
126#[derive(Debug, Clone)]
127pub(crate) struct BoundImplicitAutoindexSlot {
128 ordinal: usize,
129 slot: ImplicitAutoindexSlot,
130 storage: BoundImplicitAutoindexStorage,
131}
132
133#[derive(Debug, Clone)]
134pub(crate) struct BoundTableAutoindexes {
135 slots: Vec<BoundImplicitAutoindexSlot>,
136}
137
138impl BoundTableAutoindexes {
139 pub(crate) fn implicit_slots(&self) -> impl Iterator<Item = &ImplicitAutoindexSlot> {
140 self.slots.iter().map(|bound| &bound.slot)
141 }
142
143 pub(crate) fn physical_index_schemas(&self, table_name: &str) -> Vec<IndexSchema> {
144 self.slots
145 .iter()
146 .filter_map(|bound| match bound.storage {
147 BoundImplicitAutoindexStorage::TableRoot => None,
148 BoundImplicitAutoindexStorage::IndexRoot(root_page) => Some(
149 bound
150 .slot
151 .clone()
152 .into_index_schema(table_name, bound.ordinal, root_page),
153 ),
154 })
155 .collect()
156 }
157}
158
159#[derive(Debug, Clone)]
160pub(crate) struct BoundImplicitAutoindexCatalog {
161 by_table: HashMap<String, BoundTableAutoindexes>,
162 canonical_virtual_table_rows: HashSet<usize>,
163}
164
165impl BoundImplicitAutoindexCatalog {
166 pub(crate) fn table(&self, table_name: &str) -> Option<&BoundTableAutoindexes> {
167 self.by_table.get(&table_name.to_ascii_lowercase())
168 }
169
170 pub(crate) fn is_canonical_virtual_table_row(&self, row_index: usize) -> bool {
171 self.canonical_virtual_table_rows.contains(&row_index)
172 }
173}
174
175#[derive(Debug, Clone, Copy)]
176struct DecodedSqliteMasterEntry<'a> {
177 entry_type: &'a str,
178 name: &'a str,
179 table_name: &'a str,
180 root_page: i64,
181 sql: Option<&'a str>,
182}
183
184#[derive(Debug)]
185struct PendingTableAutoindexes {
186 table_name: String,
187 slots: Vec<ImplicitAutoindexSlot>,
188 physical_roots: Vec<Option<i32>>,
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192enum SqliteMasterSchemaNameKind {
193 Table,
194 VirtualTable,
195 View,
196 Index,
197}
198
199impl SqliteMasterSchemaNameKind {
200 const fn label(self) -> &'static str {
201 match self {
202 Self::Table => "table",
203 Self::VirtualTable => "virtual table",
204 Self::View => "view",
205 Self::Index => "index",
206 }
207 }
208}
209
210#[derive(Debug)]
211struct SqliteMasterSchemaNameOwner {
212 name: String,
213 kind: SqliteMasterSchemaNameKind,
214}
215
216#[derive(Debug)]
217struct VirtualTableCatalogVariant {
218 row_index: usize,
219 name: String,
220 root_page: i64,
221 module: String,
222 args: Vec<String>,
223}
224
225fn sqlite_master_corrupt(detail: impl Into<String>) -> FrankenError {
226 FrankenError::DatabaseCorrupt {
227 detail: detail.into(),
228 }
229}
230
231fn qualified_catalog_name_targets_main(schema: Option<&str>) -> bool {
232 schema.is_none_or(|schema| schema.eq_ignore_ascii_case("main"))
233}
234
235fn claim_sqlite_master_schema_name(
236 name: &str,
237 kind: SqliteMasterSchemaNameKind,
238 schema_names: &mut HashMap<String, SqliteMasterSchemaNameOwner>,
239) -> Result<()> {
240 let key = name.to_ascii_lowercase();
241 if let Some(existing) = schema_names.get(&key) {
242 return Err(sqlite_master_corrupt(format!(
243 "sqlite_master schema name `{name}` is shared by {} `{}` and {} `{name}`",
244 existing.kind.label(),
245 existing.name,
246 kind.label()
247 )));
248 }
249 schema_names.insert(
250 key,
251 SqliteMasterSchemaNameOwner {
252 name: name.to_owned(),
253 kind,
254 },
255 );
256 Ok(())
257}
258
259fn normalize_virtual_table_option_token(token: &str) -> String {
260 let trimmed = token.trim();
261 if trimmed.len() >= 2 {
262 let bytes = trimmed.as_bytes();
263 let first = bytes[0];
264 let last = bytes[bytes.len() - 1];
265 if matches!((first, last), (b'\'', b'\'') | (b'"', b'"') | (b'`', b'`')) {
266 let body = &trimmed[1..trimmed.len() - 1];
267 let quote = char::from(first);
268 return body
269 .replace(&format!("{quote}{quote}"), "e.to_string())
270 .to_ascii_lowercase();
271 }
272 if first == b'[' && last == b']' {
273 return trimmed[1..trimmed.len() - 1].to_ascii_lowercase();
274 }
275 }
276 trimmed.to_ascii_lowercase()
277}
278
279fn fts5_content_variant(args: &[String]) -> Option<(Option<String>, Vec<&str>)> {
280 let mut content = None;
281 let mut non_content = Vec::with_capacity(args.len());
282 for arg in args {
283 let is_content_option = arg
284 .split_once('=')
285 .is_some_and(|(key, _)| normalize_virtual_table_option_token(key) == "content");
286 if is_content_option {
287 let (_, value) = arg
288 .split_once('=')
289 .expect("content option was identified by an equals sign");
290 if content
291 .replace(normalize_virtual_table_option_token(value))
292 .is_some()
293 {
294 return None;
295 }
296 } else {
297 non_content.push(arg.trim());
298 }
299 }
300 Some((content, non_content))
301}
302
303fn supported_virtual_table_duplicate(
304 existing: &VirtualTableCatalogVariant,
305 root_page: i64,
306 module: &str,
307 args: &[String],
308) -> bool {
309 if existing.module.eq_ignore_ascii_case(module) && existing.args == args {
313 return existing.root_page > 0 || root_page > 0 || module.eq_ignore_ascii_case("fts5");
314 }
315 if existing.root_page != 0
316 || root_page != 0
317 || !existing.module.eq_ignore_ascii_case("fts5")
318 || !module.eq_ignore_ascii_case("fts5")
319 {
320 return false;
321 }
322 let Some((existing_content, existing_non_content)) = fts5_content_variant(&existing.args)
326 else {
327 return false;
328 };
329 let Some((candidate_content, candidate_non_content)) = fts5_content_variant(args) else {
330 return false;
331 };
332 existing_non_content == candidate_non_content
333 && matches!(
334 (existing_content.as_deref(), candidate_content.as_deref()),
335 (None, Some("")) | (Some(""), None)
336 )
337}
338
339fn canonical_virtual_table_variant(
340 variants: &[VirtualTableCatalogVariant],
341) -> &VirtualTableCatalogVariant {
342 variants
343 .iter()
344 .find(|variant| variant.root_page > 0)
345 .or_else(|| {
346 variants.iter().find(|variant| {
347 variant.module.eq_ignore_ascii_case("fts5")
348 && fts5_content_variant(&variant.args)
349 .is_some_and(|(content, _)| content.as_deref() == Some(""))
350 })
351 })
352 .unwrap_or_else(|| {
353 variants
354 .first()
355 .expect("validated virtual-table variant set is non-empty")
356 })
357}
358
359fn claim_sqlite_master_virtual_table(
360 row_index: usize,
361 name: &str,
362 root_page: i64,
363 module: &str,
364 args: &[String],
365 schema_names: &mut HashMap<String, SqliteMasterSchemaNameOwner>,
366 variants: &mut HashMap<String, Vec<VirtualTableCatalogVariant>>,
367) -> Result<()> {
368 let key = name.to_ascii_lowercase();
369 if let Some(existing_variants) = variants.get_mut(&key) {
370 let Some(existing) = existing_variants.first() else {
371 unreachable!("virtual-table variant map entries are never empty");
372 };
373 if existing_variants.len() != 1
374 || (existing.root_page > 0 && root_page > 0)
375 || !supported_virtual_table_duplicate(existing, root_page, module, args)
376 {
377 return Err(sqlite_master_corrupt(format!(
378 "sqlite_master contains conflicting virtual-table entries for `{}` and `{name}`",
379 existing.name
380 )));
381 }
382 existing_variants.push(VirtualTableCatalogVariant {
383 row_index,
384 name: name.to_owned(),
385 root_page,
386 module: module.to_owned(),
387 args: args.to_vec(),
388 });
389 return Ok(());
390 }
391
392 claim_sqlite_master_schema_name(name, SqliteMasterSchemaNameKind::VirtualTable, schema_names)?;
393 variants.insert(
394 key,
395 vec![VirtualTableCatalogVariant {
396 row_index,
397 name: name.to_owned(),
398 root_page,
399 module: module.to_owned(),
400 args: args.to_vec(),
401 }],
402 );
403 Ok(())
404}
405
406fn decode_sqlite_master_entry(
407 entry: &[SqliteValue],
408 row_index: usize,
409) -> Result<DecodedSqliteMasterEntry<'_>> {
410 if entry.len() != 5 {
411 return Err(sqlite_master_corrupt(format!(
412 "sqlite_master row {} has {} columns instead of 5",
413 row_index + 1,
414 entry.len()
415 )));
416 }
417 let text_column = |column_index: usize, column_name: &str| match &entry[column_index] {
418 SqliteValue::Text(value) => Ok(value.as_ref()),
419 value => Err(sqlite_master_corrupt(format!(
420 "sqlite_master row {} column `{column_name}` must be TEXT, found {value:?}",
421 row_index + 1
422 ))),
423 };
424 let entry_type = text_column(0, "type")?;
425 let name = text_column(1, "name")?;
426 let table_name = text_column(2, "tbl_name")?;
427 let root_page = match &entry[3] {
428 SqliteValue::Integer(value) => *value,
429 value => {
430 return Err(sqlite_master_corrupt(format!(
431 "sqlite_master row {} column `rootpage` must be INTEGER, found {value:?}",
432 row_index + 1
433 )));
434 }
435 };
436 let sql = match &entry[4] {
437 SqliteValue::Text(value) => Some(value.as_ref()),
438 SqliteValue::Null => None,
439 value => {
440 return Err(sqlite_master_corrupt(format!(
441 "sqlite_master row {} column `sql` must be TEXT or NULL, found {value:?}",
442 row_index + 1
443 )));
444 }
445 };
446 Ok(DecodedSqliteMasterEntry {
447 entry_type,
448 name,
449 table_name,
450 root_page,
451 sql,
452 })
453}
454
455fn claim_sqlite_master_root(
456 entry_kind: &str,
457 entry_name: &str,
458 root_page: i64,
459 max_root_page: u32,
460 header: &DatabaseHeader,
461 free_pages: &HashSet<PageNumber>,
462 root_owners: &mut HashMap<i32, String>,
463) -> Result<i32> {
464 let root_page_u32 = validate_sqlite_master_root_page(entry_name, root_page)?;
465 let root_page_i32 = i32::try_from(root_page_u32).map_err(|_| {
466 sqlite_master_corrupt(format!(
467 "sqlite_master {entry_kind} `{entry_name}` has unsupported rootpage {root_page}"
468 ))
469 })?;
470 if root_page_i32 >= i32::MAX - 1 {
471 return Err(sqlite_master_corrupt(format!(
472 "sqlite_master {entry_kind} `{entry_name}` has terminal rootpage {root_page}, which leaves no safe MemDatabase allocation sentinel"
473 )));
474 }
475 if root_page_u32 > max_root_page {
476 return Err(sqlite_master_corrupt(format!(
477 "sqlite_master {entry_kind} `{entry_name}` has rootpage {root_page}, which exceeds the visible database page count {max_root_page}"
478 )));
479 }
480 if root_page_u32 == fsqlite_pager::lock_byte_page(header.page_size) {
481 return Err(sqlite_master_corrupt(format!(
482 "sqlite_master {entry_kind} `{entry_name}` uses reserved lock-byte rootpage {root_page}"
483 )));
484 }
485 let root_page_number =
486 PageNumber::new(root_page_u32).expect("validated positive rootpage must be nonzero");
487 if header.largest_root_page != 0
488 && fsqlite_btree::freelist::is_ptrmap_page(
489 root_page_number,
490 header.page_size.usable(header.reserved_per_page),
491 header.page_size.get(),
492 )
493 {
494 return Err(sqlite_master_corrupt(format!(
495 "sqlite_master {entry_kind} `{entry_name}` uses auto-vacuum pointer-map rootpage {root_page}"
496 )));
497 }
498 if free_pages.contains(&root_page_number) {
499 return Err(sqlite_master_corrupt(format!(
500 "sqlite_master {entry_kind} `{entry_name}` uses free rootpage {root_page}"
501 )));
502 }
503 let owner = format!("{entry_kind} `{entry_name}`");
504 if let Some(existing_owner) = root_owners.insert(root_page_i32, owner.clone()) {
505 return Err(sqlite_master_corrupt(format!(
506 "sqlite_master rootpage {root_page_i32} is shared by {existing_owner} and {owner}"
507 )));
508 }
509 Ok(root_page_i32)
510}
511
512pub(crate) fn bind_implicit_autoindex_catalog(
519 master_entries: &[Vec<SqliteValue>],
520 max_root_page: u32,
521 header: &DatabaseHeader,
522 free_pages: &HashSet<PageNumber>,
523) -> Result<BoundImplicitAutoindexCatalog> {
524 if max_root_page == 0 {
525 return Err(sqlite_master_corrupt(
526 "sqlite_master cannot be bound without a visible database page",
527 ));
528 }
529 let decoded = master_entries
530 .iter()
531 .enumerate()
532 .map(|(row_index, entry)| decode_sqlite_master_entry(entry, row_index))
533 .collect::<Result<Vec<_>>>()?;
534 let mut root_owners = HashMap::new();
535 root_owners.insert(1, "sqlite_master".to_owned());
536 let mut pending_by_table = HashMap::<String, PendingTableAutoindexes>::new();
537 let mut schema_names = HashMap::<String, SqliteMasterSchemaNameOwner>::new();
538 let mut trigger_names = HashMap::<String, String>::new();
539 let mut virtual_table_variants = HashMap::<String, Vec<VirtualTableCatalogVariant>>::new();
540 let mut logical_autoindex_names = HashMap::<String, String>::new();
541 let mut pending_trigger_parents = Vec::<(String, String, bool)>::new();
542
543 for (row_index, entry) in decoded.iter().enumerate() {
546 if entry.entry_type.eq_ignore_ascii_case("view") {
547 if entry.root_page != 0 || entry.sql.is_none() {
548 return Err(sqlite_master_corrupt(format!(
549 "sqlite_master view `{}` must have rootpage 0 and non-NULL sql",
550 entry.name
551 )));
552 }
553 if !entry.name.eq_ignore_ascii_case(entry.table_name) {
554 return Err(sqlite_master_corrupt(format!(
555 "sqlite_master view `{}` has mismatched tbl_name `{}`",
556 entry.name, entry.table_name
557 )));
558 }
559 let create_sql = entry.sql.expect("view sql was validated above");
560 let Some(Statement::CreateView(create)) = parse_single_statement(create_sql) else {
561 return Err(sqlite_master_corrupt(format!(
562 "could not parse CREATE VIEW SQL for `{}`",
563 entry.name
564 )));
565 };
566 if create.temporary
567 || !qualified_catalog_name_targets_main(create.name.schema.as_deref())
568 || !create.name.name.eq_ignore_ascii_case(entry.name)
569 {
570 return Err(sqlite_master_corrupt(format!(
571 "CREATE VIEW SQL for `{}` declares a temporary, non-main, or differently named view `{}`",
572 entry.name, create.name.name
573 )));
574 }
575 claim_sqlite_master_schema_name(
576 entry.name,
577 SqliteMasterSchemaNameKind::View,
578 &mut schema_names,
579 )?;
580 continue;
581 }
582 if entry.entry_type.eq_ignore_ascii_case("trigger") {
583 if entry.root_page != 0 || entry.sql.is_none() {
584 return Err(sqlite_master_corrupt(format!(
585 "sqlite_master trigger `{}` must have rootpage 0 and non-NULL sql",
586 entry.name
587 )));
588 }
589 let create_sql = entry.sql.expect("trigger sql was validated above");
590 let Some(Statement::CreateTrigger(create)) = parse_single_statement(create_sql) else {
591 return Err(sqlite_master_corrupt(format!(
592 "could not parse CREATE TRIGGER SQL for `{}`",
593 entry.name
594 )));
595 };
596 if create.temporary
597 || !qualified_catalog_name_targets_main(create.name.schema.as_deref())
598 || !create.name.name.eq_ignore_ascii_case(entry.name)
599 || !create.table.eq_ignore_ascii_case(entry.table_name)
600 {
601 return Err(sqlite_master_corrupt(format!(
602 "CREATE TRIGGER SQL for `{}` does not match its main-catalog name or target `{}`",
603 entry.name, entry.table_name
604 )));
605 }
606 let trigger_key = entry.name.to_ascii_lowercase();
607 if let Some(existing) = trigger_names.insert(trigger_key, entry.name.to_owned()) {
608 return Err(sqlite_master_corrupt(format!(
609 "sqlite_master contains duplicate trigger entries for `{existing}` and `{}`",
610 entry.name
611 )));
612 }
613 pending_trigger_parents.push((
614 entry.name.to_owned(),
615 entry.table_name.to_owned(),
616 matches!(create.timing, TriggerTiming::InsteadOf),
617 ));
618 continue;
619 }
620 if entry.entry_type.eq_ignore_ascii_case("index") {
621 continue;
622 }
623 if !entry.entry_type.eq_ignore_ascii_case("table") {
624 return Err(sqlite_master_corrupt(format!(
625 "sqlite_master entry `{}` has unsupported type `{}`",
626 entry.name, entry.entry_type
627 )));
628 }
629 let Some(create_sql) = entry.sql else {
630 return Err(sqlite_master_corrupt(format!(
631 "sqlite_master table `{}` has NULL sql",
632 entry.name
633 )));
634 };
635 if !entry.name.eq_ignore_ascii_case(entry.table_name) {
636 return Err(sqlite_master_corrupt(format!(
637 "sqlite_master table `{}` has mismatched tbl_name `{}`",
638 entry.name, entry.table_name
639 )));
640 }
641
642 if is_virtual_table_sql(create_sql) {
643 if entry.root_page < 0 {
644 return Err(sqlite_master_corrupt(format!(
645 "sqlite_master virtual table `{}` has invalid rootpage {}",
646 entry.name, entry.root_page
647 )));
648 }
649 let Some(Statement::CreateVirtualTable(create)) = parse_single_statement(create_sql)
650 else {
651 return Err(sqlite_master_corrupt(format!(
652 "could not parse CREATE VIRTUAL TABLE SQL for `{}`",
653 entry.name
654 )));
655 };
656 if !create.name.name.eq_ignore_ascii_case(entry.name) {
657 return Err(sqlite_master_corrupt(format!(
658 "CREATE VIRTUAL TABLE SQL for `{}` declares `{}`",
659 entry.name, create.name.name
660 )));
661 }
662 if !qualified_catalog_name_targets_main(create.name.schema.as_deref()) {
663 return Err(sqlite_master_corrupt(format!(
664 "CREATE VIRTUAL TABLE SQL for `{}` targets a non-main schema",
665 entry.name
666 )));
667 }
668 if entry.root_page > 0 {
669 claim_sqlite_master_root(
670 "virtual table",
671 entry.name,
672 entry.root_page,
673 max_root_page,
674 header,
675 free_pages,
676 &mut root_owners,
677 )?;
678 }
679 claim_sqlite_master_virtual_table(
680 row_index,
681 entry.name,
682 entry.root_page,
683 &create.module,
684 &create.args,
685 &mut schema_names,
686 &mut virtual_table_variants,
687 )?;
688 continue;
689 }
690
691 claim_sqlite_master_root(
692 "table",
693 entry.name,
694 entry.root_page,
695 max_root_page,
696 header,
697 free_pages,
698 &mut root_owners,
699 )?;
700 let Some(Statement::CreateTable(create)) = parse_single_statement(create_sql) else {
701 return Err(sqlite_master_corrupt(format!(
702 "could not parse CREATE TABLE SQL for `{}`",
703 entry.name
704 )));
705 };
706 if create.temporary
707 || !qualified_catalog_name_targets_main(create.name.schema.as_deref())
708 || !create.name.name.eq_ignore_ascii_case(entry.name)
709 || !create.name.name.eq_ignore_ascii_case(entry.table_name)
710 {
711 return Err(sqlite_master_corrupt(format!(
712 "CREATE TABLE SQL for `{}` declares a temporary, non-main, or differently named table `{}`",
713 entry.name, create.name.name
714 )));
715 }
716 let slots = match &create.body {
717 CreateTableBody::Columns {
718 columns,
719 constraints,
720 } => implicit_autoindex_layout(columns, constraints, create.without_rowid).map_err(
721 |error| {
722 sqlite_master_corrupt(format!(
723 "invalid implicit autoindex layout for table `{}`: {error}",
724 entry.name
725 ))
726 },
727 )?,
728 CreateTableBody::AsSelect(_) => {
729 return Err(sqlite_master_corrupt(format!(
730 "sqlite_master table `{}` stores CREATE TABLE AS SELECT instead of a normalized column definition",
731 entry.name
732 )));
733 }
734 };
735 let slot_count = slots.len();
736 for ordinal in 1..=slot_count {
737 let logical_name = format!("sqlite_autoindex_{}_{ordinal}", entry.name);
738 logical_autoindex_names.insert(logical_name.to_ascii_lowercase(), logical_name);
739 }
740 let key = entry.name.to_ascii_lowercase();
741 let pending = PendingTableAutoindexes {
742 table_name: entry.name.to_owned(),
743 slots,
744 physical_roots: vec![None; slot_count],
745 };
746 if let Some(existing) = pending_by_table.insert(key, pending) {
747 return Err(sqlite_master_corrupt(format!(
748 "sqlite_master contains duplicate table entries for `{}` and `{}`",
749 existing.table_name, entry.name
750 )));
751 }
752 claim_sqlite_master_schema_name(
753 entry.name,
754 SqliteMasterSchemaNameKind::Table,
755 &mut schema_names,
756 )?;
757 }
758
759 for (trigger_name, table_name, requires_view) in pending_trigger_parents {
760 let target = schema_names.get(&table_name.to_ascii_lowercase());
761 let target_is_view =
762 target.is_some_and(|owner| owner.kind == SqliteMasterSchemaNameKind::View);
763 let target_is_table = target.is_some_and(|owner| {
764 matches!(
765 owner.kind,
766 SqliteMasterSchemaNameKind::Table | SqliteMasterSchemaNameKind::VirtualTable
767 )
768 });
769 if (!requires_view && !target_is_table) || (requires_view && !target_is_view) {
770 let expected_kind = if requires_view { "view" } else { "table" };
771 return Err(sqlite_master_corrupt(format!(
772 "trigger `{trigger_name}` refers to missing or incompatible {expected_kind} `{table_name}`"
773 )));
774 }
775 }
776
777 let mut index_names = HashMap::<String, String>::new();
778 for entry in &decoded {
779 if !entry.entry_type.eq_ignore_ascii_case("index") {
780 continue;
781 }
782 let index_key = entry.name.to_ascii_lowercase();
783 let logical_autoindex_name = logical_autoindex_names.get(&index_key);
784 if let Some(existing_name) = index_names.insert(index_key, entry.name.to_owned()) {
785 return Err(sqlite_master_corrupt(format!(
786 "sqlite_master contains duplicate index entries for `{existing_name}` and `{}`",
787 entry.name
788 )));
789 }
790 claim_sqlite_master_schema_name(
791 entry.name,
792 SqliteMasterSchemaNameKind::Index,
793 &mut schema_names,
794 )?;
795 let root_page = claim_sqlite_master_root(
796 "index",
797 entry.name,
798 entry.root_page,
799 max_root_page,
800 header,
801 free_pages,
802 &mut root_owners,
803 )?;
804 let table_key = entry.table_name.to_ascii_lowercase();
805 if !pending_by_table.contains_key(&table_key) {
806 return Err(sqlite_master_corrupt(format!(
807 "index `{}` refers to missing ordinary table `{}`",
808 entry.name, entry.table_name
809 )));
810 }
811 if let Some(create_sql) = entry.sql {
816 if let Some(logical_name) = logical_autoindex_name {
817 return Err(sqlite_master_corrupt(format!(
818 "explicit index `{}` collides with logical implicit index `{logical_name}`",
819 entry.name
820 )));
821 }
822 let Some(Statement::CreateIndex(create)) = parse_single_statement(create_sql) else {
823 return Err(sqlite_master_corrupt(format!(
824 "could not parse CREATE INDEX SQL for `{}`",
825 entry.name
826 )));
827 };
828 if !create.name.name.eq_ignore_ascii_case(entry.name)
829 || !create.table.eq_ignore_ascii_case(entry.table_name)
830 || !qualified_catalog_name_targets_main(create.name.schema.as_deref())
831 {
832 return Err(sqlite_master_corrupt(format!(
833 "CREATE INDEX SQL for `{}` declares index `{}` on table `{}` instead of `{}`",
834 entry.name, create.name.name, create.table, entry.table_name
835 )));
836 }
837 continue;
838 }
839
840 let Some(table) = pending_by_table.get_mut(&table_key) else {
841 unreachable!("ordinary index parent was validated above");
842 };
843 let Some(ordinal) = parse_autoindex_ordinal(entry.name, entry.table_name) else {
844 return Err(sqlite_master_corrupt(format!(
845 "implicit index `{}` does not have a canonical autoindex name for table `{}`",
846 entry.name, entry.table_name
847 )));
848 };
849 let Some(slot_index) = ordinal.checked_sub(1) else {
850 return Err(sqlite_master_corrupt(format!(
851 "implicit index `{}` has invalid ordinal {ordinal}",
852 entry.name
853 )));
854 };
855 let Some(slot) = table.slots.get(slot_index) else {
856 return Err(sqlite_master_corrupt(format!(
857 "implicit index `{}` selects nonexistent declaration slot {ordinal} on table `{}`",
858 entry.name, table.table_name
859 )));
860 };
861 if slot.is_hidden_without_rowid_primary_key() {
862 return Err(sqlite_master_corrupt(format!(
863 "implicit index `{}` illegally materializes hidden WITHOUT ROWID primary-key slot {ordinal}",
864 entry.name
865 )));
866 }
867 let root = table
868 .physical_roots
869 .get_mut(slot_index)
870 .expect("validated implicit autoindex slot must have a root binding");
871 if root.replace(root_page).is_some() {
872 return Err(sqlite_master_corrupt(format!(
873 "implicit autoindex slot {ordinal} on table `{}` is bound more than once",
874 table.table_name
875 )));
876 }
877 }
878
879 let mut by_table = HashMap::with_capacity(pending_by_table.len());
880 for (table_key, pending) in pending_by_table {
881 let mut bound_slots = Vec::with_capacity(pending.slots.len());
882 for (slot_index, slot) in pending.slots.into_iter().enumerate() {
883 let ordinal = slot_index + 1;
884 let storage = if slot.is_hidden_without_rowid_primary_key() {
885 BoundImplicitAutoindexStorage::TableRoot
886 } else {
887 let root_page = pending.physical_roots[slot_index].ok_or_else(|| {
888 sqlite_master_corrupt(format!(
889 "sqlite_master is missing implicit autoindex slot {ordinal} for table `{}`",
890 pending.table_name
891 ))
892 })?;
893 BoundImplicitAutoindexStorage::IndexRoot(root_page)
894 };
895 bound_slots.push(BoundImplicitAutoindexSlot {
896 ordinal,
897 slot,
898 storage,
899 });
900 }
901 by_table.insert(table_key, BoundTableAutoindexes { slots: bound_slots });
902 }
903
904 let canonical_virtual_table_rows = virtual_table_variants
905 .values()
906 .map(|variants| canonical_virtual_table_variant(variants).row_index)
907 .collect();
908
909 Ok(BoundImplicitAutoindexCatalog {
910 by_table,
911 canonical_virtual_table_rows,
912 })
913}
914
915#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
916fn load_sqlite_header_from_page1(page1_bytes: &[u8]) -> Result<DatabaseHeader> {
917 let header_bytes: &[u8; DATABASE_HEADER_SIZE] = page1_bytes
918 .get(..DATABASE_HEADER_SIZE)
919 .ok_or_else(|| FrankenError::DatabaseCorrupt {
920 detail: format!(
921 "database header truncated: expected at least {DATABASE_HEADER_SIZE} bytes, found {}",
922 page1_bytes.len()
923 ),
924 })?
925 .try_into()
926 .map_err(|_| FrankenError::DatabaseCorrupt {
927 detail: "database header is not a fixed-size 100-byte prefix".to_owned(),
928 })?;
929 DatabaseHeader::from_bytes(header_bytes).map_err(|error| FrankenError::DatabaseCorrupt {
930 detail: format!("invalid database header: {error}"),
931 })
932}
933
934#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
935fn configure_btree_cursor_page_size<P: fsqlite_btree::PageReader>(
936 cursor: &mut fsqlite_btree::BtCursor<P>,
937 usable_size: u32,
938 page_size: u32,
939) {
940 if page_size != usable_size {
941 cursor.set_page_size(page_size);
942 }
943}
944
945#[derive(Debug)]
949pub struct LoadedState {
950 pub schema: Vec<TableSchema>,
952 pub db: MemDatabase,
954 pub master_row_count: i64,
957 pub schema_cookie: u32,
959 pub change_counter: u32,
961}
962
963#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
967pub fn is_sqlite_format(path: &Path) -> bool {
968 let Ok(data) = host_fs::read(path) else {
969 return false;
970 };
971 data.len() >= SQLITE_MAGIC.len() && data[..SQLITE_MAGIC.len()] == *SQLITE_MAGIC
972}
973
974#[allow(clippy::too_many_lines)]
985#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
986pub async fn persist_to_sqlite(
987 cx: &Cx,
988 path: &Path,
989 schema: &[TableSchema],
990 db: &MemDatabase,
991 schema_cookie: u32,
992 change_counter: u32,
993) -> Result<()> {
994 let mut header = DatabaseHeader {
995 page_size: DEFAULT_PAGE_SIZE,
996 schema_cookie,
997 change_counter,
998 ..DatabaseHeader::default()
999 };
1000 let effective_counter = header.change_counter.max(1);
1001 header.change_counter = effective_counter;
1002 header.schema_cookie = header.schema_cookie.max(1);
1003 header.version_valid_for = effective_counter;
1004 persist_to_sqlite_with_header(cx, path, schema, db, &header).await
1005}
1006
1007#[allow(clippy::too_many_lines)]
1012#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
1013pub async fn persist_to_sqlite_with_header(
1014 cx: &Cx,
1015 path: &Path,
1016 schema: &[TableSchema],
1017 db: &MemDatabase,
1018 header_template: &DatabaseHeader,
1019) -> Result<()> {
1020 persist_to_sqlite_with_header_and_master_entries(
1021 cx,
1022 path,
1023 schema,
1024 db,
1025 header_template,
1026 &[],
1027 &HashMap::new(),
1028 )
1029 .await
1030}
1031
1032#[allow(clippy::too_many_lines)]
1035#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
1036pub async fn persist_to_sqlite_with_header_and_master_entries<S: BuildHasher>(
1037 cx: &Cx,
1038 path: &Path,
1039 schema: &[TableSchema],
1040 db: &MemDatabase,
1041 header_template: &DatabaseHeader,
1042 extra_master_entries: &[SqliteMasterEntry],
1043 original_ddl: &HashMap<String, String, S>,
1044) -> Result<()> {
1045 persist_to_sqlite_with_header_and_master_entries_impl(
1046 cx,
1047 path,
1048 schema,
1049 db,
1050 header_template,
1051 extra_master_entries,
1052 original_ddl,
1053 None,
1054 )
1055 .await
1056}
1057
1058#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
1064#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
1065pub async fn persist_to_reserved_sqlite_with_header_and_master_entries<S: BuildHasher>(
1066 cx: &Cx,
1067 path: &Path,
1068 expected_identity: FileIdentity,
1069 schema: &[TableSchema],
1070 db: &MemDatabase,
1071 header_template: &DatabaseHeader,
1072 extra_master_entries: &[SqliteMasterEntry],
1073 original_ddl: &HashMap<String, String, S>,
1074) -> Result<()> {
1075 persist_to_sqlite_with_header_and_master_entries_impl(
1076 cx,
1077 path,
1078 schema,
1079 db,
1080 header_template,
1081 extra_master_entries,
1082 original_ddl,
1083 Some(expected_identity),
1084 )
1085 .await
1086}
1087
1088#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
1089#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
1090async fn persist_to_sqlite_with_header_and_master_entries_impl<S: BuildHasher>(
1091 cx: &Cx,
1092 path: &Path,
1093 schema: &[TableSchema],
1094 db: &MemDatabase,
1095 header_template: &DatabaseHeader,
1096 extra_master_entries: &[SqliteMasterEntry],
1097 original_ddl: &HashMap<String, String, S>,
1098 expected_empty_identity: Option<FileIdentity>,
1099) -> Result<()> {
1100 if expected_empty_identity.is_none() && path.exists() {
1101 host_fs::create_empty_file(path)?;
1104 }
1105
1106 let vfs = PlatformVfs::new();
1107 let pager = if let Some(expected_identity) = expected_empty_identity {
1108 SimplePager::open_reserved_with_cx_and_page_buffer_max(
1109 cx,
1110 vfs,
1111 path,
1112 header_template.page_size,
1113 expected_identity,
1114 None,
1115 )
1116 .await?
1117 } else {
1118 SimplePager::open_with_cx(cx, vfs, path, header_template.page_size).await?
1119 };
1120 let mut txn = pager.begin(cx, TransactionMode::Immediate).await?;
1121
1122 let page_size = header_template.page_size;
1123 let page_size_usize = page_size.as_usize();
1124 let usable_size = page_size.usable(header_template.reserved_per_page);
1125 let full_page_size = page_size.get();
1126
1127 let mut master_entries: Vec<SqliteMasterEntry> = Vec::new();
1132
1133 for table in schema {
1135 let Some(mem_table) = db.get_table(table.root_page) else {
1136 continue;
1137 };
1138
1139 let create_sql = original_ddl
1149 .get(&table.name.to_ascii_lowercase())
1150 .cloned()
1151 .unwrap_or_else(|| {
1152 build_create_table_sql_with_implicit_index_predicate(table, |index| {
1153 parse_autoindex_ordinal(&index.name, &table.name).is_some()
1154 && !original_ddl.contains_key(&index.name.to_ascii_lowercase())
1155 })
1156 });
1157
1158 let without_rowid_pk = if table.without_rowid {
1163 Some(resolve_without_rowid_pk_layout(table, &create_sql)?)
1164 } else {
1165 None
1166 };
1167
1168 let root_page = txn.allocate_page(cx).await?;
1170
1171 if let Some(pk) = without_rowid_pk.as_ref() {
1172 init_leaf_index_page(cx, &mut txn, root_page, page_size_usize, usable_size).await?;
1177
1178 let mut cursor = fsqlite_btree::BtCursor::new_with_index_desc(
1179 TransactionPageIo::new(&mut txn),
1180 root_page,
1181 usable_size,
1182 false,
1185 pk.desc_flags.clone(),
1186 );
1187 let collation_registry = cursor.collation_registry();
1188 {
1195 let registry = collation_registry.lock().map_err(|_| {
1196 FrankenError::internal(
1197 "collation registry lock poisoned while rebuilding a WITHOUT ROWID table"
1198 .to_owned(),
1199 )
1200 })?;
1201 for collation in pk.collations.iter().flatten() {
1202 if !registry.contains(collation) {
1203 return Err(FrankenError::not_implemented(format!(
1204 "WITHOUT ROWID table `{}` declares primary-key collation \
1205 `{collation}`, which is not available to the compatibility \
1206 builder; rebuilding it would order the table by BINARY and \
1207 contradict its own declaration",
1208 table.name
1209 )));
1210 }
1211 }
1212 }
1213 cursor.set_index_collation_context(pk.collations.clone(), collation_registry);
1214 configure_btree_cursor_page_size(&mut cursor, usable_size, full_page_size);
1215
1216 for (_synthetic_rowid, values) in mem_table.iter_rows() {
1217 cursor.index_insert(cx, &serialize_record(values)).await?;
1223 }
1224 } else {
1225 init_leaf_table_page(cx, &mut txn, root_page, page_size_usize, usable_size).await?;
1227
1228 let mut cursor = fsqlite_btree::BtCursor::new(
1230 TransactionPageIo::new(&mut txn),
1231 root_page,
1232 usable_size,
1233 true,
1234 );
1235 configure_btree_cursor_page_size(&mut cursor, usable_size, full_page_size);
1236 for (rowid, values) in mem_table.iter_rows() {
1237 let payload = serialize_record(values);
1238 cursor.table_insert(cx, rowid, &payload).await?;
1239 }
1240 }
1241
1242 let table_name = table.name.clone();
1243 master_entries.push((
1244 "table".to_owned(),
1245 table_name.clone(),
1246 table_name.clone(),
1247 root_page.get(),
1248 Some(create_sql),
1249 ));
1250
1251 let col_map: Vec<(String, String, bool)> = table
1254 .columns
1255 .iter()
1256 .map(|c| (table.name.clone(), c.name.clone(), false))
1257 .collect();
1258
1259 for index in &table.indexes {
1266 let is_expression_index = index.columns.is_empty() && !index.key_expressions.is_empty();
1267 if index.columns.is_empty() && !is_expression_index {
1268 continue;
1269 }
1270 let key_exprs = if is_expression_index {
1271 index
1272 .key_expressions
1273 .iter()
1274 .map(|expr| {
1275 fsqlite_parser::expr::parse_expr(expr).map_err(|err| {
1276 FrankenError::Internal(format!(
1277 "failed to parse expression index term `{expr}` while persisting `{}`: {err}",
1278 index.name
1279 ))
1280 })
1281 })
1282 .collect::<Result<Vec<_>>>()?
1283 } else {
1284 Vec::new()
1285 };
1286 let idx_root = txn.allocate_page(cx).await?;
1288 init_leaf_index_page(cx, &mut txn, idx_root, page_size_usize, usable_size).await?;
1289
1290 let partial_predicate = index
1293 .where_clause
1294 .as_deref()
1295 .map(fsqlite_parser::expr::parse_expr)
1296 .transpose()
1297 .ok()
1298 .flatten();
1299
1300 {
1302 let key_terms = if is_expression_index {
1341 index.key_expressions.len()
1342 } else {
1343 index.columns.len()
1344 };
1345 let mut index_desc_flags: Vec<bool> = (0..key_terms)
1346 .map(|term| {
1347 index.key_sort_directions.get(term).copied() == Some(SortDirection::Desc)
1348 })
1349 .collect();
1350 let mut index_collations: Vec<Option<String>> = (0..key_terms)
1351 .map(|term| index.key_collations.get(term).cloned().flatten())
1352 .collect();
1353 if let Some(pk) = without_rowid_pk.as_ref() {
1361 index_desc_flags.extend(pk.desc_flags.iter().copied());
1362 index_collations.extend(pk.collations.iter().cloned());
1363 } else {
1364 index_desc_flags.push(false);
1365 index_collations.push(None);
1366 }
1367
1368 let mut idx_cursor = fsqlite_btree::BtCursor::new_with_index_desc(
1369 TransactionPageIo::new(&mut txn),
1370 idx_root,
1371 usable_size,
1372 true,
1373 index_desc_flags,
1374 );
1375 let collation_registry = idx_cursor.collation_registry();
1376 {
1410 let registry = collation_registry.lock().map_err(|_| {
1411 FrankenError::internal(
1412 "collation registry lock poisoned while rebuilding an index".to_owned(),
1413 )
1414 })?;
1415 for collation in index_collations.iter().flatten() {
1416 if !registry.contains(collation) {
1417 return Err(FrankenError::not_implemented(format!(
1418 "index `{}` declares collation `{collation}`, which is not \
1419 available to the compatibility index builder; rebuilding it \
1420 would order the index by BINARY and contradict its own \
1421 declaration",
1422 index.name
1423 )));
1424 }
1425 }
1426 }
1427 idx_cursor.set_index_collation_context(index_collations, collation_registry);
1428 configure_btree_cursor_page_size(&mut idx_cursor, usable_size, full_page_size);
1429 if let Some(mem_table) = db.get_table(table.root_page) {
1430 for (rowid, values) in mem_table.iter_rows() {
1431 if let Some(ref predicate) = partial_predicate
1435 && let Ok(result) = eval_join_expr(predicate, values, &col_map)
1436 && !is_sqlite_truthy(&result)
1437 {
1438 continue;
1439 }
1440
1441 let mut key_values: Vec<SqliteValue> = Vec::new();
1443 if is_expression_index {
1444 for expr in &key_exprs {
1445 key_values.push(eval_join_expr(expr, values, &col_map)?);
1446 }
1447 } else {
1448 for col_name in &index.columns {
1449 let col_idx = table
1450 .columns
1451 .iter()
1452 .position(|c| c.name.eq_ignore_ascii_case(col_name));
1453 if let Some(idx) = col_idx {
1454 key_values.push(
1455 values.get(idx).cloned().unwrap_or(SqliteValue::Null),
1456 );
1457 } else {
1458 key_values.push(SqliteValue::Null);
1459 }
1460 }
1461 }
1462 if let Some(pk) = without_rowid_pk.as_ref() {
1469 for &col_idx in &pk.indices {
1470 key_values.push(
1471 values.get(col_idx).cloned().unwrap_or(SqliteValue::Null),
1472 );
1473 }
1474 } else {
1475 key_values.push(SqliteValue::Integer(rowid));
1476 }
1477 let key = serialize_record(&key_values);
1478 idx_cursor.index_insert(cx, &key).await?;
1479 }
1480 }
1481 }
1482
1483 let idx_sql = index_sql_for_persistence(&index.name, &table_name, original_ddl, || {
1490 if is_expression_index {
1491 build_create_expression_index_sql(
1492 &index.name,
1493 &table_name,
1494 index.is_unique,
1495 &index.key_expressions,
1496 &index.key_collations,
1497 &index.key_sort_directions,
1498 index.where_clause.as_deref(),
1499 )
1500 } else {
1501 let terms: Vec<CreateIndexSqlTerm<'_>> = index
1502 .columns
1503 .iter()
1504 .enumerate()
1505 .map(|(i, col)| CreateIndexSqlTerm {
1506 column_name: col.as_str(),
1507 collation: index.key_collations.get(i).and_then(|c| c.as_deref()),
1508 direction: index.key_sort_directions.get(i).copied(),
1509 })
1510 .collect();
1511 let sql = build_create_index_sql(
1512 &index.name,
1513 &table_name,
1514 index.is_unique,
1515 &terms,
1516 None,
1517 );
1518 if let Some(ref wc) = index.where_clause {
1519 format!("{sql} WHERE {wc}")
1520 } else {
1521 sql
1522 }
1523 }
1524 });
1525 master_entries.push((
1526 "index".to_owned(),
1527 index.name.clone(),
1528 table_name.clone(),
1529 idx_root.get(),
1530 idx_sql,
1531 ));
1532 }
1533 }
1534
1535 master_entries.extend(extra_master_entries.iter().cloned());
1536
1537 {
1540 let mut page1 = txn.get_page(cx, PageNumber::ONE).await?.into_vec();
1541 if page1.len() < DATABASE_HEADER_SIZE + 8 {
1542 return Err(FrankenError::internal(format!(
1543 "page 1 too short for sqlite_master root header: {} bytes",
1544 page1.len()
1545 )));
1546 }
1547 page1[DATABASE_HEADER_SIZE] = 0x0D;
1548 page1[DATABASE_HEADER_SIZE + 3..DATABASE_HEADER_SIZE + 5]
1549 .copy_from_slice(&0u16.to_be_bytes());
1550 let master_content_start: u16 = if usable_size == 65536 {
1551 0
1552 } else {
1553 u16::try_from(usable_size).map_err(|_| {
1554 FrankenError::internal(format!(
1555 "usable_size {usable_size} does not fit in u16 and is not 65536"
1556 ))
1557 })?
1558 };
1559 page1[DATABASE_HEADER_SIZE + 5..DATABASE_HEADER_SIZE + 7]
1560 .copy_from_slice(&master_content_start.to_be_bytes());
1561 txn.write_page(cx, PageNumber::ONE, &page1).await?;
1562
1563 let master_root = PageNumber::ONE;
1564 let mut cursor = fsqlite_btree::BtCursor::new(
1565 TransactionPageIo::new(&mut txn),
1566 master_root,
1567 usable_size,
1568 true,
1569 );
1570 configure_btree_cursor_page_size(&mut cursor, usable_size, full_page_size);
1571
1572 for (rowid, (entry_type, name, tbl_name, root_page_num, create_sql)) in
1573 master_entries.iter().enumerate()
1574 {
1575 let sql_value = match create_sql {
1576 Some(sql) => SqliteValue::Text(sql.clone().into()),
1577 None => SqliteValue::Null,
1578 };
1579 let record = serialize_record(&[
1580 SqliteValue::Text(entry_type.clone().into()),
1581 SqliteValue::Text(name.clone().into()),
1582 SqliteValue::Text(tbl_name.clone().into()),
1583 SqliteValue::Integer(i64::from(*root_page_num)),
1584 sql_value,
1585 ]);
1586 #[allow(clippy::cast_possible_wrap)]
1587 let rid = (rowid as i64) + 1;
1588 cursor.table_insert(cx, rid, &record).await?;
1589 }
1590 }
1591
1592 {
1595 let mut hdr_page = txn.get_page(cx, PageNumber::ONE).await?.into_vec();
1596
1597 let next_page = txn.allocate_page(cx).await?.get();
1603 let max_page = next_page.saturating_sub(1).max(1);
1604
1605 let mut final_header = header_template.clone();
1606 final_header.page_count = max_page;
1607 final_header.freelist_trunk = 0;
1608 final_header.freelist_count = 0;
1609 final_header.change_counter = final_header.change_counter.max(1);
1610 final_header.schema_cookie = final_header.schema_cookie.max(1);
1611 final_header.version_valid_for = final_header.change_counter;
1612
1613 let encoded_header = final_header.to_bytes().map_err(|err| {
1614 FrankenError::internal(format!("failed to encode database header: {err}"))
1615 })?;
1616 hdr_page[..DATABASE_HEADER_SIZE].copy_from_slice(&encoded_header);
1617
1618 txn.write_page(cx, PageNumber::ONE, &hdr_page).await?;
1619 }
1620
1621 txn.commit(cx).await?;
1622 Ok(())
1623}
1624
1625#[allow(clippy::too_many_lines, clippy::similar_names)]
1637#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
1638pub async fn load_from_sqlite(cx: &Cx, path: &Path) -> Result<LoadedState> {
1639 let _record_profile_scope = enter_record_profile_scope(RecordProfileScope::CoreCompatPersist);
1640 let vfs = PlatformVfs::new();
1641 let pager = Arc::new(SimplePager::open_with_cx(cx, vfs, path, DEFAULT_PAGE_SIZE).await?);
1642 let wal_path = crate::connection::wal_path_for_db_path(&path.to_string_lossy());
1648 crate::connection::install_wal_backend_with_vfs(&pager, PlatformVfs::new(), cx, &wal_path)
1649 .await?;
1650 let mut txn = pager.begin(cx, TransactionMode::ReadOnly).await?;
1651 let max_root_page = txn.snapshot_db_size();
1652 let page1 = txn.get_page(cx, PageNumber::ONE).await?;
1653 let header = load_sqlite_header_from_page1(page1.as_ref())?;
1654 let usable_size = header.page_size.usable(header.reserved_per_page);
1655 let page_size = header.page_size.get();
1656
1657 let master_entries = {
1659 let mut entries = Vec::new();
1660 let master_root = PageNumber::ONE;
1661 let mut cursor = fsqlite_btree::BtCursor::new(
1662 TransactionPageIo::new(&mut txn),
1663 master_root,
1664 usable_size,
1665 true,
1666 );
1667 configure_btree_cursor_page_size(&mut cursor, usable_size, page_size);
1668
1669 if cursor.first(cx).await? {
1670 let mut payload_buf: Vec<u8> = Vec::new();
1671 loop {
1672 payload_buf.clear();
1677 let rowid = cursor.rowid_and_payload_into(cx, &mut payload_buf).await?;
1678 let values =
1679 parse_record(&payload_buf).ok_or_else(|| FrankenError::DatabaseCorrupt {
1680 detail: format!(
1681 "sqlite_master row {rowid} payload is not a valid SQLite record"
1682 ),
1683 })?;
1684 entries.push(values);
1685 if !cursor.next(cx).await? {
1686 break;
1687 }
1688 }
1689 }
1690 entries
1691 };
1692
1693 let free_pages = txn.live_freelist_pages().into_iter().collect();
1694 let bound_implicit_autoindexes =
1695 bind_implicit_autoindex_catalog(&master_entries, max_root_page, &header, &free_pages)?;
1696
1697 let materialized_virtual_tables: HashSet<String> = master_entries
1700 .iter()
1701 .filter_map(|entry| {
1702 if entry.len() < 5 {
1703 return None;
1704 }
1705 let entry_type = match &entry[0] {
1706 SqliteValue::Text(s) => s,
1707 _ => return None,
1708 };
1709 if !entry_type.eq_ignore_ascii_case("table") {
1710 return None;
1711 }
1712 let name = match &entry[1] {
1713 SqliteValue::Text(s) => s,
1714 _ => return None,
1715 };
1716 let root_page_num = match &entry[3] {
1717 SqliteValue::Integer(n) => *n,
1718 _ => return None,
1719 };
1720 let create_sql = match &entry[4] {
1721 SqliteValue::Text(s) => s,
1722 _ => return None,
1723 };
1724 if root_page_num > 0 && is_virtual_table_sql(create_sql) {
1725 Some(name.to_ascii_lowercase())
1726 } else {
1727 None
1728 }
1729 })
1730 .collect();
1731 let mut schema = Vec::new();
1732 let mut db = MemDatabase::new();
1733
1734 for entry in &master_entries {
1735 if entry.len() < 5 {
1736 continue;
1737 }
1738 let entry_type = match &entry[0] {
1739 SqliteValue::Text(s) => s,
1740 _ => continue,
1741 };
1742 if !entry_type.eq_ignore_ascii_case("table") {
1743 continue; }
1745
1746 let name = match &entry[1] {
1747 SqliteValue::Text(s) => s.clone(),
1748 _ => continue,
1749 };
1750 let root_page_num = match &entry[3] {
1751 SqliteValue::Integer(n) => *n,
1752 _ => continue,
1753 };
1754 let create_sql = match &entry[4] {
1755 SqliteValue::Text(s) => s.clone(),
1756 _ => continue,
1757 };
1758
1759 let is_virtual_table = is_virtual_table_sql(&create_sql);
1764 if root_page_num == 0 && is_virtual_table {
1765 let _shadowed_by_materialized =
1766 materialized_virtual_tables.contains(&name.to_ascii_lowercase());
1767 continue;
1768 }
1769 let root_page_u32 = validate_sqlite_master_root_page(&name, root_page_num)?;
1770
1771 let columns = parse_columns_from_sqlite_master_sql(&create_sql);
1773 let bound_table_autoindexes = if is_virtual_table {
1774 None
1775 } else {
1776 Some(bound_implicit_autoindexes.table(&name).ok_or_else(|| {
1777 sqlite_master_corrupt(format!(
1778 "validated ordinary table `{name}` has no bound autoindex layout"
1779 ))
1780 })?)
1781 };
1782 let indexes = bound_table_autoindexes
1783 .map_or_else(Vec::new, |bound| bound.physical_index_schemas(&name));
1784 let primary_key_constraints = extract_primary_key_constraints_from_sql(&create_sql);
1785 let foreign_keys = extract_foreign_keys_from_sql(&create_sql, &columns);
1786 let check_constraints = extract_check_constraints_with_owners_from_sql(&create_sql);
1787 let num_columns = columns.len();
1788 let without_rowid = is_without_rowid_table_sql(&create_sql);
1789 let ipk_col_idx = columns.iter().position(|c| c.is_ipk);
1790
1791 let real_root_page =
1793 i32::try_from(root_page_u32).expect("validated root page must fit MemDatabase");
1794 db.create_table_at(real_root_page, num_columns);
1795 for index in &indexes {
1796 db.create_table_at(index.root_page, 0);
1797 }
1798
1799 let table_name_for_err = name.to_string();
1800 schema.push(TableSchema {
1801 name: name.to_string(),
1802 root_page: real_root_page,
1803 columns,
1804 indexes: indexes.clone(),
1805 strict: is_strict_table_sql(&create_sql),
1806 without_rowid,
1807 primary_key_constraints,
1808 foreign_keys,
1809 check_constraints,
1810 });
1811 let current_table_schema = schema.last().ok_or_else(|| {
1812 FrankenError::Internal(format!(
1813 "compat loader lost table schema after registering `{table_name_for_err}`"
1814 ))
1815 })?;
1816
1817 let file_root =
1819 PageNumber::new(root_page_u32).expect("validated sqlite_master root page is positive");
1820
1821 let mut cursor = fsqlite_btree::BtCursor::new(
1822 TransactionPageIo::new(&mut txn),
1823 file_root,
1824 usable_size,
1825 !without_rowid,
1826 );
1827 configure_btree_cursor_page_size(&mut cursor, usable_size, page_size);
1828
1829 if let Some(mem_table) = db.tables.get_mut(&real_root_page) {
1830 for slot in bound_table_autoindexes
1831 .into_iter()
1832 .flat_map(BoundTableAutoindexes::implicit_slots)
1833 {
1834 let Some(column_indices) = slot
1835 .columns()
1836 .iter()
1837 .map(|column_name| {
1838 current_table_schema
1839 .columns
1840 .iter()
1841 .position(|column| column.name.eq_ignore_ascii_case(column_name))
1842 })
1843 .collect::<Option<Vec<_>>>()
1844 else {
1845 return Err(FrankenError::DatabaseCorrupt {
1846 detail: format!(
1847 "canonical autoindex layout for `{table_name_for_err}` references a missing column"
1848 ),
1849 });
1850 };
1851 if !column_indices.is_empty() {
1852 mem_table.add_unique_column_group_with_collations(
1853 column_indices,
1854 slot.key_collations().to_vec(),
1855 );
1856 }
1857 }
1858 if cursor.first(cx).await? {
1859 if without_rowid {
1860 let mut synthetic_rowid = 1_i64;
1861 let mut payload_buf: Vec<u8> = Vec::new();
1862 loop {
1863 payload_buf.clear();
1864 cursor.payload_into(cx, &mut payload_buf).await?;
1865 let mut values = parse_record(&payload_buf).ok_or_else(|| {
1866 FrankenError::DatabaseCorrupt {
1867 detail: format!(
1868 "WITHOUT ROWID table `{table_name_for_err}` payload is not a valid SQLite record"
1869 ),
1870 }
1871 })?;
1872 inflate_loaded_table_row_values(
1873 &mut values,
1874 synthetic_rowid,
1875 ¤t_table_schema.columns,
1876 None,
1877 &table_name_for_err,
1878 )?;
1879 mem_table.insert_row(synthetic_rowid, values);
1880 synthetic_rowid = synthetic_rowid.saturating_add(1);
1881 if !cursor.next(cx).await? {
1882 break;
1883 }
1884 }
1885 continue;
1886 }
1887 let mut payload_buf: Vec<u8> = Vec::new();
1888 loop {
1889 payload_buf.clear();
1893 let rowid = cursor.rowid_and_payload_into(cx, &mut payload_buf).await?;
1894 let mut values = parse_record(&payload_buf).ok_or_else(|| {
1895 FrankenError::DatabaseCorrupt {
1896 detail: format!(
1897 "table `{table_name_for_err}` rowid {rowid} payload is not a valid SQLite record"
1898 ),
1899 }
1900 })?;
1901 inflate_loaded_table_row_values(
1902 &mut values,
1903 rowid,
1904 ¤t_table_schema.columns,
1905 if without_rowid { None } else { ipk_col_idx },
1906 &table_name_for_err,
1907 )?;
1908 mem_table.insert_row(rowid, values);
1909 if !cursor.next(cx).await? {
1910 break;
1911 }
1912 }
1913 }
1914 }
1915 }
1916
1917 for entry in &master_entries {
1921 if entry.len() < 5 {
1922 continue;
1923 }
1924 let entry_type = match &entry[0] {
1925 SqliteValue::Text(s) => s,
1926 _ => continue,
1927 };
1928 if !entry_type.eq_ignore_ascii_case("index") {
1929 continue;
1930 }
1931 let index_name = match &entry[1] {
1932 SqliteValue::Text(s) => s.to_string(),
1933 _ => continue,
1934 };
1935 let tbl_name = match &entry[2] {
1936 SqliteValue::Text(s) => s.to_string(),
1937 _ => continue,
1938 };
1939 let root_page_num = match &entry[3] {
1940 SqliteValue::Integer(n) => *n,
1941 _ => continue,
1942 };
1943 let create_sql = match &entry[4] {
1944 SqliteValue::Text(s) => s.to_string(),
1945 _ => continue,
1946 };
1947
1948 let root_page_u32 = validate_sqlite_master_root_page(&index_name, root_page_num)?;
1949 let root_page_i32 =
1950 i32::try_from(root_page_u32).map_err(|_| FrankenError::DatabaseCorrupt {
1951 detail: format!(
1952 "sqlite_master index `{index_name}` has rootpage {root_page_num} that exceeds supported range"
1953 ),
1954 })?;
1955
1956 let Some(table_position) = schema
1959 .iter()
1960 .position(|table| table.name.eq_ignore_ascii_case(&tbl_name))
1961 else {
1962 return Err(sqlite_master_corrupt(format!(
1963 "validated index `{index_name}` lost parent table `{tbl_name}` during load"
1964 )));
1965 };
1966
1967 let Some(Statement::CreateIndex(create_stmt)) = parse_single_statement(&create_sql) else {
1968 return Err(sqlite_master_corrupt(format!(
1969 "validated CREATE INDEX SQL for `{index_name}` could not be parsed during load"
1970 )));
1971 };
1972 if let Some(schema_name) = create_stmt.name.schema.as_deref() {
1973 return Err(sqlite_master_corrupt(format!(
1974 "explicit index `{index_name}` on table `{tbl_name}` has non-canonical schema-qualified CREATE INDEX SQL (`{schema_name}`) during load"
1975 )));
1976 }
1977 let table = &schema[table_position];
1978 if table
1979 .indexes
1980 .iter()
1981 .any(|index| index.name.eq_ignore_ascii_case(&index_name))
1982 {
1983 return Err(sqlite_master_corrupt(format!(
1984 "validated explicit index `{index_name}` duplicates an existing index on table `{tbl_name}` during load"
1985 )));
1986 }
1987 let bound_index = bind_explicit_index(&create_stmt, &index_name, &tbl_name, table)
1988 .map_err(|error| {
1989 sqlite_master_corrupt(format!(
1990 "invalid explicit index `{index_name}` on table `{tbl_name}` during load: {error}"
1991 ))
1992 })?;
1993 for indexed in &create_stmt.columns {
1994 validate_builtin_persisted_index_expr_functions(&indexed.expr).map_err(|error| {
1995 sqlite_master_corrupt(format!(
1996 "invalid explicit index `{index_name}` on table `{tbl_name}` during load: {error}"
1997 ))
1998 })?;
1999 }
2000 if let Some(predicate) = create_stmt.where_clause.as_ref() {
2001 validate_builtin_persisted_index_expr_functions(predicate).map_err(|error| {
2002 sqlite_master_corrupt(format!(
2003 "invalid explicit index `{index_name}` on table `{tbl_name}` during load: {error}"
2004 ))
2005 })?;
2006 }
2007 schema[table_position]
2008 .indexes
2009 .push(bound_index.into_index_schema(root_page_i32));
2010 db.create_table_at(root_page_i32, 0);
2011 }
2012
2013 let (schema_cookie, change_counter) = {
2015 let header_buf = txn.get_page(cx, PageNumber::ONE).await?;
2016 let hdr = header_buf.as_ref();
2017 let cookie = if hdr.len() >= 44 {
2018 u32::from_be_bytes([hdr[40], hdr[41], hdr[42], hdr[43]])
2019 } else {
2020 0
2021 };
2022 let counter = if hdr.len() >= 28 {
2023 u32::from_be_bytes([hdr[24], hdr[25], hdr[26], hdr[27]])
2024 } else {
2025 0
2026 };
2027 (cookie, counter)
2028 };
2029
2030 #[allow(clippy::cast_possible_wrap)]
2031 let master_row_count = master_entries.len() as i64;
2032 Ok(LoadedState {
2033 schema,
2034 db,
2035 master_row_count,
2036 schema_cookie,
2037 change_counter,
2038 })
2039}
2040
2041#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
2052struct WithoutRowidPkLayout {
2053 indices: Vec<usize>,
2058 desc_flags: Vec<bool>,
2060 collations: Vec<Option<String>>,
2062}
2063
2064#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
2087fn resolve_without_rowid_pk_layout(
2088 table: &TableSchema,
2089 create_sql: &str,
2090) -> Result<WithoutRowidPkLayout> {
2091 let indices = without_rowid_pk_indices(table).map_err(codegen_error_to_franken)?;
2096
2097 let Some(Statement::CreateTable(create)) = parse_single_statement(create_sql) else {
2098 return Err(FrankenError::not_implemented(format!(
2099 "WITHOUT ROWID table `{}` cannot be rebuilt: its CREATE TABLE text does not parse, \
2100 so the primary-key direction its b-tree must be keyed by is unknown",
2101 table.name
2102 )));
2103 };
2104 let CreateTableBody::Columns {
2105 columns: column_defs,
2106 constraints,
2107 } = &create.body
2108 else {
2109 return Err(FrankenError::not_implemented(format!(
2110 "WITHOUT ROWID table `{}` cannot be rebuilt from a CREATE TABLE ... AS SELECT form: \
2111 it declares no primary-key columns to key the table b-tree by",
2112 table.name
2113 )));
2114 };
2115
2116 let desc_flags = collect_primary_key_desc_flags(column_defs, constraints)
2117 .into_iter()
2118 .next()
2119 .unwrap_or_default();
2120 if desc_flags.len() != indices.len() {
2121 return Err(FrankenError::not_implemented(format!(
2122 "WITHOUT ROWID table `{}` declares {} primary-key column(s) but its CREATE TABLE text \
2123 yields {} sort direction(s); refusing to rebuild rather than key the table b-tree by \
2124 a direction vector that does not describe it",
2125 table.name,
2126 indices.len(),
2127 desc_flags.len()
2128 )));
2129 }
2130
2131 let collations = indices
2132 .iter()
2133 .map(|&col_idx| {
2134 table
2135 .columns
2136 .get(col_idx)
2137 .and_then(|column| column.collation.clone())
2138 })
2139 .collect();
2140
2141 Ok(WithoutRowidPkLayout {
2142 indices,
2143 desc_flags,
2144 collations,
2145 })
2146}
2147
2148#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
2150async fn init_leaf_table_page(
2151 cx: &Cx,
2152 txn: &mut impl TransactionHandle,
2153 page_no: PageNumber,
2154 full_page_size: usize,
2155 usable_size: u32,
2156) -> Result<()> {
2157 let mut page = vec![0u8; full_page_size];
2158 page[0] = 0x0D; page[3..5].copy_from_slice(&0u16.to_be_bytes());
2161 let content_start: u16 = if usable_size == 65536 {
2165 0
2166 } else {
2167 u16::try_from(usable_size).map_err(|_| {
2168 FrankenError::internal(format!(
2169 "usable_size {usable_size} does not fit in u16 and is not 65536"
2170 ))
2171 })?
2172 };
2173 page[5..7].copy_from_slice(&content_start.to_be_bytes());
2174 txn.write_page(cx, page_no, &page).await
2175}
2176
2177#[cfg(all(not(target_arch = "wasm32"), feature = "native"))]
2178async fn init_leaf_index_page(
2179 cx: &Cx,
2180 txn: &mut impl TransactionHandle,
2181 page_no: PageNumber,
2182 full_page_size: usize,
2183 usable_size: u32,
2184) -> Result<()> {
2185 let mut page = vec![0u8; full_page_size];
2186 page[0] = 0x0A; page[3..5].copy_from_slice(&0u16.to_be_bytes());
2188 let content_start: u16 = if usable_size == 65536 {
2189 0
2190 } else {
2191 u16::try_from(usable_size).map_err(|_| {
2192 FrankenError::internal(format!(
2193 "usable_size {usable_size} does not fit in u16 and is not 65536"
2194 ))
2195 })?
2196 };
2197 page[5..7].copy_from_slice(&content_start.to_be_bytes());
2198 txn.write_page(cx, page_no, &page).await
2199}
2200
2201fn quote_identifier(identifier: &str) -> String {
2202 let escaped = identifier.replace('"', "\"\"");
2203 format!("\"{escaped}\"")
2204}
2205
2206fn append_fk_reference_clause(sql: &mut String, fk: &FkDef) {
2207 use std::fmt::Write as _;
2208
2209 let _ = write!(sql, " REFERENCES {}", quote_identifier(&fk.parent_table));
2210 if !fk.parent_columns.is_empty() {
2211 let parent_columns = fk
2212 .parent_columns
2213 .iter()
2214 .map(|column_name| quote_identifier(column_name))
2215 .collect::<Vec<_>>()
2216 .join(", ");
2217 let _ = write!(sql, "({parent_columns})");
2218 }
2219 if fk.on_delete != FkActionType::NoAction {
2220 let _ = write!(sql, " ON DELETE {}", fk_action_sql(fk.on_delete));
2221 }
2222 if fk.on_update != FkActionType::NoAction {
2223 let _ = write!(sql, " ON UPDATE {}", fk_action_sql(fk.on_update));
2224 }
2225 if fk.deferred {
2226 sql.push_str(" DEFERRABLE INITIALLY DEFERRED");
2227 }
2228}
2229
2230pub(crate) fn build_create_table_sql(table: &TableSchema) -> String {
2232 build_create_table_sql_with_implicit_index_predicate(table, |index| {
2233 parse_autoindex_ordinal(&index.name, &table.name).is_some()
2234 })
2235}
2236
2237fn build_create_table_sql_with_implicit_index_predicate<F>(
2238 table: &TableSchema,
2239 is_implicit_autoindex: F,
2240) -> String
2241where
2242 F: Fn(&IndexSchema) -> bool,
2243{
2244 use std::fmt::Write as _;
2245 let mut sql = format!("CREATE TABLE {} (", quote_identifier(&table.name));
2246 let is_single_column_primary_key = |column_name: &str| {
2247 table
2248 .primary_key_constraints
2249 .iter()
2250 .any(|pk| pk.len() == 1 && pk[0].eq_ignore_ascii_case(column_name))
2251 };
2252 let primary_key_matches_index = |index: &fsqlite_vdbe::codegen::IndexSchema| {
2253 table.primary_key_constraints.iter().any(|pk| {
2254 pk.len() == index.columns.len()
2255 && pk
2256 .iter()
2257 .zip(index.columns.iter())
2258 .all(|(lhs, rhs): (&String, &String)| lhs.eq_ignore_ascii_case(rhs))
2259 })
2260 };
2261 for (i, col) in table.columns.iter().enumerate() {
2262 if i > 0 {
2263 sql.push_str(", ");
2264 }
2265 sql.push_str("e_identifier(&col.name));
2266 if let Some(type_kw) = col.type_name.as_deref() {
2267 let _ = write!(sql, " {type_kw}");
2268 }
2269 if col.is_ipk {
2270 sql.push_str(" PRIMARY KEY");
2271 }
2272 if col.notnull && !col.is_ipk {
2273 sql.push_str(" NOT NULL");
2274 }
2275 if col.unique && !col.is_ipk && !is_single_column_primary_key(&col.name) {
2276 sql.push_str(" UNIQUE");
2277 }
2278 if let Some(ref default) = col.default_value {
2279 sql.push_str(" DEFAULT ");
2280 sql.push_str(default);
2281 }
2282 if let Some(ref collation) = col.collation {
2283 sql.push_str(" COLLATE ");
2284 sql.push_str("e_identifier(collation));
2285 }
2286 if let Some(ref gen_expr) = col.generated_expr {
2287 sql.push_str(" GENERATED ALWAYS AS (");
2288 sql.push_str(gen_expr);
2289 sql.push(')');
2290 if col.generated_stored == Some(true) {
2291 sql.push_str(" STORED");
2292 } else {
2293 sql.push_str(" VIRTUAL");
2294 }
2295 }
2296 for check in table.check_constraints.iter().filter(|check| {
2297 check
2298 .owner_column
2299 .as_deref()
2300 .is_some_and(|owner| owner.eq_ignore_ascii_case(&col.name))
2301 }) {
2302 let _ = write!(sql, " CHECK({})", check.expr);
2303 }
2304 for fk in table.foreign_keys.iter().filter(|fk| {
2305 fk.owner_column
2306 .as_deref()
2307 .is_some_and(|owner| owner.eq_ignore_ascii_case(&col.name))
2308 }) {
2309 append_fk_reference_clause(&mut sql, fk);
2310 }
2311 }
2312 for pk in &table.primary_key_constraints {
2320 if pk.len() == 1
2321 && table
2322 .columns
2323 .iter()
2324 .any(|column| column.is_ipk && column.name.eq_ignore_ascii_case(&pk[0]))
2325 {
2326 continue;
2327 }
2328 let cols = pk
2329 .iter()
2330 .map(|name| quote_identifier(name))
2331 .collect::<Vec<_>>()
2332 .join(", ");
2333 let _ = write!(sql, ", PRIMARY KEY ({cols})");
2334 }
2335 for index in &table.indexes {
2336 if !index.is_unique || index.columns.is_empty() || primary_key_matches_index(index) {
2337 continue;
2338 }
2339 if !is_implicit_autoindex(index) {
2345 continue;
2346 }
2347 if index.columns.len() == 1
2348 && table.columns.iter().any(|column| {
2349 column.unique
2350 && !column.is_ipk
2351 && column.name.eq_ignore_ascii_case(&index.columns[0])
2352 })
2353 {
2354 continue;
2355 }
2356 let cols = index
2357 .columns
2358 .iter()
2359 .map(|name| quote_identifier(name))
2360 .collect::<Vec<_>>()
2361 .join(", ");
2362 let _ = write!(sql, ", UNIQUE ({cols})");
2363 }
2364 for fk in table
2365 .foreign_keys
2366 .iter()
2367 .filter(|fk| fk.owner_column.is_none())
2368 {
2369 let child_columns = fk
2370 .child_columns
2371 .iter()
2372 .filter_map(|&column_index| table.columns.get(column_index))
2373 .map(|column| quote_identifier(&column.name))
2374 .collect::<Vec<_>>();
2375 if child_columns.is_empty() {
2376 continue;
2377 }
2378 let _ = write!(sql, ", FOREIGN KEY({})", child_columns.join(", "));
2379 append_fk_reference_clause(&mut sql, fk);
2380 }
2381 for check in table
2382 .check_constraints
2383 .iter()
2384 .filter(|check| check.owner_column.is_none())
2385 {
2386 let _ = write!(sql, ", CHECK({})", check.expr);
2387 }
2388 sql.push(')');
2389 let mut table_options = Vec::new();
2390 if table.without_rowid {
2391 table_options.push("WITHOUT ROWID");
2392 }
2393 if table.strict {
2394 table_options.push("STRICT");
2395 }
2396 if !table_options.is_empty() {
2397 sql.push(' ');
2398 sql.push_str(&table_options.join(", "));
2399 }
2400 sql
2401}
2402
2403const fn fk_action_sql(action: FkActionType) -> &'static str {
2404 match action {
2405 FkActionType::NoAction => "NO ACTION",
2406 FkActionType::Restrict => "RESTRICT",
2407 FkActionType::SetNull => "SET NULL",
2408 FkActionType::SetDefault => "SET DEFAULT",
2409 FkActionType::Cascade => "CASCADE",
2410 }
2411}
2412
2413pub(crate) fn extract_primary_key_constraints_from_sql(sql: &str) -> Vec<Vec<String>> {
2414 let Some(Statement::CreateTable(create)) = parse_single_statement(sql) else {
2415 return Vec::new();
2416 };
2417 let CreateTableBody::Columns {
2418 columns,
2419 constraints,
2420 } = &create.body
2421 else {
2422 return Vec::new();
2423 };
2424
2425 let mut primary_keys = columns
2426 .iter()
2427 .filter(|column| {
2428 column.constraints.iter().any(|constraint| {
2429 matches!(constraint.kind, ColumnConstraintKind::PrimaryKey { .. })
2430 })
2431 })
2432 .map(|column| vec![column.name.clone()])
2433 .collect::<Vec<_>>();
2434
2435 primary_keys.extend(constraints.iter().filter_map(|constraint| {
2436 let TableConstraintKind::PrimaryKey {
2437 columns: indexed_columns,
2438 ..
2439 } = &constraint.kind
2440 else {
2441 return None;
2442 };
2443 let columns = indexed_columns
2444 .iter()
2445 .filter_map(indexed_column_name)
2446 .map(str::to_owned)
2447 .collect::<Vec<_>>();
2448 (!columns.is_empty()).then_some(columns)
2449 }));
2450
2451 primary_keys
2452}
2453
2454#[cfg(test)]
2455fn extract_unique_constraint_indexes_from_sql(
2456 sql: &str,
2457 table_name: &str,
2458) -> Result<Vec<IndexSchema>> {
2459 let slots = extract_implicit_autoindex_slots_from_sql(sql, table_name)?;
2460 Ok(slots
2461 .into_iter()
2462 .enumerate()
2463 .filter(|(_, slot)| !slot.is_hidden_without_rowid_primary_key())
2464 .map(|(slot_index, slot)| slot.into_index_schema(table_name, slot_index + 1, 0))
2465 .collect())
2466}
2467
2468#[cfg(test)]
2469fn extract_implicit_autoindex_slots_from_sql(
2470 sql: &str,
2471 table_name: &str,
2472) -> Result<Vec<ImplicitAutoindexSlot>> {
2473 let Some(Statement::CreateTable(create)) = parse_single_statement(sql) else {
2474 return Err(FrankenError::DatabaseCorrupt {
2475 detail: format!("could not parse CREATE TABLE SQL for `{table_name}`"),
2476 });
2477 };
2478 if !create.name.name.eq_ignore_ascii_case(table_name) {
2479 return Err(FrankenError::DatabaseCorrupt {
2480 detail: format!(
2481 "CREATE TABLE SQL for `{table_name}` declares `{}`",
2482 create.name.name
2483 ),
2484 });
2485 }
2486 let CreateTableBody::Columns {
2487 columns,
2488 constraints,
2489 } = &create.body
2490 else {
2491 return Ok(Vec::new());
2492 };
2493 implicit_autoindex_layout(columns, constraints, create.without_rowid)
2494}
2495
2496pub(crate) fn extract_foreign_keys_from_sql(sql: &str, columns: &[ColumnInfo]) -> Vec<FkDef> {
2497 let Some(Statement::CreateTable(create)) = parse_single_statement(sql) else {
2498 return extract_foreign_keys_sql_fallback(sql, columns);
2499 };
2500 let CreateTableBody::Columns {
2501 columns: column_defs,
2502 constraints,
2503 } = &create.body
2504 else {
2505 return Vec::new();
2506 };
2507
2508 let mut foreign_keys = Vec::new();
2509 for (column_index, column) in column_defs.iter().enumerate() {
2510 for constraint in &column.constraints {
2511 if let ColumnConstraintKind::ForeignKey(clause) = &constraint.kind {
2512 foreign_keys.push(fk_clause_to_def(
2513 &[column_index],
2514 Some(column.name.clone()),
2515 clause,
2516 ));
2517 }
2518 }
2519 }
2520 for constraint in constraints {
2521 if let TableConstraintKind::ForeignKey {
2522 columns: child_columns,
2523 clause,
2524 } = &constraint.kind
2525 {
2526 let child_indices = child_columns
2527 .iter()
2528 .filter_map(|column_name| {
2529 columns
2530 .iter()
2531 .position(|column| column.name.eq_ignore_ascii_case(column_name))
2532 })
2533 .collect::<Vec<_>>();
2534 if !child_indices.is_empty() {
2535 foreign_keys.push(fk_clause_to_def(&child_indices, None, clause));
2536 }
2537 }
2538 }
2539
2540 foreign_keys
2541}
2542
2543fn extract_foreign_keys_sql_fallback(sql: &str, columns: &[ColumnInfo]) -> Vec<FkDef> {
2544 let Some(open) = find_unquoted_sql_char(sql, '(') else {
2545 return Vec::new();
2546 };
2547 let Some(close) = find_matching_sql_paren(sql, open) else {
2548 return Vec::new();
2549 };
2550 let mut foreign_keys = Vec::new();
2551
2552 for definition in split_top_level_csv_items(&sql[open + 1..close]) {
2553 if starts_with_unquoted_table_constraint(&definition) {
2554 let Some(foreign_pos) = find_top_level_unquoted_sql_keyword(&definition, "FOREIGN")
2555 else {
2556 continue;
2557 };
2558 let after_foreign = &definition[foreign_pos + "FOREIGN".len()..];
2559 let Some(key_pos) = find_top_level_unquoted_sql_keyword(after_foreign, "KEY") else {
2560 continue;
2561 };
2562 let after_key = &after_foreign[key_pos + "KEY".len()..];
2563 let child_list = trim_leading_sql_space_and_comments(after_key);
2564 if !child_list.starts_with('(') {
2565 continue;
2566 }
2567 let open_paren = definition.len() - child_list.len();
2568 let Some(close_paren) = find_matching_sql_paren(&definition, open_paren) else {
2569 continue;
2570 };
2571 let Some(child_names) =
2572 parse_sql_identifier_list(&definition[open_paren + 1..close_paren])
2573 else {
2574 continue;
2575 };
2576 let Some(child_indices) = child_names
2577 .iter()
2578 .map(|name| {
2579 columns
2580 .iter()
2581 .position(|column| column.name.eq_ignore_ascii_case(name))
2582 })
2583 .collect::<Option<Vec<_>>>()
2584 else {
2585 continue;
2586 };
2587 if let Some(fk) =
2588 parse_fk_reference_sql(&definition[close_paren + 1..], &child_indices, None)
2589 {
2590 foreign_keys.push(fk);
2591 }
2592 continue;
2593 }
2594
2595 let Some((column_name, remainder)) = parse_column_name_and_remainder(&definition) else {
2596 continue;
2597 };
2598 let Some(column_index) = columns
2599 .iter()
2600 .position(|column| column.name.eq_ignore_ascii_case(&column_name))
2601 else {
2602 continue;
2603 };
2604 if let Some(fk) = parse_fk_reference_sql(remainder, &[column_index], Some(column_name)) {
2605 foreign_keys.push(fk);
2606 }
2607 }
2608
2609 foreign_keys
2610}
2611
2612fn parse_sql_identifier_list(input: &str) -> Option<Vec<String>> {
2613 split_top_level_csv_items(input)
2614 .into_iter()
2615 .map(|item| {
2616 let (name, remainder) = parse_column_name_and_remainder(&item)?;
2617 trim_leading_sql_space_and_comments(remainder)
2618 .is_empty()
2619 .then_some(name)
2620 })
2621 .collect()
2622}
2623
2624fn parse_fk_reference_sql(
2625 input: &str,
2626 child_indices: &[usize],
2627 owner_column: Option<String>,
2628) -> Option<FkDef> {
2629 let references_pos = find_top_level_unquoted_sql_keyword(input, "REFERENCES")?;
2630 let after_references =
2631 trim_leading_sql_space_and_comments(&input[references_pos + "REFERENCES".len()..]);
2632 let (parent_table, after_parent_table) = parse_fk_parent_table(after_references)?;
2633 let after_parent_table = trim_leading_sql_space_and_comments(after_parent_table);
2634 let (parent_columns, action_sql) = if after_parent_table.starts_with('(') {
2635 let open_paren = input.len() - after_parent_table.len();
2636 let close_paren = find_matching_sql_paren(input, open_paren)?;
2637 (
2638 parse_sql_identifier_list(&input[open_paren + 1..close_paren])?,
2639 &input[close_paren + 1..],
2640 )
2641 } else {
2642 (Vec::new(), after_parent_table)
2643 };
2644 let tokens = collect_unquoted_sql_keyword_tokens(action_sql)
2645 .into_iter()
2646 .map(|(token, _)| token)
2647 .collect::<Vec<_>>();
2648 let mut on_delete = FkActionType::NoAction;
2649 let mut on_update = FkActionType::NoAction;
2650 for (index, token) in tokens.iter().enumerate() {
2651 if token != "ON" || index + 2 >= tokens.len() {
2652 continue;
2653 }
2654 let action = match tokens[index + 2].as_str() {
2655 "CASCADE" => Some(FkActionType::Cascade),
2656 "RESTRICT" => Some(FkActionType::Restrict),
2657 "SET" if tokens.get(index + 3).is_some_and(|token| token == "NULL") => {
2658 Some(FkActionType::SetNull)
2659 }
2660 "SET"
2661 if tokens
2662 .get(index + 3)
2663 .is_some_and(|token| token == "DEFAULT") =>
2664 {
2665 Some(FkActionType::SetDefault)
2666 }
2667 "NO" if tokens.get(index + 3).is_some_and(|token| token == "ACTION") => {
2668 Some(FkActionType::NoAction)
2669 }
2670 _ => None,
2671 };
2672 match (tokens[index + 1].as_str(), action) {
2673 ("DELETE", Some(action)) => on_delete = action,
2674 ("UPDATE", Some(action)) => on_update = action,
2675 _ => {}
2676 }
2677 }
2678 let deferred = tokens.windows(3).enumerate().any(|(index, window)| {
2679 (index == 0 || tokens[index - 1] != "NOT")
2680 && window[0] == "DEFERRABLE"
2681 && window[1] == "INITIALLY"
2682 && window[2] == "DEFERRED"
2683 });
2684
2685 Some(FkDef {
2686 child_columns: child_indices.to_vec(),
2687 owner_column,
2688 parent_table,
2689 parent_columns,
2690 on_delete,
2691 on_update,
2692 deferred,
2693 })
2694}
2695
2696fn parse_fk_parent_table(input: &str) -> Option<(String, &str)> {
2697 let trimmed = trim_leading_sql_space_and_comments(input);
2698 if trimmed.is_empty() {
2699 return None;
2700 }
2701 let (name_raw, remainder) = match trimmed.as_bytes()[0] {
2702 b'"' => parse_quoted_identifier(trimmed, b'"', b'"')?,
2703 b'`' => parse_quoted_identifier(trimmed, b'`', b'`')?,
2704 b'[' => parse_bracket_identifier(trimmed)?,
2705 _ => {
2706 let mut chars = trimmed.char_indices().peekable();
2707 let mut end = trimmed.len();
2708 while let Some((index, ch)) = chars.next() {
2709 let starts_comment = matches!(ch, '-' | '/')
2710 && chars.peek().is_some_and(|(_, next)| {
2711 (ch == '-' && *next == '-') || (ch == '/' && *next == '*')
2712 });
2713 if ch.is_whitespace() || ch == '(' || starts_comment {
2714 end = index;
2715 break;
2716 }
2717 }
2718 (&trimmed[..end], &trimmed[end..])
2719 }
2720 };
2721 (!name_raw.is_empty()).then(|| {
2722 (
2723 strip_identifier_quotes(name_raw),
2724 trim_leading_sql_space_and_comments(remainder),
2725 )
2726 })
2727}
2728
2729fn fk_clause_to_def(
2730 child_indices: &[usize],
2731 owner_column: Option<String>,
2732 clause: &fsqlite_ast::ForeignKeyClause,
2733) -> FkDef {
2734 let mut on_delete = FkActionType::NoAction;
2735 let mut on_update = FkActionType::NoAction;
2736 for action in &clause.actions {
2737 let action_type = match action.action {
2738 fsqlite_ast::ForeignKeyActionType::SetNull => FkActionType::SetNull,
2739 fsqlite_ast::ForeignKeyActionType::SetDefault => FkActionType::SetDefault,
2740 fsqlite_ast::ForeignKeyActionType::Cascade => FkActionType::Cascade,
2741 fsqlite_ast::ForeignKeyActionType::Restrict => FkActionType::Restrict,
2742 fsqlite_ast::ForeignKeyActionType::NoAction => FkActionType::NoAction,
2743 };
2744 match action.trigger {
2745 fsqlite_ast::ForeignKeyTrigger::OnDelete => on_delete = action_type,
2746 fsqlite_ast::ForeignKeyTrigger::OnUpdate => on_update = action_type,
2747 }
2748 }
2749 let deferred = clause.deferrable.as_ref().is_some_and(|d| {
2750 !d.not
2751 && matches!(
2752 d.initially,
2753 Some(fsqlite_ast::DeferrableInitially::Deferred)
2754 )
2755 });
2756 FkDef {
2757 child_columns: child_indices.to_vec(),
2758 owner_column,
2759 parent_table: clause.table.clone(),
2760 parent_columns: clause.columns.clone(),
2761 on_delete,
2762 on_update,
2763 deferred,
2764 }
2765}
2766
2767#[derive(Debug, Clone, Copy)]
2769#[allow(dead_code)]
2770pub(crate) struct CreateIndexSqlTerm<'a> {
2771 pub(crate) column_name: &'a str,
2772 pub(crate) collation: Option<&'a str>,
2773 pub(crate) direction: Option<SortDirection>,
2774}
2775
2776#[allow(dead_code)]
2780pub(crate) fn build_create_index_sql(
2781 index_name: &str,
2782 table_name: &str,
2783 unique: bool,
2784 terms: &[CreateIndexSqlTerm<'_>],
2785 where_clause: Option<&fsqlite_ast::Expr>,
2786) -> String {
2787 use std::fmt::Write as _;
2788 let mut sql = if unique {
2789 format!(
2790 "CREATE UNIQUE INDEX {} ON {} (",
2791 quote_identifier(index_name),
2792 quote_identifier(table_name)
2793 )
2794 } else {
2795 format!(
2796 "CREATE INDEX {} ON {} (",
2797 quote_identifier(index_name),
2798 quote_identifier(table_name)
2799 )
2800 };
2801 for (i, term) in terms.iter().enumerate() {
2802 if i > 0 {
2803 sql.push_str(", ");
2804 }
2805 sql.push_str("e_identifier(term.column_name));
2806 if let Some(collation) = term.collation {
2807 let _ = write!(sql, " COLLATE {}", quote_identifier(collation));
2808 }
2809 match term.direction {
2810 Some(SortDirection::Asc) => sql.push_str(" ASC"),
2811 Some(SortDirection::Desc) => sql.push_str(" DESC"),
2812 None => {}
2813 }
2814 }
2815 sql.push(')');
2816 if let Some(expr) = where_clause {
2817 let _ = write!(sql, " WHERE {expr}");
2818 }
2819 sql
2820}
2821
2822fn build_create_expression_index_sql(
2823 index_name: &str,
2824 table_name: &str,
2825 unique: bool,
2826 expressions: &[String],
2827 collations: &[Option<String>],
2828 directions: &[SortDirection],
2829 where_clause: Option<&str>,
2830) -> String {
2831 use std::fmt::Write as _;
2832 let mut sql = if unique {
2833 format!(
2834 "CREATE UNIQUE INDEX {} ON {} (",
2835 quote_identifier(index_name),
2836 quote_identifier(table_name)
2837 )
2838 } else {
2839 format!(
2840 "CREATE INDEX {} ON {} (",
2841 quote_identifier(index_name),
2842 quote_identifier(table_name)
2843 )
2844 };
2845 for (i, expr) in expressions.iter().enumerate() {
2846 if i > 0 {
2847 sql.push_str(", ");
2848 }
2849 sql.push_str(expr);
2850 let expression_already_declares_collation = unquoted_sql_keyword_tokens(expr)
2851 .iter()
2852 .any(|token| token == "COLLATE");
2853 if !expression_already_declares_collation
2854 && let Some(collation) = collations.get(i).and_then(|c| c.as_deref())
2855 {
2856 let _ = write!(sql, " COLLATE {}", quote_identifier(collation));
2857 }
2858 match directions.get(i).copied() {
2859 Some(SortDirection::Asc) => sql.push_str(" ASC"),
2860 Some(SortDirection::Desc) => sql.push_str(" DESC"),
2861 None => {}
2862 }
2863 }
2864 sql.push(')');
2865 if let Some(predicate) = where_clause {
2866 let _ = write!(sql, " WHERE {predicate}");
2867 }
2868 sql
2869}
2870
2871pub fn parse_columns_from_create_sql(sql: &str) -> Vec<ColumnInfo> {
2878 if let Some(columns) = try_parse_columns_from_create_sql_ast(sql) {
2879 return columns;
2880 }
2881
2882 let is_strict = is_strict_table_sql(sql);
2883 let is_without_rowid = is_without_rowid_table_sql(sql);
2884 let Some(open) = find_unquoted_sql_char(sql, '(') else {
2886 return Vec::new();
2887 };
2888 let Some(close) = find_matching_sql_paren(sql, open) else {
2889 return Vec::new();
2890 };
2891
2892 let body = &sql[open + 1..close];
2893 split_top_level_csv_items(body)
2894 .into_iter()
2895 .filter_map(|col_def| {
2896 if starts_with_unquoted_table_constraint(&col_def) {
2897 return None;
2898 }
2899
2900 let (name, remainder) = parse_column_name_and_remainder(&col_def)?;
2901 let tokens: Vec<&str> = remainder.split_whitespace().collect();
2902 let type_decl = extract_type_declaration(&tokens);
2903 let affinity = type_to_affinity(&type_decl);
2904 let keyword_tokens = unquoted_sql_keyword_tokens(remainder);
2905 let has_primary_key =
2906 unquoted_tokens_contain_phrase(&keyword_tokens, &["PRIMARY", "KEY"]);
2907 let has_primary_key_desc =
2908 unquoted_tokens_contain_phrase(&keyword_tokens, &["PRIMARY", "KEY", "DESC"]);
2909 let has_unique = keyword_tokens
2910 .iter()
2911 .any(|keyword| matches!(keyword.as_str(), "UNIQUE"));
2912 let has_not_null = unquoted_tokens_contain_phrase(&keyword_tokens, &["NOT", "NULL"]);
2913 let is_ipk = !is_without_rowid
2914 && has_primary_key
2915 && !has_primary_key_desc
2916 && type_decl.eq_ignore_ascii_case("INTEGER");
2917 let type_name = if type_decl.is_empty() {
2918 None
2919 } else {
2920 Some(type_decl)
2921 };
2922 let strict_type = if is_strict {
2923 type_name
2924 .as_deref()
2925 .and_then(StrictColumnType::from_type_name)
2926 } else {
2927 None
2928 };
2929
2930 let default_value = extract_default_value(remainder);
2931
2932 let collation = extract_collation_name(remainder);
2933 let (generated_expr, generated_stored) = extract_generated_column_clause(remainder);
2934
2935 Some(ColumnInfo {
2936 name,
2937 affinity,
2938 is_ipk,
2939 type_name,
2940 notnull: has_not_null,
2941 unique: has_unique || has_primary_key,
2942 default_value,
2943 strict_type,
2944 generated_expr,
2945 generated_stored,
2946 collation,
2947 conflict_action: None,
2948 })
2949 })
2950 .collect()
2951}
2952
2953#[must_use]
2956pub fn parse_columns_from_sqlite_master_sql(sql: &str) -> Vec<ColumnInfo> {
2957 if is_virtual_table_sql(sql) {
2958 return parse_virtual_table_columns_from_sql(sql)
2959 .unwrap_or_else(|| parse_columns_from_create_sql(sql));
2960 }
2961 parse_columns_from_create_sql(sql)
2962}
2963
2964pub(crate) fn validate_sqlite_master_root_page(name: &str, root_page_num: i64) -> Result<u32> {
2965 if root_page_num <= 0 {
2966 return Err(FrankenError::DatabaseCorrupt {
2967 detail: format!("sqlite_master entry `{name}` has invalid rootpage {root_page_num}"),
2968 });
2969 }
2970
2971 let root_page_u32 =
2972 u32::try_from(root_page_num).map_err(|_| FrankenError::DatabaseCorrupt {
2973 detail: format!(
2974 "sqlite_master entry `{name}` has out-of-range rootpage {root_page_num}"
2975 ),
2976 })?;
2977 i32::try_from(root_page_u32).map_err(|_| FrankenError::DatabaseCorrupt {
2978 detail: format!(
2979 "sqlite_master entry `{name}` has rootpage {root_page_num} that exceeds supported range"
2980 ),
2981 })?;
2982 Ok(root_page_u32)
2983}
2984
2985fn is_virtual_table_sql(sql: &str) -> bool {
2986 sql.trim_start()
2987 .to_ascii_uppercase()
2988 .starts_with("CREATE VIRTUAL TABLE")
2989}
2990
2991#[must_use]
2992pub fn is_without_rowid_table_sql(sql: &str) -> bool {
2993 if let Some(Statement::CreateTable(create)) = parse_single_statement(sql) {
2994 return create.without_rowid;
2995 }
2996
2997 let Some(close_paren) = sql.rfind(')') else {
2998 return false;
2999 };
3000 let tail = &sql[close_paren + 1..];
3001 unquoted_tokens_contain_phrase(&unquoted_sql_keyword_tokens(tail), &["WITHOUT", "ROWID"])
3002}
3003
3004fn parse_virtual_table_columns_from_sql(sql: &str) -> Option<Vec<ColumnInfo>> {
3005 let mut parser = Parser::from_sql(sql);
3006 let (statements, errors) = parser.parse_all();
3007 if !errors.is_empty() || statements.len() != 1 {
3008 return None;
3009 }
3010 match statements.into_iter().next()? {
3011 Statement::CreateVirtualTable(create) => {
3012 Some(parse_virtual_table_column_infos(&create.args))
3013 }
3014 _ => None,
3015 }
3016}
3017
3018fn parse_virtual_table_column_infos(args: &[String]) -> Vec<ColumnInfo> {
3019 let mut columns = Vec::new();
3020 let mut seen = std::collections::HashSet::<String>::new();
3021
3022 for arg in args {
3023 let trimmed = arg.trim();
3024 if trimmed.is_empty() || trimmed.contains('=') {
3025 continue;
3026 }
3027 let raw_name = trimmed
3028 .split_whitespace()
3029 .next()
3030 .unwrap_or_default()
3031 .trim_matches(|ch| matches!(ch, '"' | '\'' | '`' | '[' | ']'));
3032 if raw_name.is_empty() {
3033 continue;
3034 }
3035 let key = raw_name.to_ascii_lowercase();
3036 if !seen.insert(key) {
3037 continue;
3038 }
3039 columns.push(ColumnInfo {
3040 name: raw_name.to_owned(),
3041 affinity: 'A',
3051 is_ipk: false,
3052 type_name: None,
3053 notnull: false,
3054 unique: false,
3055 default_value: None,
3056 strict_type: None,
3057 generated_expr: None,
3058 generated_stored: None,
3059 collation: None,
3060 conflict_action: None,
3061 });
3062 }
3063
3064 if columns.is_empty() {
3065 columns.push(ColumnInfo {
3066 name: "content".to_owned(),
3067 affinity: 'A',
3069 is_ipk: false,
3070 type_name: None,
3071 notnull: false,
3072 unique: false,
3073 default_value: None,
3074 strict_type: None,
3075 generated_expr: None,
3076 generated_stored: None,
3077 collation: None,
3078 conflict_action: None,
3079 });
3080 }
3081
3082 columns
3083}
3084
3085#[must_use]
3087pub fn is_strict_table_sql(sql: &str) -> bool {
3088 if let Some(Statement::CreateTable(create)) = parse_single_statement(sql) {
3089 return create.strict;
3090 }
3091
3092 let Some(close_paren) = sql.rfind(')') else {
3093 return false;
3094 };
3095 let tail = &sql[close_paren + 1..];
3096 unquoted_sql_keyword_tokens(tail)
3097 .iter()
3098 .any(|keyword| matches!(keyword.as_str(), "STRICT"))
3099}
3100
3101#[must_use]
3103pub fn is_autoincrement_table_sql(sql: &str) -> bool {
3104 if let Some(Statement::CreateTable(create)) = parse_single_statement(sql) {
3105 return autoincrement_from_create_table_statement(&create);
3106 }
3107
3108 unquoted_sql_keyword_tokens(sql)
3109 .iter()
3110 .any(|keyword| matches!(keyword.as_str(), "AUTOINCREMENT"))
3111}
3112
3113pub(crate) fn autoincrement_from_create_table_statement(create: &CreateTableStatement) -> bool {
3114 let CreateTableBody::Columns { columns, .. } = &create.body else {
3115 return false;
3116 };
3117 columns.iter().any(|col| {
3118 let is_integer = col
3119 .type_name
3120 .as_ref()
3121 .is_some_and(|tn| tn.name.eq_ignore_ascii_case("INTEGER"));
3122 is_integer
3123 && col.constraints.iter().any(|constraint| {
3124 matches!(
3125 &constraint.kind,
3126 ColumnConstraintKind::PrimaryKey {
3127 autoincrement: true,
3128 direction,
3129 ..
3130 } if *direction != Some(SortDirection::Desc)
3131 )
3132 })
3133 })
3134}
3135
3136#[must_use]
3141pub fn extract_check_constraints_from_sql(sql: &str) -> Vec<String> {
3142 extract_check_constraints_with_owners_from_sql(sql)
3143 .into_iter()
3144 .map(|check| check.expr)
3145 .collect()
3146}
3147
3148pub(crate) fn extract_check_constraints_with_owners_from_sql(sql: &str) -> Vec<CheckConstraint> {
3149 if let Some(Statement::CreateTable(create)) = parse_single_statement(sql) {
3150 return check_constraints_from_create_table_statement(&create);
3151 }
3152
3153 extract_check_constraints_with_owners_sql_fallback(sql)
3154}
3155
3156fn extract_check_constraints_with_owners_sql_fallback(sql: &str) -> Vec<CheckConstraint> {
3157 let Some(open) = find_unquoted_sql_char(sql, '(') else {
3158 return Vec::new();
3159 };
3160 let Some(close) = find_matching_sql_paren(sql, open) else {
3161 return Vec::new();
3162 };
3163 let body = &sql[open + 1..close];
3164 let mut checks = Vec::new();
3165
3166 for definition in split_top_level_csv_items(body) {
3167 let owner_column = if starts_with_unquoted_table_constraint(&definition) {
3168 None
3169 } else {
3170 parse_column_name_and_remainder(&definition).map(|(name, _)| name)
3171 };
3172 let mut search_from = 0_usize;
3173 while let Some(relative_check) =
3174 find_unquoted_sql_keyword(&definition[search_from..], "CHECK")
3175 {
3176 let check_start = search_from + relative_check;
3177 let after_keyword = &definition[check_start + "CHECK".len()..];
3178 let after_space_and_comments = trim_leading_sql_space_and_comments(after_keyword);
3179 let skipped = after_keyword.len() - after_space_and_comments.len();
3180 let open_paren = check_start + "CHECK".len() + skipped;
3181 if !after_space_and_comments.starts_with('(') {
3182 search_from = check_start + "CHECK".len();
3183 continue;
3184 }
3185 let Some(close_paren) = find_matching_sql_paren(&definition, open_paren) else {
3186 break;
3187 };
3188 checks.push(CheckConstraint {
3189 expr: definition[open_paren + 1..close_paren].trim().to_owned(),
3190 owner_column: owner_column.clone(),
3191 });
3192 search_from = close_paren + 1;
3193 }
3194 }
3195 checks
3196}
3197
3198pub(crate) fn check_constraints_from_create_table_statement(
3199 create: &CreateTableStatement,
3200) -> Vec<CheckConstraint> {
3201 let CreateTableBody::Columns {
3202 columns,
3203 constraints,
3204 } = &create.body
3205 else {
3206 return Vec::new();
3207 };
3208 let mut checks = Vec::new();
3209 for column in columns {
3210 for constraint in &column.constraints {
3211 if let ColumnConstraintKind::Check(expr) = &constraint.kind {
3212 checks.push(CheckConstraint {
3213 expr: expr.to_string(),
3214 owner_column: Some(column.name.clone()),
3215 });
3216 }
3217 }
3218 }
3219 for constraint in constraints {
3220 if let TableConstraintKind::Check(expr) = &constraint.kind {
3221 checks.push(CheckConstraint {
3222 expr: expr.to_string(),
3223 owner_column: None,
3224 });
3225 }
3226 }
3227 checks
3228}
3229
3230fn parse_column_name_and_remainder(def: &str) -> Option<(String, &str)> {
3231 let trimmed = def.trim_start();
3232 if trimmed.is_empty() {
3233 return None;
3234 }
3235 let bytes = trimmed.as_bytes();
3236 let (name_raw, remainder) = match bytes[0] {
3237 b'"' => parse_quoted_identifier(trimmed, b'"', b'"')?,
3238 b'`' => parse_quoted_identifier(trimmed, b'`', b'`')?,
3239 b'[' => parse_bracket_identifier(trimmed)?,
3240 _ => {
3241 let end = find_unquoted_name_end(trimmed);
3242 (&trimmed[..end], &trimmed[end..])
3243 }
3244 };
3245 Some((
3246 strip_identifier_quotes(name_raw),
3247 trim_leading_sql_space_and_comments(remainder),
3248 ))
3249}
3250
3251fn parse_single_statement(sql: &str) -> Option<Statement> {
3252 let mut parser = Parser::from_sql(sql);
3253 let (statements, errors) = parser.parse_all();
3254 if !errors.is_empty() || statements.len() != 1 {
3255 return None;
3256 }
3257 statements.into_iter().next()
3258}
3259
3260fn format_default_value(dv: &DefaultValue) -> String {
3261 match dv {
3262 DefaultValue::Expr(expr) => expr.to_string(),
3263 DefaultValue::ParenExpr(expr) => format!("({expr})"),
3264 }
3265}
3266
3267fn indexed_column_name(indexed_column: &fsqlite_ast::IndexedColumn) -> Option<&str> {
3268 fn extract(expr: &Expr) -> Option<&str> {
3269 match expr {
3270 Expr::Column(column, _) if column.table.is_none() => Some(&column.column),
3271 Expr::Literal(Literal::String(name), _) => Some(name),
3274 Expr::Collate { expr, .. } => extract(expr),
3275 _ => None,
3276 }
3277 }
3278
3279 extract(&indexed_column.expr)
3280}
3281
3282fn strip_wrapping_default_parens(mut default_sql: &str) -> &str {
3283 loop {
3284 let trimmed = default_sql.trim();
3285 let bytes = trimmed.as_bytes();
3286 if bytes.first() != Some(&b'(') || bytes.last() != Some(&b')') {
3287 return trimmed;
3288 }
3289
3290 let mut depth = 0_i32;
3291 let mut idx = 0_usize;
3292 let mut wraps_entire_expr = false;
3293 while idx < bytes.len() {
3294 match bytes[idx] {
3295 quote @ (b'\'' | b'"') => {
3296 idx += 1;
3297 while idx < bytes.len() {
3298 if bytes[idx] == quote {
3299 if idx + 1 < bytes.len() && bytes[idx + 1] == quote {
3300 idx += 2;
3301 } else {
3302 idx += 1;
3303 break;
3304 }
3305 } else {
3306 idx += 1;
3307 }
3308 }
3309 continue;
3310 }
3311 b'(' => depth += 1,
3312 b')' => {
3313 depth -= 1;
3314 if depth == 0 {
3315 wraps_entire_expr = idx == bytes.len() - 1;
3316 break;
3317 }
3318 if depth < 0 {
3319 return trimmed;
3320 }
3321 }
3322 _ => {}
3323 }
3324 idx += 1;
3325 }
3326
3327 if !wraps_entire_expr || depth != 0 {
3328 return trimmed;
3329 }
3330 default_sql = &trimmed[1..trimmed.len() - 1];
3331 }
3332}
3333
3334fn parse_wrapped_default_text(default_sql: &str, quote: char) -> Option<SqliteValue> {
3335 if !default_sql.starts_with(quote) {
3336 return None;
3337 }
3338 let mut value = String::new();
3339 let body = &default_sql[quote.len_utf8()..];
3340 let mut chars = body.char_indices().peekable();
3341
3342 while let Some((offset, ch)) = chars.next() {
3343 if ch != quote {
3344 value.push(ch);
3345 continue;
3346 }
3347 if let Some((_, next_ch)) = chars.peek()
3348 && *next_ch == quote
3349 {
3350 value.push(quote);
3351 let _ = chars.next();
3352 continue;
3353 }
3354 let absolute_end = quote.len_utf8() + offset + ch.len_utf8();
3355 return (absolute_end == default_sql.len()).then(|| SqliteValue::Text(value.into()));
3356 }
3357
3358 None
3359}
3360
3361fn loaded_default_literal_value(literal: &Literal) -> Option<SqliteValue> {
3362 match literal {
3363 Literal::Integer(value) => Some(SqliteValue::Integer(*value)),
3364 Literal::Float(value) => Some(SqliteValue::Float(*value)),
3365 Literal::String(value) => Some(SqliteValue::Text(value.clone().into())),
3366 Literal::Blob(value) => Some(SqliteValue::from(value.clone())),
3367 Literal::Null => Some(SqliteValue::Null),
3368 Literal::True => Some(SqliteValue::Integer(1)),
3369 Literal::False => Some(SqliteValue::Integer(0)),
3370 Literal::CurrentTime | Literal::CurrentDate | Literal::CurrentTimestamp => None,
3371 }
3372}
3373
3374fn loaded_constant_default_expr_value(expr: &Expr) -> Option<SqliteValue> {
3375 match expr {
3376 Expr::Literal(literal, _) => loaded_default_literal_value(literal),
3377 Expr::UnaryOp {
3378 op: UnaryOp::Plus,
3379 expr,
3380 ..
3381 } => match loaded_constant_default_expr_value(expr)? {
3382 value @ (SqliteValue::Integer(_) | SqliteValue::Float(_)) => Some(value),
3383 _ => None,
3384 },
3385 Expr::UnaryOp {
3386 op: UnaryOp::Negate,
3387 expr,
3388 ..
3389 } => match loaded_constant_default_expr_value(expr)? {
3390 SqliteValue::Integer(value) => Some(
3391 value
3392 .checked_neg()
3393 .map_or_else(|| SqliteValue::Float(-(value as f64)), SqliteValue::Integer),
3394 ),
3395 SqliteValue::Float(value) => Some(SqliteValue::Float(-value)),
3396 _ => None,
3397 },
3398 _ => None,
3399 }
3400}
3401
3402fn parse_loaded_column_default_value(default_sql: &str) -> SqliteValue {
3403 let default_sql = strip_wrapping_default_parens(default_sql);
3404 if let Some(value) = parse_wrapped_default_text(default_sql, '\'')
3405 .or_else(|| parse_wrapped_default_text(default_sql, '"'))
3406 {
3407 return value;
3408 }
3409 if let Ok(expr) = fsqlite_parser::expr::parse_expr(default_sql)
3410 && let Some(value) = loaded_constant_default_expr_value(&expr)
3411 {
3412 return value;
3413 }
3414 SqliteValue::Text(default_sql.into())
3415}
3416
3417fn inflate_loaded_table_row_values(
3418 values: &mut Vec<SqliteValue>,
3419 rowid: i64,
3420 columns: &[ColumnInfo],
3421 rowid_alias_col_idx: Option<usize>,
3422 table_name: &str,
3423) -> Result<()> {
3424 let num_columns = columns.len();
3425 if values.len() > num_columns {
3426 return Err(FrankenError::DatabaseCorrupt {
3427 detail: format!(
3428 "table `{table_name}` rowid {rowid} payload has {} columns; expected at most {num_columns}",
3429 values.len()
3430 ),
3431 });
3432 }
3433 if let Some(ipk_idx) = rowid_alias_col_idx
3434 && ipk_idx >= num_columns
3435 {
3436 return Err(FrankenError::DatabaseCorrupt {
3437 detail: format!(
3438 "table `{table_name}` rowid {rowid} has invalid INTEGER PRIMARY KEY alias column index {ipk_idx}"
3439 ),
3440 });
3441 }
3442
3443 let payload_values = std::mem::take(values);
3444 let inflated = inflate_loaded_table_row_values_from_payload(
3445 &payload_values,
3446 rowid,
3447 columns,
3448 rowid_alias_col_idx,
3449 table_name,
3450 )?;
3451 *values = inflated;
3452
3453 Ok(())
3454}
3455
3456fn inflate_loaded_table_row_values_from_payload(
3457 payload_values: &[SqliteValue],
3458 rowid: i64,
3459 columns: &[ColumnInfo],
3460 rowid_alias_col_idx: Option<usize>,
3461 table_name: &str,
3462) -> Result<Vec<SqliteValue>> {
3463 let Some(ipk_idx) = rowid_alias_col_idx else {
3464 return inflate_loaded_table_row_values_with_alias_alignment(
3465 payload_values,
3466 rowid,
3467 columns,
3468 None,
3469 false,
3470 table_name,
3471 );
3472 };
3473
3474 if payload_values.len() == columns.len() {
3475 return inflate_loaded_table_row_values_with_alias_alignment(
3476 payload_values,
3477 rowid,
3478 columns,
3479 Some(ipk_idx),
3480 true,
3481 table_name,
3482 );
3483 }
3484
3485 let Some(value_at_alias_position) = payload_values.get(ipk_idx) else {
3486 return inflate_loaded_table_row_values_with_alias_alignment(
3487 payload_values,
3488 rowid,
3489 columns,
3490 Some(ipk_idx),
3491 false,
3492 table_name,
3493 );
3494 };
3495
3496 let alias_slot_could_be_present = match value_at_alias_position {
3497 SqliteValue::Null => true,
3498 SqliteValue::Integer(encoded_rowid) => *encoded_rowid == rowid,
3499 _ => false,
3500 };
3501 if !alias_slot_could_be_present {
3502 return inflate_loaded_table_row_values_with_alias_alignment(
3503 payload_values,
3504 rowid,
3505 columns,
3506 Some(ipk_idx),
3507 false,
3508 table_name,
3509 );
3510 }
3511
3512 let with_alias = inflate_loaded_table_row_values_with_alias_alignment(
3513 payload_values,
3514 rowid,
3515 columns,
3516 Some(ipk_idx),
3517 true,
3518 table_name,
3519 )?;
3520 let without_alias = inflate_loaded_table_row_values_with_alias_alignment(
3521 payload_values,
3522 rowid,
3523 columns,
3524 Some(ipk_idx),
3525 false,
3526 table_name,
3527 )?;
3528 let with_alias_valid = loaded_row_values_satisfy_notnull(columns, &with_alias);
3529 let without_alias_valid = loaded_row_values_satisfy_notnull(columns, &without_alias);
3530
3531 if !with_alias_valid && !without_alias_valid {
3532 return Err(FrankenError::DatabaseCorrupt {
3533 detail: format!(
3534 "table `{table_name}` rowid {rowid} short payload violates NOT NULL constraints under both rowid-alias alignments"
3535 ),
3536 });
3537 }
3538 if with_alias_valid
3539 && (!without_alias_valid || matches!(value_at_alias_position, SqliteValue::Null))
3540 {
3541 Ok(with_alias)
3542 } else {
3543 Ok(without_alias)
3544 }
3545}
3546
3547fn inflate_loaded_table_row_values_with_alias_alignment(
3548 payload_values: &[SqliteValue],
3549 rowid: i64,
3550 columns: &[ColumnInfo],
3551 rowid_alias_col_idx: Option<usize>,
3552 payload_includes_rowid_alias: bool,
3553 table_name: &str,
3554) -> Result<Vec<SqliteValue>> {
3555 let mut inflated = Vec::with_capacity(columns.len());
3556 let mut payload_idx = 0_usize;
3557
3558 for (col_idx, column) in columns.iter().enumerate() {
3559 if rowid_alias_col_idx == Some(col_idx) && !payload_includes_rowid_alias {
3560 inflated.push(SqliteValue::Integer(rowid));
3561 continue;
3562 }
3563
3564 let value = if let Some(value) = payload_values.get(payload_idx) {
3565 payload_idx += 1;
3566 value.clone()
3567 } else if let Some(default_sql) = column.default_value.as_ref() {
3568 parse_loaded_column_default_value(default_sql)
3569 } else {
3570 SqliteValue::Null
3571 };
3572
3573 if rowid_alias_col_idx == Some(col_idx) {
3574 match &value {
3575 SqliteValue::Null => {
3576 inflated.push(SqliteValue::Integer(rowid));
3577 continue;
3578 }
3579 SqliteValue::Integer(encoded_rowid) if *encoded_rowid == rowid => {}
3580 SqliteValue::Integer(encoded_rowid) => {
3581 return Err(FrankenError::DatabaseCorrupt {
3582 detail: format!(
3583 "table `{table_name}` rowid {rowid} stores inconsistent INTEGER PRIMARY KEY alias value {encoded_rowid}"
3584 ),
3585 });
3586 }
3587 other => {
3588 return Err(FrankenError::DatabaseCorrupt {
3589 detail: format!(
3590 "table `{table_name}` rowid {rowid} stores non-integer INTEGER PRIMARY KEY alias value {other:?}"
3591 ),
3592 });
3593 }
3594 }
3595 }
3596
3597 inflated.push(value);
3598 }
3599
3600 if payload_idx != payload_values.len() {
3601 return Err(FrankenError::DatabaseCorrupt {
3602 detail: format!(
3603 "table `{table_name}` rowid {rowid} left {} payload columns unconsumed after rowid-alias inflation",
3604 payload_values.len() - payload_idx
3605 ),
3606 });
3607 }
3608
3609 Ok(inflated)
3610}
3611
3612fn loaded_row_values_satisfy_notnull(columns: &[ColumnInfo], values: &[SqliteValue]) -> bool {
3613 values.len() == columns.len()
3614 && columns.iter().zip(values.iter()).all(|(column, value)| {
3615 !column.notnull || column.is_ipk || !matches!(value, SqliteValue::Null)
3616 })
3617}
3618
3619fn try_parse_columns_from_create_sql_ast(sql: &str) -> Option<Vec<ColumnInfo>> {
3620 let Statement::CreateTable(create) = parse_single_statement(sql)? else {
3621 return None;
3622 };
3623 columns_from_create_table_statement(&create)
3624}
3625
3626pub(crate) fn columns_from_create_table_statement(
3627 create: &CreateTableStatement,
3628) -> Option<Vec<ColumnInfo>> {
3629 let CreateTableBody::Columns { columns, .. } = &create.body else {
3630 return None;
3631 };
3632
3633 let mut table_pk_rowid = None;
3634
3635 if let CreateTableBody::Columns { constraints, .. } = &create.body {
3636 for constraint in constraints {
3637 match &constraint.kind {
3638 TableConstraintKind::PrimaryKey {
3639 columns: pk_columns,
3640 conflict,
3641 } if pk_columns.len() == 1 => {
3642 let Some(column_name) = indexed_column_name(&pk_columns[0]) else {
3643 continue;
3644 };
3645 let Some(index) = columns
3646 .iter()
3647 .position(|col| col.name.eq_ignore_ascii_case(column_name))
3648 else {
3649 continue;
3650 };
3651
3652 let is_integer = column_def_is_exact_integer(&columns[index]);
3653 if is_integer && !create.without_rowid {
3654 table_pk_rowid = Some((index, *conflict));
3655 }
3656 }
3657 _ => {}
3658 }
3659 }
3660 }
3661
3662 let rowid_col_idx = columns
3663 .iter()
3664 .enumerate()
3665 .find_map(|(index, col)| {
3666 let is_integer = column_def_is_exact_integer(col);
3667 let pk = col.constraints.iter().find_map(|constraint| {
3668 if let ColumnConstraintKind::PrimaryKey { direction, .. } = &constraint.kind {
3669 if *direction != Some(SortDirection::Desc) {
3670 Some(())
3671 } else {
3672 None
3673 }
3674 } else {
3675 None
3676 }
3677 });
3678 if is_integer && pk.is_some() && !create.without_rowid {
3679 Some(index)
3680 } else {
3681 None
3682 }
3683 })
3684 .or_else(|| table_pk_rowid.map(|(index, _)| index));
3685
3686 Some(
3687 columns
3688 .iter()
3689 .enumerate()
3690 .map(|(index, col)| {
3691 let affinity = col
3692 .type_name
3693 .as_ref()
3694 .map_or('A', |type_name| type_to_affinity(&type_name.name));
3695 let type_name = col.type_name.as_ref().map(std::string::ToString::to_string);
3696 let is_ipk = rowid_col_idx.is_some_and(|rowid_index| rowid_index == index);
3697 let notnull = col.constraints.iter().any(|constraint| {
3698 matches!(&constraint.kind, ColumnConstraintKind::NotNull { .. })
3699 });
3700 let has_primary_key = col.constraints.iter().any(|constraint| {
3701 matches!(&constraint.kind, ColumnConstraintKind::PrimaryKey { .. })
3702 });
3703 let unique = (!is_ipk && has_primary_key)
3704 || col.constraints.iter().any(|constraint| {
3705 matches!(&constraint.kind, ColumnConstraintKind::Unique { .. })
3706 });
3707 let default_value = col
3708 .constraints
3709 .iter()
3710 .find_map(|constraint| match &constraint.kind {
3711 ColumnConstraintKind::Default(default_value) => {
3712 Some(format_default_value(default_value))
3713 }
3714 _ => None,
3715 });
3716 let strict_type = if create.strict {
3717 type_name
3718 .as_deref()
3719 .and_then(StrictColumnType::from_type_name)
3720 } else {
3721 None
3722 };
3723 let (generated_expr, generated_stored) = col
3724 .constraints
3725 .iter()
3726 .find_map(|constraint| match &constraint.kind {
3727 ColumnConstraintKind::Generated { expr, storage } => {
3728 let stored = storage
3729 .as_ref()
3730 .is_some_and(|storage| *storage == GeneratedStorage::Stored);
3731 Some((Some(expr.to_string()), Some(stored)))
3732 }
3733 _ => None,
3734 })
3735 .unwrap_or((None, None));
3736 let collation = col.constraints.iter().rev().find_map(|constraint| {
3737 if let ColumnConstraintKind::Collate(name) = &constraint.kind {
3738 Some(name.clone())
3739 } else {
3740 None
3741 }
3742 });
3743 let conflict_action = if is_ipk {
3747 col.constraints
3748 .iter()
3749 .find_map(|constraint| match &constraint.kind {
3750 ColumnConstraintKind::PrimaryKey { conflict, .. } => *conflict,
3751 _ => None,
3752 })
3753 .or_else(|| {
3754 table_pk_rowid.and_then(|(pk_index, conflict)| {
3755 (pk_index == index).then_some(conflict).flatten()
3756 })
3757 })
3758 } else {
3759 col.constraints
3760 .iter()
3761 .find_map(|constraint| match &constraint.kind {
3762 ColumnConstraintKind::NotNull { conflict } => *conflict,
3763 _ => None,
3764 })
3765 };
3766
3767 ColumnInfo {
3768 name: col.name.clone(),
3769 affinity,
3770 is_ipk,
3771 type_name,
3772 notnull,
3773 unique,
3774 default_value,
3775 strict_type,
3776 generated_expr,
3777 generated_stored,
3778 collation,
3779 conflict_action,
3780 }
3781 })
3782 .collect(),
3783 )
3784}
3785
3786fn parse_quoted_identifier(input: &str, quote: u8, escape: u8) -> Option<(&str, &str)> {
3787 let bytes = input.as_bytes();
3788 let mut i = 1usize;
3789 while i < bytes.len() {
3790 if bytes[i] == quote {
3791 if i + 1 < bytes.len() && bytes[i + 1] == escape {
3792 i += 2;
3793 continue;
3794 }
3795 return Some((&input[..=i], &input[i + 1..]));
3796 }
3797 i += 1;
3798 }
3799 None
3800}
3801
3802fn parse_bracket_identifier(input: &str) -> Option<(&str, &str)> {
3803 let bytes = input.as_bytes();
3804 let mut i = 1usize;
3805 while i < bytes.len() {
3806 if bytes[i] == b']' {
3807 return Some((&input[..=i], &input[i + 1..]));
3808 }
3809 i += 1;
3810 }
3811 None
3812}
3813
3814const COLUMN_CONSTRAINT_KEYWORDS: &[&str] = &[
3815 "CONSTRAINT",
3816 "PRIMARY",
3817 "NOT",
3818 "NULL",
3819 "UNIQUE",
3820 "CHECK",
3821 "DEFAULT",
3822 "COLLATE",
3823 "REFERENCES",
3824 "GENERATED",
3825 "AS",
3826];
3827
3828fn split_top_level_csv_items(input: &str) -> Vec<String> {
3831 let mut chars = input.char_indices().peekable();
3832 let mut out = Vec::new();
3833 let mut current = String::new();
3834 let mut paren_depth = 0usize;
3835 let mut quote: Option<char> = None;
3836 let mut in_brackets = false;
3837
3838 while let Some((_, ch)) = chars.next() {
3839 if let Some(q) = quote {
3840 current.push(ch);
3841 if ch == q {
3842 if let Some(&(_, next_ch)) = chars.peek() {
3843 if next_ch == q {
3844 current.push(next_ch);
3845 chars.next();
3846 } else {
3847 quote = None;
3848 }
3849 } else {
3850 quote = None;
3851 }
3852 }
3853 continue;
3854 }
3855
3856 if in_brackets {
3857 current.push(ch);
3858 if ch == ']' {
3859 in_brackets = false;
3860 }
3861 continue;
3862 }
3863
3864 match ch {
3865 '\'' | '"' | '`' => {
3866 quote = Some(ch);
3867 current.push(ch);
3868 }
3869 '[' => {
3870 in_brackets = true;
3871 current.push(ch);
3872 }
3873 '-' if chars.peek().is_some_and(|(_, next_ch)| *next_ch == '-') => {
3874 chars.next();
3875 let ends_with_whitespace = current.chars().last().is_some_and(char::is_whitespace);
3876 if !current.trim_end().is_empty() && !ends_with_whitespace {
3877 current.push(' ');
3878 }
3879
3880 while let Some((_, next_ch)) = chars.next() {
3881 if next_ch == '\n' {
3882 break;
3883 }
3884 if next_ch == '\r' {
3885 if chars.peek().is_some_and(|(_, next_ch)| *next_ch == '\n') {
3886 chars.next();
3887 }
3888 break;
3889 }
3890 }
3891 }
3892 '/' if chars.peek().is_some_and(|(_, next_ch)| *next_ch == '*') => {
3893 chars.next();
3894 let ends_with_whitespace = current.chars().last().is_some_and(char::is_whitespace);
3895 if !current.trim_end().is_empty() && !ends_with_whitespace {
3896 current.push(' ');
3897 }
3898
3899 let mut previous = '\0';
3900 for (_, next_ch) in chars.by_ref() {
3901 if previous == '*' && next_ch == '/' {
3902 break;
3903 }
3904 previous = next_ch;
3905 }
3906 }
3907 '(' => {
3908 paren_depth = paren_depth.saturating_add(1);
3909 current.push(ch);
3910 }
3911 ')' => {
3912 paren_depth = paren_depth.saturating_sub(1);
3913 current.push(ch);
3914 }
3915 ',' if paren_depth == 0 => {
3916 let part = current.trim();
3917 if !part.is_empty() {
3918 out.push(part.to_owned());
3919 }
3920 current.clear();
3921 }
3922 _ => current.push(ch),
3923 }
3924 }
3925
3926 let tail = current.trim();
3927 if !tail.is_empty() {
3928 out.push(tail.to_owned());
3929 }
3930
3931 out
3932}
3933
3934fn find_unquoted_name_end(input: &str) -> usize {
3935 let mut chars = input.char_indices().peekable();
3936 while let Some((idx, ch)) = chars.next() {
3937 if ch.is_whitespace() {
3938 return idx;
3939 }
3940 if ch == '-' && chars.peek().is_some_and(|(_, next_ch)| *next_ch == '-') {
3941 return idx;
3942 }
3943 if ch == '/' && chars.peek().is_some_and(|(_, next_ch)| *next_ch == '*') {
3944 return idx;
3945 }
3946 }
3947 input.len()
3948}
3949
3950fn starts_with_unquoted_table_constraint(def: &str) -> bool {
3951 let trimmed = trim_leading_sql_space_and_comments(def);
3952 if trimmed.is_empty() {
3953 return false;
3954 }
3955 match trimmed.as_bytes()[0] {
3956 b'"' | b'`' | b'[' => return false,
3957 _ => {}
3958 }
3959 collect_unquoted_sql_keyword_tokens(trimmed)
3960 .first()
3961 .is_some_and(|(token, start)| {
3962 *start == 0
3963 && matches!(
3964 token.as_str(),
3965 "CONSTRAINT" | "PRIMARY" | "UNIQUE" | "CHECK" | "FOREIGN"
3966 )
3967 })
3968}
3969
3970type SqlCharIndices<'a> = std::iter::Peekable<std::str::CharIndices<'a>>;
3971
3972fn unquoted_sql_keyword_tokens(input: &str) -> Vec<String> {
3973 collect_unquoted_sql_keyword_tokens(input)
3974 .into_iter()
3975 .map(|(token, _)| token)
3976 .collect()
3977}
3978
3979fn find_unquoted_sql_keyword(input: &str, keyword: &str) -> Option<usize> {
3980 let keyword = keyword.to_ascii_uppercase();
3981 collect_unquoted_sql_keyword_tokens(input)
3982 .into_iter()
3983 .find_map(|(token, start)| (token == keyword).then_some(start))
3984}
3985
3986fn find_top_level_unquoted_sql_keyword(input: &str, keyword: &str) -> Option<usize> {
3987 let mut chars = input.char_indices().peekable();
3988 let mut paren_depth = 0_usize;
3989 let mut token_start = None;
3990
3991 while let Some((idx, ch)) = chars.next() {
3992 let is_token_char = ch.is_ascii_alphanumeric() || ch == '_';
3993 if paren_depth == 0 && is_token_char {
3994 token_start.get_or_insert(idx);
3995 continue;
3996 }
3997 if let Some(start) = token_start.take()
3998 && input[start..idx].eq_ignore_ascii_case(keyword)
3999 {
4000 return Some(start);
4001 }
4002
4003 match ch {
4004 '\'' | '"' | '`' => skip_quoted_sql(&mut chars, ch),
4005 '[' => skip_bracket_identifier(&mut chars),
4006 '-' if chars.peek().is_some_and(|(_, next_ch)| *next_ch == '-') => {
4007 let _ = chars.next();
4008 skip_line_comment(&mut chars);
4009 }
4010 '/' if chars.peek().is_some_and(|(_, next_ch)| *next_ch == '*') => {
4011 let _ = chars.next();
4012 skip_block_comment(&mut chars);
4013 }
4014 '(' => paren_depth += 1,
4015 ')' => paren_depth = paren_depth.saturating_sub(1),
4016 _ => {}
4017 }
4018 }
4019
4020 token_start.filter(|start| input[*start..].eq_ignore_ascii_case(keyword))
4021}
4022
4023fn find_unquoted_sql_char(input: &str, target: char) -> Option<usize> {
4024 let mut chars = input.char_indices().peekable();
4025 while let Some((idx, ch)) = chars.next() {
4026 match ch {
4027 '\'' | '"' | '`' => skip_quoted_sql(&mut chars, ch),
4028 '[' => skip_bracket_identifier(&mut chars),
4029 '-' if chars.peek().is_some_and(|(_, next_ch)| *next_ch == '-') => {
4030 let _ = chars.next();
4031 skip_line_comment(&mut chars);
4032 }
4033 '/' if chars.peek().is_some_and(|(_, next_ch)| *next_ch == '*') => {
4034 let _ = chars.next();
4035 skip_block_comment(&mut chars);
4036 }
4037 _ if ch == target => return Some(idx),
4038 _ => {}
4039 }
4040 }
4041 None
4042}
4043
4044fn find_matching_sql_paren(input: &str, open_idx: usize) -> Option<usize> {
4045 if input.as_bytes().get(open_idx).copied() != Some(b'(') {
4046 return None;
4047 }
4048
4049 let mut depth = 0_usize;
4050 let mut chars = input[open_idx..].char_indices().peekable();
4051 while let Some((rel_idx, ch)) = chars.next() {
4052 let idx = open_idx + rel_idx;
4053 match ch {
4054 '\'' | '"' | '`' => skip_quoted_sql(&mut chars, ch),
4055 '[' => skip_bracket_identifier(&mut chars),
4056 '-' if chars.peek().is_some_and(|(_, next_ch)| *next_ch == '-') => {
4057 let _ = chars.next();
4058 skip_line_comment(&mut chars);
4059 }
4060 '/' if chars.peek().is_some_and(|(_, next_ch)| *next_ch == '*') => {
4061 let _ = chars.next();
4062 skip_block_comment(&mut chars);
4063 }
4064 '(' => depth += 1,
4065 ')' => {
4066 depth = depth.checked_sub(1)?;
4067 if depth == 0 {
4068 return Some(idx);
4069 }
4070 }
4071 _ => {}
4072 }
4073 }
4074 None
4075}
4076
4077fn trim_leading_sql_space_and_comments(mut input: &str) -> &str {
4078 loop {
4079 let trimmed = input.trim_start();
4080 if let Some(rest) = trimmed.strip_prefix("--") {
4081 let end = rest.find(['\n', '\r']).map_or(rest.len(), |idx| idx + 1);
4082 input = &rest[end..];
4083 continue;
4084 }
4085 if let Some(rest) = trimmed.strip_prefix("/*") {
4086 let Some(end) = rest.find("*/") else {
4087 return "";
4088 };
4089 input = &rest[end + 2..];
4090 continue;
4091 }
4092 return trimmed;
4093 }
4094}
4095
4096fn collect_unquoted_sql_keyword_tokens(input: &str) -> Vec<(String, usize)> {
4097 let mut tokens = Vec::new();
4098 let mut current = String::new();
4099 let mut current_start = 0_usize;
4100 let mut chars = input.char_indices().peekable();
4101
4102 while let Some((idx, ch)) = chars.next() {
4103 match ch {
4104 '\'' | '"' | '`' => {
4105 push_keyword_token(&mut tokens, &mut current, current_start);
4106 skip_quoted_sql(&mut chars, ch);
4107 }
4108 '[' => {
4109 push_keyword_token(&mut tokens, &mut current, current_start);
4110 skip_bracket_identifier(&mut chars);
4111 }
4112 '-' if chars.peek().is_some_and(|(_, next_ch)| *next_ch == '-') => {
4113 let _ = chars.next();
4114 push_keyword_token(&mut tokens, &mut current, current_start);
4115 skip_line_comment(&mut chars);
4116 }
4117 '/' if chars.peek().is_some_and(|(_, next_ch)| *next_ch == '*') => {
4118 let _ = chars.next();
4119 push_keyword_token(&mut tokens, &mut current, current_start);
4120 skip_block_comment(&mut chars);
4121 }
4122 _ if ch.is_ascii_alphanumeric() || matches!(ch, '_') => {
4123 if current.is_empty() {
4124 current_start = idx;
4125 }
4126 current.push(ch.to_ascii_uppercase());
4127 }
4128 _ => push_keyword_token(&mut tokens, &mut current, current_start),
4129 }
4130 }
4131
4132 push_keyword_token(&mut tokens, &mut current, current_start);
4133 tokens
4134}
4135
4136fn push_keyword_token(
4137 tokens: &mut Vec<(String, usize)>,
4138 current: &mut String,
4139 current_start: usize,
4140) {
4141 if !current.is_empty() {
4142 tokens.push((std::mem::take(current), current_start));
4143 }
4144}
4145
4146fn skip_quoted_sql(chars: &mut SqlCharIndices<'_>, quote: char) {
4147 while let Some((_, ch)) = chars.next() {
4148 if ch != quote {
4149 continue;
4150 }
4151 if chars.peek().is_some_and(|(_, next_ch)| *next_ch == quote) {
4152 let _ = chars.next();
4153 } else {
4154 break;
4155 }
4156 }
4157}
4158
4159fn skip_bracket_identifier(chars: &mut SqlCharIndices<'_>) {
4160 for (_, ch) in chars.by_ref() {
4161 if ch == ']' {
4162 break;
4163 }
4164 }
4165}
4166
4167fn skip_line_comment(chars: &mut SqlCharIndices<'_>) {
4168 for (_, ch) in chars.by_ref() {
4169 if ch == '\n' || ch == '\r' {
4170 break;
4171 }
4172 }
4173}
4174
4175fn skip_block_comment(chars: &mut SqlCharIndices<'_>) {
4176 let mut previous = '\0';
4177 for (_, ch) in chars.by_ref() {
4178 if previous == '*' && ch == '/' {
4179 break;
4180 }
4181 previous = ch;
4182 }
4183}
4184
4185fn unquoted_tokens_contain_phrase(tokens: &[String], phrase: &[&str]) -> bool {
4186 !phrase.is_empty()
4187 && tokens.len() >= phrase.len()
4188 && tokens.windows(phrase.len()).any(|window| {
4189 window
4190 .iter()
4191 .zip(phrase)
4192 .all(|(token, expected)| token.as_str() == *expected)
4193 })
4194}
4195
4196fn extract_collation_name(remainder: &str) -> Option<String> {
4197 let raw_name = remainder.get(find_collation_name_range(remainder)?)?;
4198 let name = strip_sql_name_quotes(raw_name);
4199 (!name.is_empty()).then(|| name.to_ascii_uppercase())
4200}
4201
4202fn find_collation_name_range(remainder: &str) -> Option<std::ops::Range<usize>> {
4203 let pos = find_unquoted_sql_keyword(remainder, "COLLATE")?;
4204 let after = trim_leading_sql_space_and_comments(&remainder[pos + 7..]);
4205 let start = remainder.len().checked_sub(after.len())?;
4206 let bytes = after.as_bytes();
4207 if bytes.is_empty() {
4208 return None;
4209 }
4210
4211 let raw_len = match bytes[0] {
4212 b'\'' => parse_quoted_identifier(after, b'\'', b'\'')?.0,
4213 b'"' => parse_quoted_identifier(after, b'"', b'"')?.0,
4214 b'`' => parse_quoted_identifier(after, b'`', b'`')?.0,
4215 b'[' => parse_bracket_identifier(after)?.0,
4216 _ => {
4217 let end = after
4218 .find(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_'))
4219 .unwrap_or(after.len());
4220 &after[..end]
4221 }
4222 }
4223 .len();
4224 (raw_len > 0).then_some(start..start + raw_len)
4225}
4226
4227fn strip_sql_name_quotes(token: &str) -> String {
4228 let trimmed = token.trim();
4229 if trimmed.len() >= 2 {
4230 if trimmed.starts_with('\'') && trimmed.ends_with('\'') {
4231 return trimmed[1..trimmed.len() - 1].replace("''", "'");
4232 }
4233 return strip_identifier_quotes(trimmed);
4234 }
4235 trimmed.to_owned()
4236}
4237
4238fn strip_identifier_quotes(token: &str) -> String {
4239 let trimmed = token.trim();
4240 if trimmed.len() >= 2 {
4241 if trimmed.starts_with('"') && trimmed.ends_with('"') {
4242 return trimmed[1..trimmed.len() - 1].replace("\"\"", "\"");
4243 }
4244 if trimmed.starts_with('`') && trimmed.ends_with('`') {
4245 return trimmed[1..trimmed.len() - 1].replace("``", "`");
4246 }
4247 if trimmed.starts_with('[') && trimmed.ends_with(']') {
4248 return trimmed[1..trimmed.len() - 1].to_owned();
4249 }
4250 }
4251 trimmed.to_owned()
4252}
4253
4254fn extract_type_declaration(tokens: &[&str]) -> String {
4255 let mut parts = Vec::new();
4256 let mut paren_depth = 0isize;
4257 for token in tokens {
4258 let token_upper = token
4259 .trim_matches(|c: char| c == ',' || c == ';')
4260 .to_ascii_uppercase();
4261 if paren_depth == 0 && COLUMN_CONSTRAINT_KEYWORDS.contains(&token_upper.as_str()) {
4262 break;
4263 }
4264 parts.push(*token);
4265 for ch in token.chars() {
4266 if ch == '(' {
4267 paren_depth += 1;
4268 } else if ch == ')' && paren_depth > 0 {
4269 paren_depth -= 1;
4270 }
4271 }
4272 }
4273 parts.join(" ")
4274}
4275
4276fn extract_generated_column_clause(remainder: &str) -> (Option<String>, Option<bool>) {
4277 let Some(as_pos) = find_top_level_unquoted_sql_keyword(remainder, "AS") else {
4278 return (None, None);
4279 };
4280 let after_keyword = &remainder[as_pos + "AS".len()..];
4281 let after_space_and_comments = trim_leading_sql_space_and_comments(after_keyword);
4282 if !after_space_and_comments.starts_with('(') {
4283 return (None, None);
4284 }
4285 let skipped = after_keyword.len() - after_space_and_comments.len();
4286 let open_paren = as_pos + "AS".len() + skipped;
4287 let Some(close_paren) = find_matching_sql_paren(remainder, open_paren) else {
4288 return (None, None);
4289 };
4290
4291 let tail = trim_leading_sql_space_and_comments(&remainder[close_paren + 1..]);
4292 let is_stored = collect_unquoted_sql_keyword_tokens(tail)
4293 .first()
4294 .is_some_and(|(token, start)| *start == 0 && token == "STORED");
4295
4296 (
4297 Some(remainder[open_paren + 1..close_paren].trim().to_owned()),
4298 Some(is_stored),
4299 )
4300}
4301
4302fn extract_default_value(remainder: &str) -> Option<String> {
4306 let pos = find_unquoted_sql_keyword(remainder, "DEFAULT")?;
4307 let after = trim_leading_sql_space_and_comments(&remainder[pos + 7..]);
4308 if after.is_empty() {
4309 return None;
4310 }
4311 if after.starts_with('(') {
4313 let mut depth = 0i32;
4314 let bytes = after.as_bytes();
4315 let mut idx = 0_usize;
4316 while idx < bytes.len() {
4317 match bytes[idx] {
4318 quote @ (b'\'' | b'"') => {
4319 idx += 1;
4320 while idx < bytes.len() {
4321 if bytes[idx] == quote {
4322 if idx + 1 < bytes.len() && bytes[idx + 1] == quote {
4323 idx += 2;
4324 } else {
4325 idx += 1;
4326 break;
4327 }
4328 } else {
4329 idx += 1;
4330 }
4331 }
4332 continue;
4333 }
4334 b'(' => depth += 1,
4335 b')' => {
4336 depth -= 1;
4337 if depth == 0 {
4338 return Some(after[..=idx].to_owned());
4339 }
4340 if depth < 0 {
4341 return None;
4342 }
4343 }
4344 _ => {}
4345 }
4346 idx += 1;
4347 }
4348 return None;
4349 }
4350 if let Some(quote) = after
4352 .as_bytes()
4353 .first()
4354 .copied()
4355 .filter(|quote| matches!(*quote, b'\'' | b'"'))
4356 {
4357 let rest = &after[1..];
4358 let mut i = 0;
4359 let bytes = rest.as_bytes();
4360 while i < bytes.len() {
4361 if bytes[i] == quote {
4362 if i + 1 < bytes.len() && bytes[i + 1] == quote {
4363 i += 2;
4364 continue;
4365 }
4366 return Some(after[..i + 2].to_owned());
4367 }
4368 i += 1;
4369 }
4370 return None;
4371 }
4372 let end = after
4374 .find(|c: char| c.is_ascii_whitespace() || c == ',')
4375 .unwrap_or(after.len());
4376 let token = &after[..end];
4377 if token.is_empty() {
4378 None
4379 } else {
4380 Some(token.to_owned())
4381 }
4382}
4383
4384fn type_to_affinity(type_str: &str) -> char {
4386 let upper = type_str.to_uppercase();
4389 if upper.contains("INT") {
4390 'D' } else if upper.contains("TEXT") || upper.contains("CHAR") || upper.contains("CLOB") {
4392 'B' } else if upper.contains("BLOB") || upper.is_empty() {
4394 'A' } else if upper.contains("REAL") || upper.contains("FLOA") || upper.contains("DOUB") {
4396 'E' } else {
4398 'C' }
4400}
4401
4402#[cfg(test)]
4405mod tests {
4406 use super::*;
4407 use std::io::Write;
4408 use std::process::{Command, Stdio};
4409
4410 async fn persist_test_db(
4411 path: &Path,
4412 schema: &[TableSchema],
4413 db: &MemDatabase,
4414 schema_cookie: u32,
4415 change_counter: u32,
4416 ) -> Result<()> {
4417 let cx = Cx::new();
4418 persist_to_sqlite(&cx, path, schema, db, schema_cookie, change_counter).await
4419 }
4420
4421 async fn load_test_db(path: &Path) -> Result<LoadedState> {
4422 let cx = Cx::new();
4423 load_from_sqlite(&cx, path).await
4424 }
4425
4426 fn bare_table_schema(name: &str, columns: &[&str]) -> TableSchema {
4427 TableSchema {
4428 name: name.to_owned(),
4429 root_page: 2,
4430 columns: columns
4431 .iter()
4432 .map(|column| ColumnInfo::basic(*column, 'A', false))
4433 .collect(),
4434 indexes: Vec::new(),
4435 strict: false,
4436 without_rowid: false,
4437 primary_key_constraints: Vec::new(),
4438 foreign_keys: Vec::new(),
4439 check_constraints: Vec::new(),
4440 }
4441 }
4442
4443 #[test]
4444 fn test_parse_loaded_default_text_requires_complete_quoted_literal() {
4445 assert_eq!(
4446 parse_loaded_column_default_value("'can''t'"),
4447 SqliteValue::Text("can't".into()),
4448 );
4449 assert_eq!(
4450 parse_loaded_column_default_value(r#""a""b""#),
4451 SqliteValue::Text("a\"b".into()),
4452 );
4453 assert_eq!(
4454 parse_loaded_column_default_value("'x' || 'y'"),
4455 SqliteValue::Text("'x' || 'y'".into()),
4456 );
4457 assert_eq!(
4458 parse_loaded_column_default_value("('a)b')"),
4459 SqliteValue::Text("a)b".into()),
4460 );
4461 assert_eq!(
4462 parse_loaded_column_default_value(r#"("a)b")"#),
4463 SqliteValue::Text("a)b".into()),
4464 );
4465 assert_eq!(
4466 extract_default_value("TEXT DEFAULT ('a)b')").as_deref(),
4467 Some("('a)b')")
4468 );
4469 assert_eq!(
4470 extract_default_value(r#"TEXT DEFAULT ("a)b")"#).as_deref(),
4471 Some(r#"("a)b")"#)
4472 );
4473 assert_eq!(
4474 extract_default_value("TEXT CHECK (note <> 'DEFAULT bad') DEFAULT 'ok'").as_deref(),
4475 Some("'ok'")
4476 );
4477 assert_eq!(
4478 extract_default_value("TEXT CHECK (note <> 'DEFAULT bad')").as_deref(),
4479 None
4480 );
4481 assert_eq!(
4482 extract_default_value("TEXT /* DEFAULT 'bad' */ DEFAULT 'ok'").as_deref(),
4483 Some("'ok'")
4484 );
4485 assert_eq!(
4486 extract_default_value("TEXT DEFAULT /* comment */ 'ok'").as_deref(),
4487 Some("'ok'")
4488 );
4489 assert_eq!(
4490 extract_default_value("TEXT DEFAULT -- comment\n 'ok'").as_deref(),
4491 Some("'ok'")
4492 );
4493 }
4494
4495 fn make_test_schema_and_db() -> (Vec<TableSchema>, MemDatabase) {
4496 let mut db = MemDatabase::new();
4497 let root = db.create_table(2);
4498 let table = db.tables.get_mut(&root).unwrap();
4499 table.insert_row(
4500 1,
4501 vec![SqliteValue::Integer(42), SqliteValue::Text("hello".into())],
4502 );
4503 table.insert_row(
4504 2,
4505 vec![SqliteValue::Integer(99), SqliteValue::Text("world".into())],
4506 );
4507
4508 let schema = vec![TableSchema {
4509 name: "test_table".to_owned(),
4510 root_page: root,
4511 columns: vec![
4512 ColumnInfo {
4513 name: "id".to_owned(),
4514 affinity: 'd',
4515 is_ipk: false,
4516 type_name: None,
4517 notnull: false,
4518 unique: false,
4519 default_value: None,
4520 strict_type: None,
4521 generated_expr: None,
4522 generated_stored: None,
4523 collation: None,
4524 conflict_action: None,
4525 },
4526 ColumnInfo {
4527 name: "name".to_owned(),
4528 affinity: 'C',
4529 is_ipk: false,
4530 type_name: None,
4531 notnull: false,
4532 unique: false,
4533 default_value: None,
4534 strict_type: None,
4535 generated_expr: None,
4536 generated_stored: None,
4537 collation: None,
4538 conflict_action: None,
4539 },
4540 ],
4541 indexes: Vec::new(),
4542 strict: false,
4543 without_rowid: false,
4544 primary_key_constraints: Vec::new(),
4545 foreign_keys: Vec::new(),
4546 check_constraints: Vec::new(),
4547 }];
4548
4549 (schema, db)
4550 }
4551
4552 #[test]
4553 fn test_roundtrip_persist_and_load() {
4554 asupersync::test_utils::run_test(|| async {
4555 let dir = tempfile::tempdir().unwrap();
4556 let db_path = dir.path().join("test.db");
4557
4558 let (schema, db) = make_test_schema_and_db();
4559 persist_test_db(&db_path, &schema, &db, 0, 0).await.unwrap();
4560
4561 assert!(db_path.exists(), "db file should exist");
4562 assert!(is_sqlite_format(&db_path), "should have SQLite magic");
4563
4564 let loaded = load_test_db(&db_path).await.unwrap();
4565 assert_eq!(loaded.schema.len(), 1);
4566 assert_eq!(loaded.schema[0].name, "test_table");
4567 assert_eq!(loaded.schema[0].columns.len(), 2);
4568
4569 let table = loaded.db.get_table(loaded.schema[0].root_page).unwrap();
4570 let rows: Vec<_> = table.iter_rows().collect();
4571 assert_eq!(rows.len(), 2);
4572 assert_eq!(rows[0].0, 1); assert_eq!(rows[0].1[0], SqliteValue::Integer(42));
4574 assert_eq!(rows[0].1[1], SqliteValue::Text("hello".into()));
4575 assert_eq!(rows[1].0, 2);
4576 assert_eq!(rows[1].1[0], SqliteValue::Integer(99));
4577 assert_eq!(rows[1].1[1], SqliteValue::Text("world".into()));
4578 });
4579 }
4580
4581 #[test]
4582 fn test_empty_database_roundtrip() {
4583 asupersync::test_utils::run_test(|| async {
4584 let dir = tempfile::tempdir().unwrap();
4585 let db_path = dir.path().join("empty.db");
4586
4587 let schema: Vec<TableSchema> = Vec::new();
4588 let db = MemDatabase::new();
4589 persist_test_db(&db_path, &schema, &db, 0, 0).await.unwrap();
4590
4591 assert!(is_sqlite_format(&db_path));
4592
4593 let loaded = load_test_db(&db_path).await.unwrap();
4594 assert!(loaded.schema.is_empty());
4595 });
4596 }
4597
4598 #[test]
4599 fn test_persist_creates_sqlite3_readable_file() {
4600 asupersync::test_utils::run_test(|| async {
4601 let dir = tempfile::tempdir().unwrap();
4602 let db_path = dir.path().join("readable.db");
4603
4604 let (schema, db) = make_test_schema_and_db();
4605 persist_test_db(&db_path, &schema, &db, 0, 0).await.unwrap();
4606
4607 let conn = rusqlite::Connection::open(&db_path).unwrap();
4609 let mut stmt = conn
4610 .prepare("SELECT id, name FROM test_table ORDER BY id")
4611 .unwrap();
4612 let rows: Vec<(i64, String)> = stmt
4613 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
4614 .unwrap()
4615 .collect::<std::result::Result<Vec<_>, _>>()
4616 .unwrap();
4617
4618 assert_eq!(rows.len(), 2);
4619 assert_eq!(rows[0], (42, "hello".to_owned()));
4620 assert_eq!(rows[1], (99, "world".to_owned()));
4621 });
4622 }
4623
4624 #[test]
4625 fn test_parse_virtual_table_columns_from_sql_rejects_trailing_junk() {
4626 assert!(
4627 parse_virtual_table_columns_from_sql("CREATE VIRTUAL TABLE docs USING fts5(a) garbage")
4628 .is_none(),
4629 "trailing tokens must invalidate virtual-table SQL during compat import"
4630 );
4631 }
4632
4633 #[test]
4634 fn test_load_sqlite3_created_file() {
4635 asupersync::test_utils::run_test(|| async {
4636 let dir = tempfile::tempdir().unwrap();
4637 let db_path = dir.path().join("from_c.db");
4638
4639 {
4641 let conn = rusqlite::Connection::open(&db_path).unwrap();
4642 conn.execute_batch(
4643 "CREATE TABLE items (val INTEGER, label TEXT);
4644 INSERT INTO items VALUES (10, 'alpha');
4645 INSERT INTO items VALUES (20, 'beta');",
4646 )
4647 .unwrap();
4648 }
4649
4650 let loaded = load_test_db(&db_path).await.unwrap();
4652 assert_eq!(loaded.schema.len(), 1);
4653 assert_eq!(loaded.schema[0].name, "items");
4654
4655 let table = loaded.db.get_table(loaded.schema[0].root_page).unwrap();
4656 let rows: Vec<_> = table.iter_rows().collect();
4657 assert_eq!(rows.len(), 2);
4658 assert_eq!(rows[0].1[0], SqliteValue::Integer(10));
4659 assert_eq!(rows[0].1[1], SqliteValue::Text("alpha".into()));
4660 assert_eq!(rows[1].1[0], SqliteValue::Integer(20));
4661 assert_eq!(rows[1].1[1], SqliteValue::Text("beta".into()));
4662 });
4663 }
4664
4665 #[test]
4666 fn test_load_sqlite3_created_file_restores_integer_primary_key_alias_values() {
4667 asupersync::test_utils::run_test(|| async {
4668 let dir = tempfile::tempdir().unwrap();
4669 let db_path = dir.path().join("from_c_ipk.db");
4670
4671 {
4672 let conn = rusqlite::Connection::open(&db_path).unwrap();
4673 conn.execute_batch(
4674 "CREATE TABLE items (id INTEGER PRIMARY KEY, label TEXT);
4675 INSERT INTO items (id, label) VALUES (10, 'alpha');
4676 INSERT INTO items (id, label) VALUES (20, 'beta');",
4677 )
4678 .unwrap();
4679 }
4680
4681 let loaded = load_test_db(&db_path).await.unwrap();
4682 assert_eq!(loaded.schema.len(), 1);
4683 assert_eq!(loaded.schema[0].name, "items");
4684 assert!(loaded.schema[0].columns[0].is_ipk);
4685 assert!(
4686 loaded.schema[0].indexes.is_empty(),
4687 "table-level INTEGER PRIMARY KEY rowid aliases must not synthesize autoindexes"
4688 );
4689
4690 let table = loaded.db.get_table(loaded.schema[0].root_page).unwrap();
4691 let rows: Vec<_> = table.iter_rows().collect();
4692 assert_eq!(rows.len(), 2);
4693 assert_eq!(rows[0].0, 10);
4694 assert_eq!(rows[0].1[0], SqliteValue::Integer(10));
4695 assert_eq!(rows[0].1[1], SqliteValue::Text("alpha".into()));
4696 assert_eq!(rows[1].0, 20);
4697 assert_eq!(rows[1].1[0], SqliteValue::Integer(20));
4698 assert_eq!(rows[1].1[1], SqliteValue::Text("beta".into()));
4699 });
4700 }
4701
4702 #[test]
4703 fn test_load_sqlite3_created_file_restores_table_level_integer_primary_key_alias_values() {
4704 asupersync::test_utils::run_test(|| async {
4705 let dir = tempfile::tempdir().unwrap();
4706 let db_path = dir.path().join("from_c_table_pk.db");
4707
4708 {
4709 let conn = rusqlite::Connection::open(&db_path).unwrap();
4710 conn.execute_batch(
4711 "CREATE TABLE items (id INTEGER, label TEXT, PRIMARY KEY(id));
4712 INSERT INTO items (id, label) VALUES (10, 'alpha');
4713 INSERT INTO items (id, label) VALUES (20, 'beta');",
4714 )
4715 .unwrap();
4716 }
4717
4718 let loaded = load_test_db(&db_path).await.unwrap();
4719 assert_eq!(loaded.schema.len(), 1);
4720 assert_eq!(loaded.schema[0].name, "items");
4721 assert!(loaded.schema[0].columns[0].is_ipk);
4722
4723 let table = loaded.db.get_table(loaded.schema[0].root_page).unwrap();
4724 let rows: Vec<_> = table.iter_rows().collect();
4725 assert_eq!(rows.len(), 2);
4726 assert_eq!(rows[0].0, 10);
4727 assert_eq!(rows[0].1[0], SqliteValue::Integer(10));
4728 assert_eq!(rows[0].1[1], SqliteValue::Text("alpha".into()));
4729 assert_eq!(rows[1].0, 20);
4730 assert_eq!(rows[1].1[0], SqliteValue::Integer(20));
4731 assert_eq!(rows[1].1[1], SqliteValue::Text("beta".into()));
4732 });
4733 }
4734
4735 #[test]
4736 fn test_load_sqlite3_rowid_alias_multi_alter_short_rows_preserves_alignment() {
4737 asupersync::test_utils::run_test(|| async {
4738 let dir = tempfile::tempdir().unwrap();
4739 let db_path = dir.path().join("from_c_ipk_multi_alter.db");
4740
4741 {
4742 let conn = rusqlite::Connection::open(&db_path).unwrap();
4743 conn.execute_batch(
4744 "CREATE TABLE items (
4745 prefix TEXT,
4746 id INTEGER PRIMARY KEY,
4747 nullable TEXT,
4748 required TEXT NOT NULL
4749 );
4750 INSERT INTO items(prefix, id, nullable, required)
4751 VALUES ('p', 7, NULL, 'keep');
4752 ALTER TABLE items ADD COLUMN extra TEXT DEFAULT 'x';
4753 ALTER TABLE items ADD COLUMN note INTEGER DEFAULT 9;",
4754 )
4755 .unwrap();
4756 }
4757
4758 let loaded = load_test_db(&db_path).await.unwrap();
4759 assert_eq!(loaded.schema.len(), 1);
4760 assert_eq!(loaded.schema[0].name, "items");
4761
4762 let table = loaded.db.get_table(loaded.schema[0].root_page).unwrap();
4763 let rows: Vec<_> = table.iter_rows().collect();
4764 assert_eq!(rows.len(), 1);
4765 assert_eq!(rows[0].0, 7);
4766 assert_eq!(rows[0].1[0], SqliteValue::Text("p".into()));
4767 assert_eq!(rows[0].1[1], SqliteValue::Integer(7));
4768 assert_eq!(rows[0].1[2], SqliteValue::Null);
4769 assert_eq!(rows[0].1[3], SqliteValue::Text("keep".into()));
4770 assert_eq!(rows[0].1[4], SqliteValue::Text("x".into()));
4771 assert_eq!(rows[0].1[5], SqliteValue::Integer(9));
4772 });
4773 }
4774
4775 #[test]
4776 fn test_load_sqlite3_rowid_alias_parenthesized_added_defaults() {
4777 asupersync::test_utils::run_test(|| async {
4778 let dir = tempfile::tempdir().unwrap();
4779 let db_path = dir.path().join("from_c_ipk_parenthesized_defaults.db");
4780
4781 {
4782 let conn = rusqlite::Connection::open(&db_path).unwrap();
4783 conn.execute_batch(
4784 "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT);
4785 INSERT INTO items(id, name) VALUES (3, 'alpha');
4786 ALTER TABLE items ADD COLUMN score INTEGER DEFAULT (9);
4787 ALTER TABLE items ADD COLUMN tag TEXT DEFAULT ('fallback');",
4788 )
4789 .unwrap();
4790 }
4791
4792 let loaded = load_test_db(&db_path).await.unwrap();
4793 let table = loaded.db.get_table(loaded.schema[0].root_page).unwrap();
4794 let rows: Vec<_> = table.iter_rows().collect();
4795 assert_eq!(rows.len(), 1);
4796 assert_eq!(rows[0].0, 3);
4797 assert_eq!(rows[0].1[0], SqliteValue::Integer(3));
4798 assert_eq!(rows[0].1[1], SqliteValue::Text("alpha".into()));
4799 assert_eq!(rows[0].1[2], SqliteValue::Integer(9));
4800 assert_eq!(rows[0].1[3], SqliteValue::Text("fallback".into()));
4801 });
4802 }
4803
4804 #[test]
4805 fn test_load_sqlite3_altered_short_rows_parse_boolean_blob_and_quoted_defaults() {
4806 asupersync::test_utils::run_test(|| async {
4807 let dir = tempfile::tempdir().unwrap();
4808 let db_path = dir.path().join("from_c_ipk_literal_defaults.db");
4809
4810 {
4811 let conn = rusqlite::Connection::open(&db_path).unwrap();
4812 conn.execute_batch(
4813 r#"CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT);
4814 INSERT INTO items(id, name) VALUES (5, 'alpha');
4815 ALTER TABLE items ADD COLUMN active BOOLEAN DEFAULT TRUE;
4816 ALTER TABLE items ADD COLUMN disabled BOOLEAN DEFAULT FALSE;
4817 ALTER TABLE items ADD COLUMN payload BLOB DEFAULT X'6162';
4818 ALTER TABLE items ADD COLUMN tag TEXT DEFAULT "fallback";"#,
4819 )
4820 .unwrap();
4821 }
4822
4823 let loaded = load_test_db(&db_path).await.unwrap();
4824 let table = loaded.db.get_table(loaded.schema[0].root_page).unwrap();
4825 let rows: Vec<_> = table.iter_rows().collect();
4826 assert_eq!(rows.len(), 1);
4827 assert_eq!(rows[0].0, 5);
4828 assert_eq!(rows[0].1[0], SqliteValue::Integer(5));
4829 assert_eq!(rows[0].1[1], SqliteValue::Text("alpha".into()));
4830 assert_eq!(rows[0].1[2], SqliteValue::Integer(1));
4831 assert_eq!(rows[0].1[3], SqliteValue::Integer(0));
4832 assert_eq!(rows[0].1[4], SqliteValue::from(vec![0x61, 0x62]));
4833 assert_eq!(rows[0].1[5], SqliteValue::Text("fallback".into()));
4834 });
4835 }
4836
4837 #[test]
4838 fn test_inflate_loaded_rowid_alias_omitted_slot_keeps_shifted_null_alignment() {
4839 let column = |name: &str, affinity: char, is_ipk: bool| ColumnInfo {
4840 name: name.to_owned(),
4841 affinity,
4842 is_ipk,
4843 type_name: None,
4844 notnull: false,
4845 unique: false,
4846 default_value: None,
4847 strict_type: None,
4848 generated_expr: None,
4849 generated_stored: None,
4850 collation: None,
4851 conflict_action: None,
4852 };
4853 let mut required = column("required", 'B', false);
4854 required.notnull = true;
4855 let mut extra = column("extra", 'B', false);
4856 extra.default_value = Some("'x'".to_owned());
4857 let mut note = column("note", 'D', false);
4858 note.default_value = Some("9".to_owned());
4859 let columns = vec![
4860 column("prefix", 'B', false),
4861 column("id", 'D', true),
4862 column("nullable", 'B', false),
4863 required,
4864 extra,
4865 note,
4866 ];
4867 let mut values = vec![
4868 SqliteValue::Text("p".into()),
4869 SqliteValue::Null,
4870 SqliteValue::Text("keep".into()),
4871 ];
4872
4873 inflate_loaded_table_row_values(&mut values, 7, &columns, Some(1), "items").unwrap();
4874
4875 assert_eq!(values[0], SqliteValue::Text("p".into()));
4876 assert_eq!(values[1], SqliteValue::Integer(7));
4877 assert_eq!(values[2], SqliteValue::Null);
4878 assert_eq!(values[3], SqliteValue::Text("keep".into()));
4879 assert_eq!(values[4], SqliteValue::Text("x".into()));
4880 assert_eq!(values[5], SqliteValue::Integer(9));
4881 }
4882
4883 #[test]
4884 fn test_load_sqlite3_created_file_with_nondefault_page_size_and_reserved_bytes() {
4885 asupersync::test_utils::run_test(|| async {
4886 if Command::new("sqlite3").arg("--version").output().is_err() {
4887 eprintln!("skipping: sqlite3 binary not found");
4888 return;
4889 }
4890
4891 let dir = tempfile::tempdir().unwrap();
4892 let db_path = dir.path().join("from_c_reserved_bytes.db");
4893
4894 let mut child = Command::new("sqlite3")
4895 .arg(&db_path)
4896 .stdin(Stdio::piped())
4897 .stdout(Stdio::piped())
4898 .stderr(Stdio::piped())
4899 .spawn()
4900 .expect("sqlite3 process should start");
4901 {
4902 let mut stdin = child
4903 .stdin
4904 .take()
4905 .expect("sqlite3 stdin should be available");
4906 stdin
4907 .write_all(
4908 br"PRAGMA journal_mode=DELETE;
4909PRAGMA page_size=8192;
4910VACUUM;
4911.filectrl reserve_bytes 32
4912VACUUM;
4913CREATE TABLE items (val INTEGER, label TEXT);
4914INSERT INTO items VALUES (10, 'alpha');
4915INSERT INTO items VALUES (20, 'beta');
4916PRAGMA integrity_check;
4917",
4918 )
4919 .expect("sqlite3 setup should accept the script");
4920 }
4921 let output = child
4922 .wait_with_output()
4923 .expect("sqlite3 process should finish");
4924 let stdout = String::from_utf8_lossy(&output.stdout);
4925 let stderr = String::from_utf8_lossy(&output.stderr);
4926 if !output.status.success()
4927 && (stdout.contains("unknown")
4928 || stdout.contains("Usage:")
4929 || stderr.contains("unknown")
4930 || stderr.contains("Usage:"))
4931 {
4932 eprintln!(
4933 "skipping: sqlite3 shell does not support .filectrl reserve_bytes: stdout={stdout} stderr={stderr}"
4934 );
4935 return;
4936 }
4937 assert!(
4938 output.status.success(),
4939 "sqlite3 reserved-byte setup failed: stdout={stdout} stderr={stderr}"
4940 );
4941 assert!(
4942 stdout.lines().any(|line| line.trim() == "ok"),
4943 "sqlite3 should report integrity_check=ok for the reserved-byte database: stdout={stdout} stderr={stderr}"
4944 );
4945
4946 let loaded = load_test_db(&db_path).await.unwrap_or_else(|error| {
4947 panic!(
4948 "compat loader must read valid C SQLite files with non-default page sizes and reserved bytes: {error}"
4949 )
4950 });
4951 assert_eq!(loaded.schema.len(), 1);
4952 assert_eq!(loaded.schema[0].name, "items");
4953
4954 let table = loaded.db.get_table(loaded.schema[0].root_page).unwrap();
4955 let rows: Vec<_> = table.iter_rows().collect();
4956 assert_eq!(rows.len(), 2);
4957 assert_eq!(rows[0].1[0], SqliteValue::Integer(10));
4958 assert_eq!(rows[0].1[1], SqliteValue::Text("alpha".into()));
4959 assert_eq!(rows[1].1[0], SqliteValue::Integer(20));
4960 assert_eq!(rows[1].1[1], SqliteValue::Text("beta".into()));
4961 });
4962 }
4963
4964 #[test]
4965 fn test_is_sqlite_format_text_file() {
4966 let dir = tempfile::tempdir().unwrap();
4967 let path = dir.path().join("text.db");
4968 host_fs::write(&path, b"CREATE TABLE t (x);").unwrap();
4969 assert!(!is_sqlite_format(&path));
4970 }
4971
4972 #[test]
4973 fn test_is_sqlite_format_nonexistent() {
4974 assert!(!is_sqlite_format(Path::new(
4975 "/tmp/nonexistent_compat_test.db"
4976 )));
4977 }
4978
4979 #[test]
4980 fn test_multiple_tables_roundtrip() {
4981 asupersync::test_utils::run_test(|| async {
4982 let dir = tempfile::tempdir().unwrap();
4983 let db_path = dir.path().join("multi.db");
4984
4985 let mut db = MemDatabase::new();
4986 let root_a = db.create_table(1);
4987 db.tables
4988 .get_mut(&root_a)
4989 .unwrap()
4990 .insert_row(1, vec![SqliteValue::Text("row_a".into())]);
4991
4992 let root_b = db.create_table(1);
4993 db.tables
4994 .get_mut(&root_b)
4995 .unwrap()
4996 .insert_row(1, vec![SqliteValue::Integer(777)]);
4997
4998 let schema = vec![
4999 TableSchema {
5000 name: "alpha".to_owned(),
5001 root_page: root_a,
5002 columns: vec![ColumnInfo {
5003 name: "val".to_owned(),
5004 affinity: 'C',
5005 is_ipk: false,
5006 type_name: None,
5007 notnull: false,
5008 unique: false,
5009 default_value: None,
5010 strict_type: None,
5011 generated_expr: None,
5012 generated_stored: None,
5013 collation: None,
5014 conflict_action: None,
5015 }],
5016 indexes: Vec::new(),
5017 strict: false,
5018 without_rowid: false,
5019 primary_key_constraints: Vec::new(),
5020 foreign_keys: Vec::new(),
5021 check_constraints: Vec::new(),
5022 },
5023 TableSchema {
5024 name: "beta".to_owned(),
5025 root_page: root_b,
5026 columns: vec![ColumnInfo {
5027 name: "num".to_owned(),
5028 affinity: 'd',
5029 is_ipk: false,
5030 type_name: None,
5031 notnull: false,
5032 unique: false,
5033 default_value: None,
5034 strict_type: None,
5035 generated_expr: None,
5036 generated_stored: None,
5037 collation: None,
5038 conflict_action: None,
5039 }],
5040 indexes: Vec::new(),
5041 strict: false,
5042 without_rowid: false,
5043 primary_key_constraints: Vec::new(),
5044 foreign_keys: Vec::new(),
5045 check_constraints: Vec::new(),
5046 },
5047 ];
5048
5049 persist_test_db(&db_path, &schema, &db, 0, 0).await.unwrap();
5050 let loaded = load_test_db(&db_path).await.unwrap();
5051
5052 assert_eq!(loaded.schema.len(), 2);
5053 assert_eq!(loaded.schema[0].name, "alpha");
5054 assert_eq!(loaded.schema[1].name, "beta");
5055
5056 let tbl_a = loaded.db.get_table(loaded.schema[0].root_page).unwrap();
5057 let rows_a: Vec<_> = tbl_a.iter_rows().collect();
5058 assert_eq!(rows_a[0].1[0], SqliteValue::Text("row_a".into()));
5059
5060 let tbl_b = loaded.db.get_table(loaded.schema[1].root_page).unwrap();
5061 let rows_b: Vec<_> = tbl_b.iter_rows().collect();
5062 assert_eq!(rows_b[0].1[0], SqliteValue::Integer(777));
5063 });
5064 }
5065
5066 #[test]
5067 fn test_parse_columns_from_create_sql() {
5068 let sql = r#"CREATE TABLE "foo" ("id" INTEGER, "name" TEXT, "data" BLOB)"#;
5069 let cols = parse_columns_from_create_sql(sql);
5070 assert_eq!(cols.len(), 3);
5071 assert_eq!(cols[0].name, "id");
5072 assert_eq!(cols[0].affinity, 'D');
5073 assert_eq!(cols[1].name, "name");
5074 assert_eq!(cols[1].affinity, 'B');
5075 assert_eq!(cols[2].name, "data");
5076 assert_eq!(cols[2].affinity, 'A');
5077 }
5078
5079 #[test]
5080 fn test_parse_columns_from_create_sql_handles_nested_commas_and_constraints() {
5081 let sql = r"CREATE TABLE metrics (
5082 id INTEGER PRIMARY KEY,
5083 amount DECIMAL(10,2) NOT NULL,
5084 status TEXT CHECK (status IN ('a,b', 'c')),
5085 CONSTRAINT metrics_pk PRIMARY KEY (id)
5086 )";
5087 let cols = parse_columns_from_create_sql(sql);
5088 assert_eq!(cols.len(), 3);
5089 assert_eq!(cols[0].name, "id");
5090 assert_eq!(cols[0].affinity, 'D');
5091 assert!(cols[0].is_ipk);
5092 assert_eq!(cols[1].name, "amount");
5093 assert_eq!(cols[1].affinity, 'C');
5094 assert_eq!(cols[2].name, "status");
5095 assert_eq!(cols[2].affinity, 'B');
5096 }
5097
5098 #[test]
5099 fn test_parse_columns_from_create_sql_table_level_integer_primary_key_is_ipk() {
5100 let sql = "CREATE TABLE metrics (id INTEGER, body TEXT, PRIMARY KEY(id))";
5101 let cols = parse_columns_from_create_sql(sql);
5102 assert_eq!(cols.len(), 2);
5103 assert_eq!(cols[0].name, "id");
5104 assert!(cols[0].is_ipk);
5105 assert_eq!(cols[1].name, "body");
5106 }
5107
5108 #[test]
5109 fn test_parse_columns_from_create_sql_legacy_quoted_integer_primary_key_is_ipk() {
5110 let sql = "CREATE TABLE metrics (id INTEGER, body TEXT, PRIMARY KEY('id'))";
5111 let cols = parse_columns_from_create_sql(sql);
5112 assert_eq!(cols.len(), 2);
5113 assert_eq!(cols[0].name, "id");
5114 assert!(cols[0].is_ipk);
5115 assert_eq!(cols[1].name, "body");
5116 assert_eq!(
5117 extract_primary_key_constraints_from_sql(sql),
5118 vec![vec!["id".to_owned()]]
5119 );
5120 }
5121
5122 #[test]
5123 fn test_parse_columns_distinguishes_column_and_table_unique_ownership() {
5124 let column_owned = parse_columns_from_create_sql(
5125 "CREATE TABLE column_owned (id INTEGER UNIQUE, body TEXT)",
5126 );
5127 assert!(column_owned[0].unique);
5128
5129 let table_owned = parse_columns_from_create_sql(
5130 "CREATE TABLE table_owned (id INTEGER, body TEXT, UNIQUE(id))",
5131 );
5132 assert!(!table_owned[0].unique);
5133 let indexes = extract_unique_constraint_indexes_from_sql(
5134 "CREATE TABLE table_owned (id INTEGER, body TEXT, UNIQUE(id))",
5135 "table_owned",
5136 )
5137 .unwrap();
5138 assert_eq!(indexes.len(), 1);
5139 assert_eq!(indexes[0].columns, vec!["id"]);
5140 }
5141
5142 #[test]
5143 fn test_parse_columns_from_create_sql_table_level_integer_primary_key_desc_is_ipk() {
5144 let sql = "CREATE TABLE metrics (id INTEGER, body TEXT, PRIMARY KEY(id DESC))";
5145 let cols = parse_columns_from_create_sql(sql);
5146 assert_eq!(cols.len(), 2);
5147 assert_eq!(cols[0].name, "id");
5148 assert!(cols[0].is_ipk);
5149 assert_eq!(cols[1].name, "body");
5150 }
5151
5152 #[test]
5153 fn test_parse_columns_from_create_sql_table_level_integer_primary_key_collate_desc_is_ipk() {
5154 let sql =
5155 "CREATE TABLE metrics (id INTEGER, body TEXT, PRIMARY KEY(id COLLATE NOCASE DESC))";
5156 let cols = parse_columns_from_create_sql(sql);
5157 assert_eq!(cols.len(), 2);
5158 assert_eq!(cols[0].name, "id");
5159 assert!(cols[0].is_ipk);
5160 assert_eq!(cols[1].name, "body");
5161 }
5162
5163 #[test]
5164 fn test_parse_columns_from_create_sql_without_rowid_integer_pk_is_not_ipk() {
5165 let sql = "CREATE TABLE wr (id INTEGER PRIMARY KEY, body TEXT) WITHOUT ROWID";
5166 let cols = parse_columns_from_create_sql(sql);
5167 assert_eq!(cols.len(), 2);
5168 assert_eq!(cols[0].name, "id");
5169 assert!(!cols[0].is_ipk);
5170 assert!(cols[0].unique);
5171 assert_eq!(cols[1].name, "body");
5172 }
5173
5174 #[test]
5175 fn test_parse_columns_from_create_sql_keeps_quoted_keyword_column_name() {
5176 let sql = r#"CREATE TABLE t ("primary" TEXT, value INTEGER)"#;
5177 let cols = parse_columns_from_create_sql(sql);
5178 assert_eq!(cols.len(), 2);
5179 assert_eq!(cols[0].name, "primary");
5180 assert_eq!(cols[0].affinity, 'B');
5181 assert_eq!(cols[1].name, "value");
5182 assert_eq!(cols[1].affinity, 'D');
5183 }
5184
5185 #[test]
5186 fn test_parse_columns_from_create_sql_handles_quoted_names_with_spaces() {
5187 let sql = r#"CREATE TABLE t ("first name" TEXT, [last name] INTEGER, `role name` NUMERIC)"#;
5188 let cols = parse_columns_from_create_sql(sql);
5189 assert_eq!(cols.len(), 3);
5190 assert_eq!(cols[0].name, "first name");
5191 assert_eq!(cols[0].affinity, 'B');
5192 assert_eq!(cols[1].name, "last name");
5193 assert_eq!(cols[1].affinity, 'D');
5194 assert_eq!(cols[2].name, "role name");
5195 assert_eq!(cols[2].affinity, 'C');
5196 }
5197
5198 #[test]
5199 fn test_parse_columns_from_create_sql_ignores_constraint_keywords_inside_default_literals() {
5200 let sql = r#"CREATE TABLE t (
5201 note TEXT DEFAULT 'NOT NULL UNIQUE PRIMARY KEY',
5202 tag TEXT DEFAULT "fallback"
5203 )"#;
5204 let cols = parse_columns_from_create_sql(sql);
5205 assert_eq!(cols.len(), 2);
5206 assert!(!cols[0].notnull);
5207 assert!(!cols[0].unique);
5208 assert!(!cols[0].is_ipk);
5209 assert_eq!(
5210 cols[0].default_value.as_deref(),
5211 Some("'NOT NULL UNIQUE PRIMARY KEY'")
5212 );
5213 assert_eq!(cols[1].default_value.as_deref(), Some("fallback"));
5214 }
5215
5216 #[test]
5217 fn test_parse_columns_fallback_ignores_constraint_keywords_inside_default_literals() {
5218 let sql = r#"CREATE TABLE t (
5219 note TEXT DEFAULT 'NOT NULL UNIQUE PRIMARY KEY COLLATE bogus',
5220 actual INTEGER DEFAULT "PRIMARY KEY" PRIMARY KEY,
5221 required TEXT DEFAULT "UNIQUE" NOT NULL,
5222 uniq TEXT DEFAULT "NOT NULL" UNIQUE COLLATE nocase
5223 ) trailing"#;
5224 let cols = parse_columns_from_create_sql(sql);
5225
5226 assert_eq!(cols.len(), 4);
5227 assert!(!cols[0].notnull);
5228 assert!(!cols[0].unique);
5229 assert!(!cols[0].is_ipk);
5230 assert_eq!(cols[0].collation, None);
5231 assert!(cols[1].is_ipk);
5232 assert!(cols[1].unique);
5233 assert!(cols[2].notnull);
5234 assert!(!cols[2].unique);
5235 assert!(!cols[3].notnull);
5236 assert!(cols[3].unique);
5237 assert_eq!(cols[3].collation.as_deref(), Some("NOCASE"));
5238 }
5239
5240 #[test]
5241 fn test_parse_columns_fallback_finds_unquoted_default_keyword() {
5242 let sql = r#"CREATE TABLE t (
5243 note TEXT CHECK (note <> 'DEFAULT NOT NULL') DEFAULT 'ok',
5244 other TEXT CHECK (other <> "DEFAULT UNIQUE")
5245 ) trailing"#;
5246 let cols = parse_columns_from_create_sql(sql);
5247
5248 assert_eq!(cols.len(), 2);
5249 assert_eq!(cols[0].default_value.as_deref(), Some("'ok'"));
5250 assert!(!cols[0].notnull);
5251 assert_eq!(cols[1].default_value, None);
5252 assert!(!cols[1].unique);
5253 }
5254
5255 #[test]
5256 fn test_parse_columns_fallback_preserves_generated_column_metadata() {
5257 let sql = "CREATE TABLE t(a, c, b GENERATED ALWAYS AS(a * 2) STORED, CHECK(c > 0) ON CONFLICT FAIL)";
5258 let cols = parse_columns_from_create_sql(sql);
5259
5260 assert_eq!(cols.len(), 3);
5261 assert_eq!(cols[2].name, "b");
5262 assert_eq!(cols[2].generated_expr.as_deref(), Some("a * 2"));
5263 assert_eq!(cols[2].generated_stored, Some(true));
5264
5265 let virtual_cols = parse_columns_from_create_sql(
5266 "CREATE TABLE v(a, b GENERATED ALWAYS AS(a) REFERENCES stored, CHECK(a > 0) ON CONFLICT FAIL)",
5267 );
5268 assert_eq!(virtual_cols[1].generated_expr.as_deref(), Some("a"));
5269 assert_eq!(virtual_cols[1].generated_stored, Some(false));
5270 }
5271
5272 #[test]
5273 fn test_parse_columns_fallback_keeps_quoted_collation_names() {
5274 let sql = r#"CREATE TABLE t (
5275 name TEXT COLLATE "NOCASE",
5276 code TEXT COLLATE [RTRIM],
5277 note TEXT COLLATE 'BINARY',
5278 tag/* name/type comment, comma */TEXT COLLATE/* collation comment, comma */`NOCASE`
5279 ) trailing"#;
5280 let cols = parse_columns_from_create_sql(sql);
5281
5282 assert_eq!(cols.len(), 4);
5283 assert_eq!(cols[0].collation.as_deref(), Some("NOCASE"));
5284 assert_eq!(cols[1].collation.as_deref(), Some("RTRIM"));
5285 assert_eq!(cols[2].collation.as_deref(), Some("BINARY"));
5286 assert_eq!(cols[3].collation.as_deref(), Some("NOCASE"));
5287 }
5288
5289 #[test]
5290 fn test_parse_columns_from_create_sql_preserves_type_arguments() {
5291 let sql = "CREATE TABLE metrics (amount DECIMAL(10, 2), name VARCHAR(255))";
5292 let cols = parse_columns_from_create_sql(sql);
5293 assert_eq!(cols[0].type_name.as_deref(), Some("DECIMAL(10, 2)"));
5294 assert_eq!(cols[1].type_name.as_deref(), Some("VARCHAR(255)"));
5295 }
5296
5297 #[test]
5298 fn test_parse_columns_from_beads_style_multiline_create_table_sql() {
5299 let cases = [
5300 (
5301 "labels",
5302 r"CREATE TABLE labels (
5303 issue_id TEXT NOT NULL,
5304 label TEXT NOT NULL,
5305 PRIMARY KEY (issue_id, label),
5306 FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
5307 )",
5308 &["issue_id", "label"][..],
5309 ),
5310 (
5311 "comments",
5312 r"CREATE TABLE comments (
5313 id INTEGER PRIMARY KEY AUTOINCREMENT,
5314 issue_id TEXT NOT NULL,
5315 author TEXT NOT NULL,
5316 text TEXT NOT NULL,
5317 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
5318 FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
5319 )",
5320 &["id", "issue_id", "author", "text", "created_at"][..],
5321 ),
5322 (
5323 "events",
5324 r"CREATE TABLE events (
5325 id INTEGER PRIMARY KEY AUTOINCREMENT,
5326 issue_id TEXT NOT NULL,
5327 event_type TEXT NOT NULL,
5328 actor TEXT NOT NULL DEFAULT '',
5329 old_value TEXT,
5330 new_value TEXT,
5331 comment TEXT,
5332 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
5333 FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
5334 )",
5335 &[
5336 "id",
5337 "issue_id",
5338 "event_type",
5339 "actor",
5340 "old_value",
5341 "new_value",
5342 "comment",
5343 "created_at",
5344 ][..],
5345 ),
5346 (
5347 "config",
5348 r"CREATE TABLE config (
5349 key TEXT PRIMARY KEY,
5350 value TEXT NOT NULL
5351 )",
5352 &["key", "value"][..],
5353 ),
5354 (
5355 "blocked_issues_cache",
5356 r"CREATE TABLE blocked_issues_cache (
5357 issue_id TEXT PRIMARY KEY,
5358 blocked_by TEXT NOT NULL, -- JSON array of blocking issue IDs
5359 blocked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
5360 FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
5361 )",
5362 &["issue_id", "blocked_by", "blocked_at"][..],
5363 ),
5364 (
5365 "issues",
5366 r"CREATE TABLE issues (
5367 id TEXT PRIMARY KEY,
5368 content_hash TEXT,
5369 title TEXT NOT NULL,
5370 description TEXT NOT NULL DEFAULT '',
5371 design TEXT NOT NULL DEFAULT '',
5372 acceptance_criteria TEXT NOT NULL DEFAULT '',
5373 notes TEXT NOT NULL DEFAULT '',
5374 status TEXT NOT NULL DEFAULT 'open',
5375 priority INTEGER NOT NULL DEFAULT 2,
5376 issue_type TEXT NOT NULL DEFAULT 'task',
5377 assignee TEXT,
5378 owner TEXT DEFAULT '',
5379 estimated_minutes INTEGER,
5380 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
5381 created_by TEXT DEFAULT '',
5382 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
5383 closed_at DATETIME,
5384 close_reason TEXT DEFAULT '',
5385 closed_by_session TEXT DEFAULT '',
5386 due_at DATETIME,
5387 defer_until DATETIME,
5388 external_ref TEXT,
5389 source_system TEXT DEFAULT '',
5390 source_repo TEXT NOT NULL DEFAULT '.',
5391 deleted_at DATETIME,
5392 deleted_by TEXT DEFAULT '',
5393 delete_reason TEXT DEFAULT '',
5394 original_type TEXT DEFAULT '',
5395 compaction_level INTEGER DEFAULT 0,
5396 compacted_at DATETIME,
5397 compacted_at_commit TEXT,
5398 original_size INTEGER,
5399 sender TEXT DEFAULT '',
5400 ephemeral INTEGER DEFAULT 0,
5401 pinned INTEGER DEFAULT 0,
5402 is_template INTEGER DEFAULT 0,
5403 CHECK(length(title) <= 500),
5404 CHECK(priority >= 0 AND priority <= 4),
5405 CHECK((status = 'closed' AND closed_at IS NOT NULL) OR (status != 'closed'))
5406 )",
5407 &[
5408 "id",
5409 "content_hash",
5410 "title",
5411 "description",
5412 "design",
5413 "acceptance_criteria",
5414 "notes",
5415 "status",
5416 "priority",
5417 "issue_type",
5418 "assignee",
5419 "owner",
5420 "estimated_minutes",
5421 "created_at",
5422 "created_by",
5423 "updated_at",
5424 "closed_at",
5425 "close_reason",
5426 "closed_by_session",
5427 "due_at",
5428 "defer_until",
5429 "external_ref",
5430 "source_system",
5431 "source_repo",
5432 "deleted_at",
5433 "deleted_by",
5434 "delete_reason",
5435 "original_type",
5436 "compaction_level",
5437 "compacted_at",
5438 "compacted_at_commit",
5439 "original_size",
5440 "sender",
5441 "ephemeral",
5442 "pinned",
5443 "is_template",
5444 ][..],
5445 ),
5446 ];
5447
5448 for (table_name, sql, expected_columns) in cases {
5449 let cols = parse_columns_from_create_sql(sql);
5450 let actual_names: Vec<&str> = cols.iter().map(|col| col.name.as_str()).collect();
5451 assert_eq!(
5452 actual_names, expected_columns,
5453 "failed to parse Beads-style column list for table {table_name}"
5454 );
5455 }
5456 }
5457
5458 #[test]
5459 fn test_build_create_table_sql_appends_strict_keyword() {
5460 let table = TableSchema {
5461 name: "strict_table".to_owned(),
5462 root_page: 2,
5463 columns: vec![ColumnInfo {
5464 name: "id".to_owned(),
5465 affinity: 'D',
5466 is_ipk: false,
5467 type_name: Some("INTEGER".to_owned()),
5468 notnull: false,
5469 unique: false,
5470 default_value: None,
5471 strict_type: Some(StrictColumnType::Integer),
5472 generated_expr: None,
5473 generated_stored: None,
5474 collation: None,
5475 conflict_action: None,
5476 }],
5477 indexes: Vec::new(),
5478 strict: true,
5479 without_rowid: false,
5480 primary_key_constraints: Vec::new(),
5481 foreign_keys: Vec::new(),
5482 check_constraints: Vec::new(),
5483 };
5484
5485 let sql = build_create_table_sql(&table);
5486 assert!(
5487 sql.ends_with(" STRICT"),
5488 "STRICT tables must round-trip with STRICT suffix: {sql}"
5489 );
5490 }
5491
5492 #[test]
5493 fn test_build_create_table_sql_preserves_declared_type_text() {
5494 let table = TableSchema {
5495 name: "typed_table".to_owned(),
5496 root_page: 2,
5497 columns: vec![
5498 ColumnInfo {
5499 name: "amount".to_owned(),
5500 affinity: 'C',
5501 is_ipk: false,
5502 type_name: Some("DECIMAL(10, 2)".to_owned()),
5503 notnull: false,
5504 unique: false,
5505 default_value: None,
5506 strict_type: None,
5507 generated_expr: None,
5508 generated_stored: None,
5509 collation: None,
5510 conflict_action: None,
5511 },
5512 ColumnInfo {
5513 name: "name".to_owned(),
5514 affinity: 'B',
5515 is_ipk: false,
5516 type_name: Some("VARCHAR(255)".to_owned()),
5517 notnull: false,
5518 unique: false,
5519 default_value: None,
5520 strict_type: None,
5521 generated_expr: None,
5522 generated_stored: None,
5523 collation: None,
5524 conflict_action: None,
5525 },
5526 ],
5527 indexes: Vec::new(),
5528 strict: false,
5529 without_rowid: false,
5530 primary_key_constraints: Vec::new(),
5531 foreign_keys: Vec::new(),
5532 check_constraints: Vec::new(),
5533 };
5534
5535 let sql = build_create_table_sql(&table);
5536 assert!(sql.contains("\"amount\" DECIMAL(10, 2)"), "{sql}");
5537 assert!(sql.contains("\"name\" VARCHAR(255)"), "{sql}");
5538 }
5539
5540 #[test]
5541 fn test_build_create_table_sql_preserves_typeless_columns() {
5542 let table = TableSchema {
5543 name: "typeless_table".to_owned(),
5544 root_page: 2,
5545 columns: vec![ColumnInfo {
5546 name: "payload".to_owned(),
5547 affinity: 'A',
5548 is_ipk: false,
5549 type_name: None,
5550 notnull: false,
5551 unique: false,
5552 default_value: None,
5553 strict_type: None,
5554 generated_expr: None,
5555 generated_stored: None,
5556 collation: None,
5557 conflict_action: None,
5558 }],
5559 indexes: Vec::new(),
5560 strict: false,
5561 without_rowid: false,
5562 primary_key_constraints: Vec::new(),
5563 foreign_keys: Vec::new(),
5564 check_constraints: Vec::new(),
5565 };
5566
5567 let sql = build_create_table_sql(&table);
5568 assert_eq!(sql, "CREATE TABLE \"typeless_table\" (\"payload\")");
5569 }
5570
5571 #[test]
5572 fn test_build_create_table_sql_escapes_embedded_quotes_in_identifiers() {
5573 let table = TableSchema {
5574 name: "ty\"ped_table".to_owned(),
5575 root_page: 2,
5576 columns: vec![
5577 ColumnInfo {
5578 name: "pay\"load".to_owned(),
5579 affinity: 'A',
5580 is_ipk: false,
5581 type_name: None,
5582 notnull: false,
5583 unique: false,
5584 default_value: None,
5585 strict_type: None,
5586 generated_expr: None,
5587 generated_stored: None,
5588 collation: Some("noca\"se".to_owned()),
5589 conflict_action: None,
5590 },
5591 ColumnInfo {
5592 name: "parent\"id".to_owned(),
5593 affinity: 'D',
5594 is_ipk: false,
5595 type_name: Some("INTEGER".to_owned()),
5596 notnull: false,
5597 unique: false,
5598 default_value: None,
5599 strict_type: None,
5600 generated_expr: None,
5601 generated_stored: None,
5602 collation: None,
5603 conflict_action: None,
5604 },
5605 ],
5606 indexes: Vec::new(),
5607 strict: false,
5608 without_rowid: false,
5609 primary_key_constraints: Vec::new(),
5610 foreign_keys: vec![FkDef {
5611 child_columns: vec![1],
5612 owner_column: None,
5613 parent_table: "pa\"rent".to_owned(),
5614 parent_columns: vec!["id\"x".to_owned()],
5615 on_delete: FkActionType::Cascade,
5616 on_update: FkActionType::NoAction,
5617 deferred: false,
5618 }],
5619 check_constraints: Vec::new(),
5620 };
5621
5622 let sql = build_create_table_sql(&table);
5623 assert!(sql.contains("\"ty\"\"ped_table\""), "{sql}");
5624 assert!(
5625 sql.contains("\"pay\"\"load\" COLLATE \"noca\"\"se\""),
5626 "{sql}"
5627 );
5628 assert!(
5629 sql.contains("FOREIGN KEY(\"parent\"\"id\") REFERENCES \"pa\"\"rent\"(\"id\"\"x\")"),
5630 "{sql}"
5631 );
5632 }
5633
5634 #[test]
5635 fn test_build_create_table_sql_preserves_primary_key_constraints() {
5636 let table = TableSchema {
5637 name: "pk_table".to_owned(),
5638 root_page: 2,
5639 columns: vec![
5640 ColumnInfo {
5641 name: "id".to_owned(),
5642 affinity: 'B',
5643 is_ipk: false,
5644 type_name: Some("TEXT".to_owned()),
5645 notnull: false,
5646 unique: true,
5647 default_value: None,
5648 strict_type: None,
5649 generated_expr: None,
5650 generated_stored: None,
5651 collation: None,
5652 conflict_action: None,
5653 },
5654 ColumnInfo {
5655 name: "body".to_owned(),
5656 affinity: 'A',
5657 is_ipk: false,
5658 type_name: None,
5659 notnull: false,
5660 unique: false,
5661 default_value: None,
5662 strict_type: None,
5663 generated_expr: None,
5664 generated_stored: None,
5665 collation: None,
5666 conflict_action: None,
5667 },
5668 ],
5669 indexes: Vec::new(),
5670 strict: false,
5671 without_rowid: false,
5672 primary_key_constraints: vec![vec!["id".to_owned()]],
5673 foreign_keys: Vec::new(),
5674 check_constraints: Vec::new(),
5675 };
5676
5677 let sql = build_create_table_sql(&table);
5678 assert!(sql.contains("PRIMARY KEY"), "{sql}");
5679 assert!(!sql.contains("UNIQUE"), "{sql}");
5680 assert_eq!(
5681 sql,
5682 "CREATE TABLE \"pk_table\" (\"id\" TEXT, \"body\", PRIMARY KEY (\"id\"))"
5683 );
5684 }
5685
5686 #[test]
5687 fn test_build_create_table_sql_appends_without_rowid_and_strict_options() {
5688 let table = TableSchema {
5689 name: "wr_strict".to_owned(),
5690 root_page: 2,
5691 columns: vec![ColumnInfo {
5692 name: "id".to_owned(),
5693 affinity: 'D',
5694 is_ipk: false,
5695 type_name: Some("INTEGER".to_owned()),
5696 notnull: false,
5697 unique: true,
5698 default_value: None,
5699 strict_type: Some(StrictColumnType::Integer),
5700 generated_expr: None,
5701 generated_stored: None,
5702 collation: None,
5703 conflict_action: None,
5704 }],
5705 indexes: Vec::new(),
5706 strict: true,
5707 without_rowid: true,
5708 primary_key_constraints: Vec::new(),
5709 foreign_keys: Vec::new(),
5710 check_constraints: Vec::new(),
5711 };
5712
5713 let sql = build_create_table_sql(&table);
5714 assert!(sql.ends_with(" WITHOUT ROWID, STRICT"), "{sql}");
5715 }
5716
5717 #[test]
5718 fn test_build_create_table_sql_preserves_unique_foreign_key_and_check_constraints() {
5719 let table = TableSchema {
5720 name: "child".to_owned(),
5721 root_page: 2,
5722 columns: vec![
5723 ColumnInfo {
5724 name: "parent_id".to_owned(),
5725 affinity: 'D',
5726 is_ipk: false,
5727 type_name: Some("INTEGER".to_owned()),
5728 notnull: true,
5729 unique: false,
5730 default_value: None,
5731 strict_type: None,
5732 generated_expr: None,
5733 generated_stored: None,
5734 collation: None,
5735 conflict_action: None,
5736 },
5737 ColumnInfo {
5738 name: "slug".to_owned(),
5739 affinity: 'B',
5740 is_ipk: false,
5741 type_name: Some("TEXT".to_owned()),
5742 notnull: false,
5743 unique: false,
5744 default_value: None,
5745 strict_type: None,
5746 generated_expr: None,
5747 generated_stored: None,
5748 collation: None,
5749 conflict_action: None,
5750 },
5751 ],
5752 indexes: vec![IndexSchema {
5753 name: "sqlite_autoindex_child_1".to_owned(),
5754 root_page: 0,
5755 columns: vec!["parent_id".to_owned(), "slug".to_owned()],
5756 key_expressions: Vec::new(),
5757 key_sort_directions: vec![SortDirection::Asc, SortDirection::Asc],
5758 where_clause: None,
5759 is_unique: true,
5760 key_collations: vec![],
5761 conflict_action: None,
5762 }],
5763 strict: false,
5764 without_rowid: false,
5765 primary_key_constraints: Vec::new(),
5766 foreign_keys: vec![FkDef {
5767 child_columns: vec![0],
5768 owner_column: None,
5769 parent_table: "parent".to_owned(),
5770 parent_columns: vec!["id".to_owned()],
5771 on_delete: FkActionType::Cascade,
5772 on_update: FkActionType::Restrict,
5773 deferred: false,
5774 }],
5775 check_constraints: vec![CheckConstraint {
5776 expr: "length(slug) > 0".to_owned(),
5777 owner_column: None,
5778 }],
5779 };
5780
5781 let sql = build_create_table_sql(&table);
5782 assert!(sql.contains("UNIQUE (\"parent_id\", \"slug\")"), "{sql}");
5783 assert!(
5784 sql.contains(
5785 "FOREIGN KEY(\"parent_id\") REFERENCES \"parent\"(\"id\") ON DELETE CASCADE ON UPDATE RESTRICT"
5786 ),
5787 "{sql}"
5788 );
5789 assert!(sql.contains("CHECK(length(slug) > 0)"), "{sql}");
5790 }
5791
5792 #[test]
5793 fn test_extract_unique_constraint_indexes_from_sql_preserves_table_level_unique_constraints() {
5794 let indexes = extract_unique_constraint_indexes_from_sql(
5795 "CREATE TABLE child (tenant TEXT, slug TEXT, UNIQUE(tenant, slug))",
5796 "child",
5797 )
5798 .unwrap();
5799 assert_eq!(indexes.len(), 1);
5800 assert_eq!(indexes[0].columns, vec!["tenant", "slug"]);
5801 assert!(indexes[0].is_unique);
5802 }
5803
5804 #[test]
5805 fn test_extract_unique_constraint_indexes_skips_table_level_integer_primary_key_alias() {
5806 let indexes = extract_unique_constraint_indexes_from_sql(
5807 "CREATE TABLE metrics (id INTEGER, body TEXT, PRIMARY KEY(id COLLATE NOCASE DESC))",
5808 "metrics",
5809 )
5810 .unwrap();
5811 assert!(indexes.is_empty(), "{indexes:?}");
5812 }
5813
5814 #[test]
5815 fn test_extract_implicit_autoindexes_preserves_without_rowid_slots_and_exact_integer_rules() {
5816 let indexes = extract_unique_constraint_indexes_from_sql(
5817 "CREATE TABLE wr(
5818 pk TEXT PRIMARY KEY,
5819 u TEXT COLLATE NOCASE COLLATE RTRIM UNIQUE
5820 ) WITHOUT ROWID",
5821 "wr",
5822 )
5823 .unwrap();
5824 assert_eq!(indexes.len(), 1);
5825 assert_eq!(indexes[0].name, "sqlite_autoindex_wr_2");
5826 assert_eq!(indexes[0].columns, ["u"]);
5827 assert_eq!(indexes[0].key_collations, [Some("RTRIM".to_owned())]);
5828
5829 let typed = extract_unique_constraint_indexes_from_sql(
5830 "CREATE TABLE typed(id INTEGER(8) PRIMARY KEY, u TEXT UNIQUE)",
5831 "typed",
5832 )
5833 .unwrap();
5834 assert_eq!(
5835 typed
5836 .iter()
5837 .map(|index| index.name.as_str())
5838 .collect::<Vec<_>>(),
5839 ["sqlite_autoindex_typed_1", "sqlite_autoindex_typed_2"]
5840 );
5841 }
5842
5843 #[test]
5844 fn test_autoindex_followup_explicit_index_uses_final_repeated_collation() {
5845 let Some(Statement::CreateIndex(create)) =
5846 parse_single_statement("CREATE INDEX idx_t_a ON t(a COLLATE NOCASE COLLATE RTRIM)")
5847 else {
5848 panic!("expected CREATE INDEX");
5849 };
5850 let table = bare_table_schema("t", &["a"]);
5851 let index = bind_explicit_index(&create, "idx_t_a", "t", &table)
5852 .expect("authoritative index binder should accept repeated COLLATE syntax")
5853 .into_index_schema(7);
5854 assert_eq!(index.columns, ["a"]);
5855 assert_eq!(index.key_collations, [Some("RTRIM".to_owned())]);
5856 }
5857
5858 #[test]
5859 fn test_is_strict_table_sql_detects_strict_options() {
5860 assert!(is_strict_table_sql(
5861 "CREATE TABLE s (id INTEGER, body TEXT) STRICT"
5862 ));
5863 assert!(is_strict_table_sql(
5864 "CREATE TABLE s (id INTEGER) WITHOUT ROWID, STRICT;"
5865 ));
5866 assert!(!is_strict_table_sql(
5867 "CREATE TABLE s (id INTEGER, body TEXT) WITHOUT ROWID"
5868 ));
5869 }
5870
5871 #[test]
5872 fn test_is_without_rowid_table_sql_detects_option() {
5873 assert!(is_without_rowid_table_sql(
5874 "CREATE TABLE s (id INTEGER PRIMARY KEY, body TEXT) WITHOUT ROWID"
5875 ));
5876 assert!(is_without_rowid_table_sql(
5877 "CREATE TABLE s (id INTEGER PRIMARY KEY, body TEXT) WITHOUT ROWID, STRICT;"
5878 ));
5879 assert!(!is_without_rowid_table_sql(
5880 "CREATE TABLE s (id INTEGER PRIMARY KEY, body TEXT) STRICT"
5881 ));
5882 }
5883
5884 #[test]
5885 fn test_is_autoincrement_table_sql_detects_keyword() {
5886 assert!(is_autoincrement_table_sql(
5887 "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, v TEXT)"
5888 ));
5889 assert!(!is_autoincrement_table_sql(
5890 "CREATE TABLE t(id INTEGER PRIMARY KEY, v TEXT)"
5891 ));
5892 }
5893
5894 #[test]
5895 fn test_is_autoincrement_table_sql_ignores_default_literal_keyword() {
5896 assert!(!is_autoincrement_table_sql(
5897 "CREATE TABLE t(id INTEGER PRIMARY KEY, note TEXT DEFAULT 'AUTOINCREMENT')"
5898 ));
5899 assert!(!is_autoincrement_table_sql(
5900 "CREATE TABLE t(id INTEGER PRIMARY KEY, note TEXT DEFAULT 'AUTOINCREMENT') trailing"
5901 ));
5902 }
5903
5904 #[test]
5905 fn test_parse_columns_from_create_sql_populates_strict_types() {
5906 let sql = "CREATE TABLE strict_cols (id INTEGER, score REAL, body TEXT, payload BLOB, any_col ANY) STRICT";
5907 let cols = parse_columns_from_create_sql(sql);
5908 assert_eq!(cols.len(), 5);
5909 assert_eq!(cols[0].strict_type, Some(StrictColumnType::Integer));
5910 assert_eq!(cols[1].strict_type, Some(StrictColumnType::Real));
5911 assert_eq!(cols[2].strict_type, Some(StrictColumnType::Text));
5912 assert_eq!(cols[3].strict_type, Some(StrictColumnType::Blob));
5913 assert_eq!(cols[4].strict_type, Some(StrictColumnType::Any));
5914 }
5915
5916 #[test]
5917 fn test_parse_columns_from_sqlite_master_sql_ignores_virtual_table_options() {
5918 let sql =
5919 "CREATE VIRTUAL TABLE docs USING fts5(subject, body, tokenize='porter', prefix='2 3')";
5920 let cols = parse_columns_from_sqlite_master_sql(sql);
5921 let names: Vec<&str> = cols.iter().map(|column| column.name.as_str()).collect();
5922 assert_eq!(names, vec!["subject", "body"]);
5923 }
5924
5925 #[test]
5926 fn test_extract_check_constraints_from_sql_ignores_literal_check_text() {
5927 let sql = "CREATE TABLE t (note TEXT DEFAULT 'CHECK(fake)', CHECK(length(note) > 0))";
5928 let checks = extract_check_constraints_from_sql(sql);
5929 assert_eq!(checks, vec!["length(note) > 0".to_owned()]);
5930 }
5931
5932 #[test]
5933 fn test_check_constraint_fallback_preserves_column_ownership() {
5934 let sql = r#"CREATE TABLE t(
5938 "owned col" TEXT DEFAULT 'CHECK(fake)' CHECK(length("owned col") > 0),
5939 b INTEGER,
5940 CONSTRAINT/*name*/ table_check CHECK/*expr*/(b > 0) ON CONFLICT FAIL
5941 )"#;
5942 let checks = extract_check_constraints_with_owners_from_sql(sql);
5943 assert_eq!(
5944 checks,
5945 vec![
5946 CheckConstraint {
5947 expr: r#"length("owned col") > 0"#.to_owned(),
5948 owner_column: Some("owned col".to_owned()),
5949 },
5950 CheckConstraint {
5951 expr: "b > 0".to_owned(),
5952 owner_column: None,
5953 },
5954 ]
5955 );
5956 }
5957
5958 #[test]
5959 fn test_foreign_key_fallback_preserves_ownership_and_actions() {
5960 let sql = r#"CREATE TABLE child(
5963 "owned col" INTEGER REFERENCES parent(id)
5964 ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
5965 keep INTEGER,
5966 CONSTRAINT fk_keep FOREIGN KEY(keep) REFERENCES "parent table"("id col")
5967 ON UPDATE RESTRICT NOT DEFERRABLE INITIALLY DEFERRED,
5968 CHECK(keep > 0) ON CONFLICT FAIL
5969 )"#;
5970 let columns = parse_columns_from_create_sql(sql);
5971 let foreign_keys = extract_foreign_keys_from_sql(sql, &columns);
5972
5973 assert_eq!(foreign_keys.len(), 2);
5974 assert_eq!(foreign_keys[0].child_columns, vec![0]);
5975 assert_eq!(foreign_keys[0].owner_column.as_deref(), Some("owned col"));
5976 assert_eq!(foreign_keys[0].parent_table, "parent");
5977 assert_eq!(foreign_keys[0].parent_columns, vec!["id"]);
5978 assert_eq!(foreign_keys[0].on_delete, FkActionType::Cascade);
5979 assert!(foreign_keys[0].deferred);
5980
5981 assert_eq!(foreign_keys[1].child_columns, vec![1]);
5982 assert_eq!(foreign_keys[1].owner_column, None);
5983 assert_eq!(foreign_keys[1].on_update, FkActionType::Restrict);
5984 assert!(!foreign_keys[1].deferred);
5985 }
5986
5987 #[test]
5988 fn test_type_to_affinity_mapping() {
5989 assert_eq!(type_to_affinity("INTEGER"), 'D');
5990 assert_eq!(type_to_affinity("INT"), 'D');
5991 assert_eq!(type_to_affinity("REAL"), 'E');
5992 assert_eq!(type_to_affinity("FLOAT"), 'E');
5993 assert_eq!(type_to_affinity("TEXT"), 'B');
5994 assert_eq!(type_to_affinity("VARCHAR"), 'B');
5995 assert_eq!(type_to_affinity("BLOB"), 'A');
5996 assert_eq!(type_to_affinity("NUMERIC"), 'C');
5997 }
5998
5999 #[test]
6000 fn test_parse_create_index_sql_preserves_quoted_collations_and_comments() {
6001 let sql = r#"CREATE INDEX "idx(words)" ON "items(table)" (
6002 "last, name" COLLATE /* keep comment invisible */ [RTRIM] DESC,
6003 code/* comma, paren ), and COLLATE text stay in comment */COLLATE 'BINARY',
6004 tag COLLATE DESC,
6005 ord COLLATE [DESC] DESC
6006 ) /* index tail */ WHERE active = 1"#;
6007
6008 let Some(Statement::CreateIndex(create)) = parse_single_statement(sql) else {
6009 panic!("expected CREATE INDEX");
6010 };
6011 let table = bare_table_schema(
6012 "items(table)",
6013 &["last, name", "code", "tag", "ord", "active"],
6014 );
6015 let idx = bind_explicit_index(&create, "idx(words)", "items(table)", &table)
6016 .expect("authoritative index binder should preserve quoted metadata")
6017 .into_index_schema(7);
6018
6019 assert_eq!(
6020 idx.columns,
6021 vec![
6022 "last, name".to_owned(),
6023 "code".to_owned(),
6024 "tag".to_owned(),
6025 "ord".to_owned()
6026 ]
6027 );
6028 let expected_collations = ["RTRIM", "BINARY", "DESC", "DESC"];
6036 assert_eq!(
6037 idx.key_collations.len(),
6038 expected_collations.len(),
6039 "every key term must bind a collation: {:?}",
6040 idx.key_collations
6041 );
6042 for (term, expected) in expected_collations.iter().enumerate() {
6043 let actual = idx.key_collations[term].as_deref().unwrap_or_else(|| {
6044 panic!("term {term} must bind collation `{expected}`, found None")
6045 });
6046 assert!(
6047 actual.eq_ignore_ascii_case(expected),
6048 "term {term} must bind collation `{expected}` (case-insensitively), found `{actual}`"
6049 );
6050 }
6051 assert_eq!(
6052 idx.key_sort_directions,
6053 vec![
6054 SortDirection::Desc,
6055 SortDirection::Asc,
6056 SortDirection::Asc,
6057 SortDirection::Desc
6058 ]
6059 );
6060 assert_eq!(idx.where_clause.as_deref(), Some("active = 1"));
6061 }
6062
6063 #[test]
6064 fn test_parse_create_index_sql_preserves_expression_terms() {
6065 let sql =
6066 "CREATE UNIQUE INDEX uq_agents_name_ci ON agents(lower(name) DESC) WHERE is_active = 1";
6067
6068 let Some(Statement::CreateIndex(create)) = parse_single_statement(sql) else {
6069 panic!("expected CREATE INDEX");
6070 };
6071 let table = bare_table_schema("agents", &["name", "is_active"]);
6072 let idx = bind_explicit_index(&create, "uq_agents_name_ci", "agents", &table)
6073 .expect("authoritative index binder should preserve expression metadata")
6074 .into_index_schema(7);
6075
6076 assert!(idx.columns.is_empty());
6077 assert_eq!(idx.key_expressions.len(), 1);
6078 assert_eq!(idx.key_expressions[0].to_ascii_lowercase(), "lower(name)");
6079 assert_eq!(idx.key_sort_directions, vec![SortDirection::Desc]);
6080 assert_eq!(idx.where_clause.as_deref(), Some("is_active = 1"));
6081 assert!(idx.is_unique);
6082 }
6083
6084 #[test]
6085 fn test_build_create_index_sql_preserves_unique_collation_and_direction() {
6086 let terms = [
6087 CreateIndexSqlTerm {
6088 column_name: "project_id",
6089 collation: None,
6090 direction: Some(SortDirection::Asc),
6091 },
6092 CreateIndexSqlTerm {
6093 column_name: "name",
6094 collation: Some("NOCASE"),
6095 direction: Some(SortDirection::Desc),
6096 },
6097 ];
6098
6099 let sql = build_create_index_sql(
6100 "idx_agents_project_name_nocase",
6101 "agents",
6102 true,
6103 &terms,
6104 None,
6105 );
6106
6107 assert_eq!(
6108 sql,
6109 "CREATE UNIQUE INDEX \"idx_agents_project_name_nocase\" ON \"agents\" (\"project_id\" ASC, \"name\" COLLATE \"NOCASE\" DESC)"
6110 );
6111 }
6112
6113 #[test]
6114 fn test_build_create_index_sql_escapes_embedded_quotes_in_identifiers() {
6115 let terms = [CreateIndexSqlTerm {
6116 column_name: "na\"me",
6117 collation: Some("NO\"CASE"),
6118 direction: Some(SortDirection::Desc),
6119 }];
6120
6121 let sql = build_create_index_sql("idx\"q", "ta\"ble", true, &terms, None);
6122
6123 assert_eq!(
6124 sql,
6125 "CREATE UNIQUE INDEX \"idx\"\"q\" ON \"ta\"\"ble\" (\"na\"\"me\" COLLATE \"NO\"\"CASE\" DESC)"
6126 );
6127 }
6128
6129 #[test]
6130 fn test_index_sql_preserves_explicit_reserved_prefix_definition() {
6131 let mut original_ddl = HashMap::new();
6132 original_ddl.insert(
6133 "sqlite_autoindex_link_table_v23_1".to_owned(),
6134 "CREATE UNIQUE INDEX \"sqlite_autoindex_link_table_v23_1\" ON \"link_table\"(a,b)"
6135 .to_owned(),
6136 );
6137
6138 let sql = index_sql_for_persistence(
6139 "sqlite_autoindex_link_table_v23_1",
6140 "link_table",
6141 &original_ddl,
6142 || panic!("preserved explicit DDL must win over prefix classification"),
6143 );
6144
6145 assert_eq!(
6146 sql.as_deref(),
6147 Some(
6148 "CREATE UNIQUE INDEX \"sqlite_autoindex_link_table_v23_1\" ON \"link_table\"(a,b)"
6149 )
6150 );
6151 }
6152
6153 #[test]
6154 fn test_index_sql_keeps_true_implicit_autoindex_null() {
6155 let original_ddl = HashMap::<String, String>::new();
6156
6157 let sql = index_sql_for_persistence(
6158 "sqlite_autoindex_link_table_1",
6159 "link_table",
6160 &original_ddl,
6161 || panic!("implicit autoindex SQL must not be synthesized"),
6162 );
6163
6164 assert_eq!(sql, None);
6165 }
6166
6167 #[test]
6168 fn test_autoindex_ordinal_requires_canonical_positive_ascii_decimal() {
6169 assert_eq!(
6170 parse_autoindex_ordinal("sqlite_autoindex_link_table_1", "link_table"),
6171 Some(1)
6172 );
6173 for noncanonical in [
6174 "sqlite_autoindex_link_table_0",
6175 "sqlite_autoindex_link_table_01",
6176 "sqlite_autoindex_link_table_+1",
6177 "sqlite_autoindex_link_table_١",
6178 "sqlite_autoindex_other_table_1",
6179 ] {
6180 assert_eq!(
6181 parse_autoindex_ordinal(noncanonical, "link_table"),
6182 None,
6183 "{noncanonical} must not classify as an implicit autoindex"
6184 );
6185 }
6186 let overflowing = format!("sqlite_autoindex_link_table_{}0", usize::MAX);
6187 assert_eq!(parse_autoindex_ordinal(&overflowing, "link_table"), None);
6188 }
6189
6190 fn implicit_autoindex_catalog_row(
6191 entry_type: &str,
6192 name: &str,
6193 table_name: &str,
6194 root_page: i64,
6195 sql: Option<&str>,
6196 ) -> Vec<SqliteValue> {
6197 vec![
6198 SqliteValue::Text(entry_type.into()),
6199 SqliteValue::Text(name.into()),
6200 SqliteValue::Text(table_name.into()),
6201 SqliteValue::Integer(root_page),
6202 sql.map_or(SqliteValue::Null, |value| SqliteValue::Text(value.into())),
6203 ]
6204 }
6205
6206 fn assert_implicit_autoindex_catalog_corrupt(
6207 case_name: &str,
6208 entries: &[Vec<SqliteValue>],
6209 detail_needle: &str,
6210 ) {
6211 assert_implicit_autoindex_catalog_corrupt_with_page_bound(
6212 case_name,
6213 entries,
6214 i32::MAX.unsigned_abs(),
6215 detail_needle,
6216 );
6217 }
6218
6219 fn assert_implicit_autoindex_catalog_corrupt_with_page_bound(
6220 case_name: &str,
6221 entries: &[Vec<SqliteValue>],
6222 max_root_page: u32,
6223 detail_needle: &str,
6224 ) {
6225 let header = DatabaseHeader::default();
6226 assert_implicit_autoindex_catalog_corrupt_with_root_context(
6227 case_name,
6228 entries,
6229 max_root_page,
6230 &header,
6231 &HashSet::new(),
6232 detail_needle,
6233 );
6234 }
6235
6236 fn assert_implicit_autoindex_catalog_corrupt_with_root_context(
6237 case_name: &str,
6238 entries: &[Vec<SqliteValue>],
6239 max_root_page: u32,
6240 header: &DatabaseHeader,
6241 free_pages: &HashSet<PageNumber>,
6242 detail_needle: &str,
6243 ) {
6244 let error = bind_implicit_autoindex_catalog(entries, max_root_page, header, free_pages)
6245 .unwrap_err();
6246 let FrankenError::DatabaseCorrupt { detail } = error else {
6247 panic!("{case_name}: expected DatabaseCorrupt, found {error:?}");
6248 };
6249 assert!(
6250 detail.contains(detail_needle),
6251 "{case_name}: expected `{detail_needle}` in corruption detail, found `{detail}`"
6252 );
6253 }
6254
6255 #[test]
6256 fn virtual_table_catalog_canonical_row_is_order_independent() {
6257 let cases = [
6258 (
6259 "contentless before stale default",
6260 vec![
6261 implicit_autoindex_catalog_row(
6262 "table",
6263 "vt",
6264 "vt",
6265 0,
6266 Some("CREATE VIRTUAL TABLE vt USING fts5(body, content='')"),
6267 ),
6268 implicit_autoindex_catalog_row(
6269 "table",
6270 "vt",
6271 "vt",
6272 0,
6273 Some("CREATE VIRTUAL TABLE vt USING fts5(body)"),
6274 ),
6275 ],
6276 0,
6277 ),
6278 (
6279 "contentless after stale default",
6280 vec![
6281 implicit_autoindex_catalog_row(
6282 "table",
6283 "vt",
6284 "vt",
6285 0,
6286 Some("CREATE VIRTUAL TABLE vt USING fts5(body)"),
6287 ),
6288 implicit_autoindex_catalog_row(
6289 "table",
6290 "vt",
6291 "vt",
6292 0,
6293 Some("CREATE VIRTUAL TABLE vt USING fts5(body, content='')"),
6294 ),
6295 ],
6296 1,
6297 ),
6298 (
6299 "equivalent root-zero rows",
6300 vec![
6301 implicit_autoindex_catalog_row(
6302 "table",
6303 "vt",
6304 "vt",
6305 0,
6306 Some("CREATE VIRTUAL TABLE vt USING fts5(body)"),
6307 ),
6308 implicit_autoindex_catalog_row(
6309 "table",
6310 "VT",
6311 "VT",
6312 0,
6313 Some("CREATE VIRTUAL TABLE vt USING fts5(body)"),
6314 ),
6315 ],
6316 0,
6317 ),
6318 (
6319 "positive row before migration row",
6320 vec![
6321 implicit_autoindex_catalog_row(
6322 "table",
6323 "vt",
6324 "vt",
6325 2,
6326 Some("CREATE VIRTUAL TABLE vt USING fts5(body)"),
6327 ),
6328 implicit_autoindex_catalog_row(
6329 "table",
6330 "vt",
6331 "vt",
6332 0,
6333 Some("CREATE VIRTUAL TABLE vt USING fts5(body)"),
6334 ),
6335 ],
6336 0,
6337 ),
6338 (
6339 "positive row after migration row",
6340 vec![
6341 implicit_autoindex_catalog_row(
6342 "table",
6343 "vt",
6344 "vt",
6345 0,
6346 Some("CREATE VIRTUAL TABLE vt USING fts5(body)"),
6347 ),
6348 implicit_autoindex_catalog_row(
6349 "table",
6350 "vt",
6351 "vt",
6352 2,
6353 Some("CREATE VIRTUAL TABLE vt USING fts5(body)"),
6354 ),
6355 ],
6356 1,
6357 ),
6358 ];
6359
6360 for (case_name, entries, expected_row) in cases {
6361 let catalog = bind_implicit_autoindex_catalog(
6362 &entries,
6363 2,
6364 &DatabaseHeader::default(),
6365 &HashSet::new(),
6366 )
6367 .unwrap_or_else(|error| panic!("{case_name}: unexpected bind failure: {error}"));
6368 for row_index in 0..entries.len() {
6369 assert_eq!(
6370 catalog.is_canonical_virtual_table_row(row_index),
6371 row_index == expected_row,
6372 "{case_name}: wrong canonical status for row {row_index}"
6373 );
6374 }
6375 }
6376 }
6377
6378 #[test]
6379 fn implicit_autoindex_catalog_binds_complete_layout_and_real_roots() {
6380 let entries = vec![
6381 implicit_autoindex_catalog_row(
6382 "table",
6383 "t",
6384 "t",
6385 2,
6386 Some("CREATE TABLE t(a TEXT UNIQUE, b TEXT UNIQUE)"),
6387 ),
6388 implicit_autoindex_catalog_row("index", "sqlite_autoindex_t_2", "t", 4, None),
6391 implicit_autoindex_catalog_row("index", "sqlite_autoindex_t_1", "t", 3, None),
6392 implicit_autoindex_catalog_row(
6393 "table",
6394 "wr",
6395 "wr",
6396 5,
6397 Some("CREATE TABLE wr(pk TEXT PRIMARY KEY, u TEXT UNIQUE) WITHOUT ROWID"),
6398 ),
6399 implicit_autoindex_catalog_row("index", "sqlite_autoindex_wr_2", "wr", 6, None),
6400 implicit_autoindex_catalog_row(
6401 "table",
6402 "plain",
6403 "plain",
6404 7,
6405 Some("CREATE TABLE plain(x TEXT)"),
6406 ),
6407 implicit_autoindex_catalog_row(
6408 "index",
6409 "sqlite_autoindex_plain_1",
6410 "plain",
6411 8,
6412 Some("CREATE INDEX sqlite_autoindex_plain_1 ON plain(x)"),
6413 ),
6414 ];
6415
6416 let catalog = bind_implicit_autoindex_catalog(
6417 &entries,
6418 8,
6419 &DatabaseHeader::default(),
6420 &HashSet::new(),
6421 )
6422 .unwrap();
6423 let ordinary = catalog.table("T").unwrap();
6424 let physical = ordinary.physical_index_schemas("t");
6425 assert_eq!(
6426 physical
6427 .iter()
6428 .map(|index| (index.name.as_str(), index.root_page))
6429 .collect::<Vec<_>>(),
6430 vec![("sqlite_autoindex_t_1", 3), ("sqlite_autoindex_t_2", 4)]
6431 );
6432
6433 let without_rowid = catalog.table("wr").unwrap();
6434 assert_eq!(
6435 without_rowid
6436 .slots
6437 .iter()
6438 .map(|bound| bound.storage)
6439 .collect::<Vec<_>>(),
6440 vec![
6441 BoundImplicitAutoindexStorage::TableRoot,
6442 BoundImplicitAutoindexStorage::IndexRoot(6)
6443 ]
6444 );
6445 let wr_physical = without_rowid.physical_index_schemas("wr");
6446 assert_eq!(wr_physical.len(), 1);
6447 assert_eq!(wr_physical[0].name, "sqlite_autoindex_wr_2");
6448 assert_eq!(wr_physical[0].root_page, 6);
6449
6450 let plain = catalog.table("plain").unwrap();
6451 assert_eq!(plain.implicit_slots().count(), 0);
6452 assert!(plain.physical_index_schemas("plain").is_empty());
6453 }
6454
6455 #[test]
6456 fn implicit_autoindex_catalog_accepts_valid_views_triggers_and_supported_fts5_repair() {
6457 let entries = vec![
6458 implicit_autoindex_catalog_row(
6459 "TaBlE",
6460 "plain",
6461 "plain",
6462 2,
6463 Some("CREATE TABLE main.plain(x TEXT)"),
6464 ),
6465 implicit_autoindex_catalog_row(
6466 "ViEw",
6467 "v",
6468 "v",
6469 0,
6470 Some("CREATE VIEW main.v AS SELECT x FROM plain"),
6471 ),
6472 implicit_autoindex_catalog_row(
6473 "TrIgGeR",
6474 "plain",
6475 "plain",
6476 0,
6477 Some("CREATE TRIGGER main.plain AFTER INSERT ON plain BEGIN SELECT 1; END"),
6478 ),
6479 implicit_autoindex_catalog_row(
6480 "trigger",
6481 "v",
6482 "v",
6483 0,
6484 Some("CREATE TRIGGER main.v INSTEAD OF INSERT ON v BEGIN SELECT 1; END"),
6485 ),
6486 implicit_autoindex_catalog_row(
6487 "table",
6488 "docs",
6489 "docs",
6490 0,
6491 Some("CREATE VIRTUAL TABLE main.docs USING fts5(title, body, content='')"),
6492 ),
6493 implicit_autoindex_catalog_row(
6494 "TABLE",
6495 "DOCS",
6496 "DOCS",
6497 0,
6498 Some("CREATE VIRTUAL TABLE docs USING fts5(title, body)"),
6499 ),
6500 implicit_autoindex_catalog_row(
6501 "table",
6502 "legacy_docs",
6503 "legacy_docs",
6504 3,
6505 Some("CREATE VIRTUAL TABLE legacy_docs USING fts5(title, body)"),
6506 ),
6507 implicit_autoindex_catalog_row(
6508 "table",
6509 "LEGACY_DOCS",
6510 "LEGACY_DOCS",
6511 0,
6512 Some("CREATE VIRTUAL TABLE legacy_docs USING fts5(title, body)"),
6513 ),
6514 ];
6515
6516 let catalog = bind_implicit_autoindex_catalog(
6517 &entries,
6518 3,
6519 &DatabaseHeader::default(),
6520 &HashSet::new(),
6521 )
6522 .unwrap();
6523 assert!(catalog.table("PLAIN").is_some());
6524 assert!(catalog.table("docs").is_none());
6525 }
6526
6527 #[test]
6528 fn implicit_autoindex_catalog_rejects_invalid_row_shapes_and_storage_classes() {
6529 let mut short_row =
6530 implicit_autoindex_catalog_row("table", "t", "t", 2, Some("CREATE TABLE t(a)"));
6531 short_row.pop();
6532 let mut non_text_type =
6533 implicit_autoindex_catalog_row("table", "t", "t", 2, Some("CREATE TABLE t(a)"));
6534 non_text_type[0] = SqliteValue::Integer(1);
6535 let mut non_text_name =
6536 implicit_autoindex_catalog_row("table", "t", "t", 2, Some("CREATE TABLE t(a)"));
6537 non_text_name[1] = SqliteValue::Null;
6538 let mut non_text_table_name =
6539 implicit_autoindex_catalog_row("table", "t", "t", 2, Some("CREATE TABLE t(a)"));
6540 non_text_table_name[2] = SqliteValue::Blob(vec![1].into());
6541 let mut invalid_sql_storage =
6542 implicit_autoindex_catalog_row("table", "t", "t", 2, Some("CREATE TABLE t(a)"));
6543 invalid_sql_storage[4] = SqliteValue::Integer(1);
6544
6545 for (case_name, entries, detail_needle) in [
6546 ("short row", vec![short_row], "columns instead of 5"),
6547 (
6548 "non-text type",
6549 vec![non_text_type],
6550 "column `type` must be TEXT",
6551 ),
6552 (
6553 "non-text name",
6554 vec![non_text_name],
6555 "column `name` must be TEXT",
6556 ),
6557 (
6558 "non-text table name",
6559 vec![non_text_table_name],
6560 "column `tbl_name` must be TEXT",
6561 ),
6562 (
6563 "invalid sql storage",
6564 vec![invalid_sql_storage],
6565 "column `sql` must be TEXT or NULL",
6566 ),
6567 (
6568 "table NULL sql",
6569 vec![implicit_autoindex_catalog_row("table", "t", "t", 2, None)],
6570 "has NULL sql",
6571 ),
6572 ] {
6573 assert_implicit_autoindex_catalog_corrupt(case_name, &entries, detail_needle);
6574 }
6575
6576 assert_implicit_autoindex_catalog_corrupt_with_page_bound(
6577 "zero visible bound",
6578 &[],
6579 0,
6580 "without a visible database page",
6581 );
6582 }
6583
6584 #[test]
6585 fn implicit_autoindex_catalog_rejects_incomplete_ambiguous_or_unsafe_catalogs() {
6586 let unique_table = || {
6587 implicit_autoindex_catalog_row(
6588 "table",
6589 "t",
6590 "t",
6591 2,
6592 Some("CREATE TABLE t(a TEXT UNIQUE)"),
6593 )
6594 };
6595 let unique_index = |name: &str, root_page: i64| {
6596 implicit_autoindex_catalog_row("index", name, "t", root_page, None)
6597 };
6598
6599 let mut non_integer_root = unique_table();
6600 non_integer_root[3] = SqliteValue::Text("two".into());
6601 let cases = vec![
6602 (
6603 "missing expected row",
6604 vec![unique_table()],
6605 "missing implicit autoindex",
6606 ),
6607 (
6608 "unexpected ordinal",
6609 vec![unique_table(), unique_index("sqlite_autoindex_t_2", 3)],
6610 "nonexistent declaration slot 2",
6611 ),
6612 (
6613 "duplicate row",
6614 vec![
6615 unique_table(),
6616 unique_index("sqlite_autoindex_t_1", 3),
6617 unique_index("SQLITE_AUTOINDEX_T_1", 4),
6618 ],
6619 "duplicate index entries",
6620 ),
6621 (
6622 "hidden WITHOUT ROWID PK row",
6623 vec![
6624 implicit_autoindex_catalog_row(
6625 "table",
6626 "wr",
6627 "wr",
6628 2,
6629 Some("CREATE TABLE wr(pk TEXT PRIMARY KEY) WITHOUT ROWID"),
6630 ),
6631 implicit_autoindex_catalog_row("index", "sqlite_autoindex_wr_1", "wr", 3, None),
6632 ],
6633 "illegally materializes hidden",
6634 ),
6635 (
6636 "missing implicit parent",
6637 vec![implicit_autoindex_catalog_row(
6638 "index",
6639 "sqlite_autoindex_absent_1",
6640 "absent",
6641 3,
6642 None,
6643 )],
6644 "missing ordinary table",
6645 ),
6646 (
6647 "missing explicit parent",
6648 vec![implicit_autoindex_catalog_row(
6649 "index",
6650 "idx_absent",
6651 "absent",
6652 3,
6653 Some("CREATE INDEX idx_absent ON absent(a)"),
6654 )],
6655 "missing ordinary table",
6656 ),
6657 (
6658 "table name mismatch",
6659 vec![implicit_autoindex_catalog_row(
6660 "table",
6661 "t",
6662 "other",
6663 2,
6664 Some("CREATE TABLE t(a)"),
6665 )],
6666 "mismatched tbl_name",
6667 ),
6668 (
6669 "CREATE TABLE name mismatch",
6670 vec![implicit_autoindex_catalog_row(
6671 "table",
6672 "t",
6673 "t",
6674 2,
6675 Some("CREATE TABLE other(a)"),
6676 )],
6677 "differently named table `other`",
6681 ),
6682 (
6683 "layout conflict mapping",
6684 vec![implicit_autoindex_catalog_row(
6685 "table",
6686 "t",
6687 "t",
6688 2,
6689 Some(
6690 "CREATE TABLE t(a TEXT UNIQUE ON CONFLICT IGNORE, UNIQUE(a) ON CONFLICT REPLACE)",
6691 ),
6692 )],
6693 "invalid implicit autoindex layout",
6694 ),
6695 (
6696 "non-integer root",
6697 vec![non_integer_root],
6698 "must be INTEGER",
6699 ),
6700 (
6701 "zero root",
6702 vec![implicit_autoindex_catalog_row(
6703 "table",
6704 "t",
6705 "t",
6706 0,
6707 Some("CREATE TABLE t(a)"),
6708 )],
6709 "invalid rootpage 0",
6710 ),
6711 (
6712 "negative root",
6713 vec![implicit_autoindex_catalog_row(
6714 "table",
6715 "t",
6716 "t",
6717 -2,
6718 Some("CREATE TABLE t(a)"),
6719 )],
6720 "invalid rootpage -2",
6721 ),
6722 (
6723 "above i32 root",
6724 vec![implicit_autoindex_catalog_row(
6725 "table",
6726 "t",
6727 "t",
6728 i64::from(i32::MAX) + 1,
6729 Some("CREATE TABLE t(a)"),
6730 )],
6731 "exceeds supported range",
6732 ),
6733 (
6734 "penultimate i32 root",
6735 vec![implicit_autoindex_catalog_row(
6736 "table",
6737 "t",
6738 "t",
6739 i64::from(i32::MAX - 1),
6740 Some("CREATE TABLE t(a)"),
6741 )],
6742 "no safe MemDatabase allocation sentinel",
6743 ),
6744 (
6745 "terminal i32 root",
6746 vec![implicit_autoindex_catalog_row(
6747 "table",
6748 "t",
6749 "t",
6750 i64::from(i32::MAX),
6751 Some("CREATE TABLE t(a)"),
6752 )],
6753 "no safe MemDatabase allocation sentinel",
6754 ),
6755 (
6756 "page one collision",
6757 vec![implicit_autoindex_catalog_row(
6758 "table",
6759 "t",
6760 "t",
6761 1,
6762 Some("CREATE TABLE t(a)"),
6763 )],
6764 "shared by sqlite_master",
6765 ),
6766 (
6767 "table index root collision",
6768 vec![unique_table(), unique_index("sqlite_autoindex_t_1", 2)],
6769 "rootpage 2 is shared",
6770 ),
6771 (
6772 "two index root collision",
6773 vec![
6774 implicit_autoindex_catalog_row(
6775 "table",
6776 "t",
6777 "t",
6778 2,
6779 Some("CREATE TABLE t(a UNIQUE, b UNIQUE)"),
6780 ),
6781 unique_index("sqlite_autoindex_t_1", 3),
6782 unique_index("sqlite_autoindex_t_2", 3),
6783 ],
6784 "rootpage 3 is shared",
6785 ),
6786 (
6787 "explicit index identity mismatch",
6788 vec![
6789 implicit_autoindex_catalog_row("table", "t", "t", 2, Some("CREATE TABLE t(a)")),
6790 implicit_autoindex_catalog_row(
6791 "index",
6792 "idx_t",
6793 "t",
6794 3,
6795 Some("CREATE INDEX idx_other ON t(a)"),
6796 ),
6797 ],
6798 "declares index `idx_other`",
6799 ),
6800 (
6801 "virtual table identity mismatch",
6802 vec![implicit_autoindex_catalog_row(
6803 "table",
6804 "vt",
6805 "vt",
6806 0,
6807 Some("CREATE VIRTUAL TABLE other USING fts5(body)"),
6808 )],
6809 "declares `other`",
6810 ),
6811 (
6812 "unsupported schema type",
6813 vec![implicit_autoindex_catalog_row(
6814 "bogus",
6815 "x",
6816 "x",
6817 0,
6818 Some("bogus"),
6819 )],
6820 "unsupported type",
6821 ),
6822 (
6823 "view owns a root",
6824 vec![implicit_autoindex_catalog_row(
6825 "view",
6826 "v",
6827 "v",
6828 2,
6829 Some("CREATE VIEW v AS SELECT 1"),
6830 )],
6831 "must have rootpage 0",
6832 ),
6833 ];
6834
6835 for (case_name, entries, detail_needle) in cases {
6836 assert_implicit_autoindex_catalog_corrupt(case_name, &entries, detail_needle);
6837 }
6838
6839 assert_implicit_autoindex_catalog_corrupt_with_page_bound(
6840 "root past visible database",
6841 &[
6842 implicit_autoindex_catalog_row(
6843 "table",
6844 "t",
6845 "t",
6846 2,
6847 Some("CREATE TABLE t(a TEXT UNIQUE)"),
6848 ),
6849 implicit_autoindex_catalog_row("index", "sqlite_autoindex_t_1", "t", 4, None),
6850 ],
6851 3,
6852 "exceeds the visible database page count 3",
6853 );
6854 }
6855
6856 #[test]
6857 fn implicit_autoindex_catalog_rejects_schema_identity_and_namespace_corruption() {
6858 let ordinary_table =
6859 || implicit_autoindex_catalog_row("table", "t", "t", 2, Some("CREATE TABLE t(a TEXT)"));
6860 let cases = vec![
6861 (
6862 "ordinary CREATE parse failure",
6863 vec![implicit_autoindex_catalog_row(
6864 "table",
6865 "t",
6866 "t",
6867 2,
6868 Some("CREATE TABLE t("),
6869 )],
6870 "could not parse CREATE TABLE",
6871 ),
6872 (
6873 "stored CTAS",
6874 vec![implicit_autoindex_catalog_row(
6875 "table",
6876 "t",
6877 "t",
6878 2,
6879 Some("CREATE TABLE t AS SELECT 1 AS a"),
6880 )],
6881 "CREATE TABLE AS SELECT",
6882 ),
6883 (
6884 "duplicate ordinary table",
6885 vec![
6886 ordinary_table(),
6887 implicit_autoindex_catalog_row(
6888 "table",
6889 "T",
6890 "T",
6891 3,
6892 Some("CREATE TABLE T(a TEXT)"),
6893 ),
6894 ],
6895 "duplicate table entries",
6896 ),
6897 (
6898 "table view namespace collision",
6899 vec![
6900 ordinary_table(),
6901 implicit_autoindex_catalog_row(
6902 "view",
6903 "T",
6904 "T",
6905 0,
6906 Some("CREATE VIEW T AS SELECT 1"),
6907 ),
6908 ],
6909 "schema name `T` is shared",
6910 ),
6911 (
6912 "view NULL sql",
6913 vec![implicit_autoindex_catalog_row("view", "v", "v", 0, None)],
6914 "non-NULL sql",
6915 ),
6916 (
6917 "view name mismatch",
6918 vec![implicit_autoindex_catalog_row(
6919 "view",
6920 "v",
6921 "v",
6922 0,
6923 Some("CREATE VIEW other AS SELECT 1"),
6924 )],
6925 "differently named view",
6926 ),
6927 (
6928 "temporary view",
6929 vec![implicit_autoindex_catalog_row(
6930 "view",
6931 "v",
6932 "v",
6933 0,
6934 Some("CREATE TEMP VIEW v AS SELECT 1"),
6935 )],
6936 "temporary, non-main, or differently named view",
6937 ),
6938 (
6939 "missing trigger target",
6940 vec![implicit_autoindex_catalog_row(
6941 "trigger",
6942 "tr",
6943 "missing",
6944 0,
6945 Some("CREATE TRIGGER tr AFTER INSERT ON missing BEGIN SELECT 1; END"),
6946 )],
6947 "missing or incompatible table `missing`",
6948 ),
6949 (
6950 "INSTEAD OF trigger on table",
6951 vec![
6952 ordinary_table(),
6953 implicit_autoindex_catalog_row(
6954 "trigger",
6955 "tr",
6956 "t",
6957 0,
6958 Some("CREATE TRIGGER tr INSTEAD OF INSERT ON t BEGIN SELECT 1; END"),
6959 ),
6960 ],
6961 "missing or incompatible view `t`",
6962 ),
6963 (
6964 "AFTER trigger on view",
6965 vec![
6966 implicit_autoindex_catalog_row(
6967 "view",
6968 "v",
6969 "v",
6970 0,
6971 Some("CREATE VIEW v AS SELECT 1"),
6972 ),
6973 implicit_autoindex_catalog_row(
6974 "trigger",
6975 "tr",
6976 "v",
6977 0,
6978 Some("CREATE TRIGGER tr AFTER INSERT ON v BEGIN SELECT 1; END"),
6979 ),
6980 ],
6981 "missing or incompatible table `v`",
6982 ),
6983 (
6984 "explicit index parse failure",
6985 vec![
6986 ordinary_table(),
6987 implicit_autoindex_catalog_row(
6988 "index",
6989 "idx_t",
6990 "t",
6991 3,
6992 Some("CREATE INDEX idx_t ON"),
6993 ),
6994 ],
6995 "could not parse CREATE INDEX",
6996 ),
6997 (
6998 "explicit index table mismatch",
6999 vec![
7000 ordinary_table(),
7001 implicit_autoindex_catalog_row(
7002 "index",
7003 "idx_t",
7004 "t",
7005 3,
7006 Some("CREATE INDEX idx_t ON other(a)"),
7007 ),
7008 ],
7009 "on table `other` instead of `t`",
7010 ),
7011 (
7012 "hidden logical autoindex name claimed explicitly",
7013 vec![
7014 implicit_autoindex_catalog_row(
7015 "table",
7016 "wr",
7017 "wr",
7018 2,
7019 Some("CREATE TABLE wr(pk TEXT PRIMARY KEY, u TEXT) WITHOUT ROWID"),
7020 ),
7021 implicit_autoindex_catalog_row(
7022 "index",
7023 "sqlite_autoindex_wr_1",
7024 "wr",
7025 3,
7026 Some("CREATE INDEX sqlite_autoindex_wr_1 ON wr(u)"),
7027 ),
7028 ],
7029 "collides with logical implicit index",
7030 ),
7031 (
7032 "conflicting rootpage-zero virtual tables",
7033 vec![
7034 implicit_autoindex_catalog_row(
7035 "table",
7036 "vt",
7037 "vt",
7038 0,
7039 Some("CREATE VIRTUAL TABLE vt USING fts5(body)"),
7040 ),
7041 implicit_autoindex_catalog_row(
7042 "table",
7043 "VT",
7044 "VT",
7045 0,
7046 Some("CREATE VIRTUAL TABLE vt USING rtree(id, min_x, max_x)"),
7047 ),
7048 ],
7049 "conflicting virtual-table entries",
7050 ),
7051 (
7052 "third duplicate virtual table",
7053 vec![
7054 implicit_autoindex_catalog_row(
7055 "table",
7056 "vt",
7057 "vt",
7058 0,
7059 Some("CREATE VIRTUAL TABLE vt USING fts5(body)"),
7060 ),
7061 implicit_autoindex_catalog_row(
7062 "table",
7063 "VT",
7064 "VT",
7065 0,
7066 Some("CREATE VIRTUAL TABLE vt USING fts5(body)"),
7067 ),
7068 implicit_autoindex_catalog_row(
7069 "table",
7070 "vt",
7071 "vt",
7072 0,
7073 Some("CREATE VIRTUAL TABLE vt USING fts5(body)"),
7074 ),
7075 ],
7076 "conflicting virtual-table entries",
7077 ),
7078 ];
7079
7080 for (case_name, entries, detail_needle) in cases {
7081 assert_implicit_autoindex_catalog_corrupt(case_name, &entries, detail_needle);
7082 }
7083 }
7084
7085 #[test]
7086 fn implicit_autoindex_catalog_rejects_reserved_or_free_root_pages() {
7087 let table_at = |root_page| {
7088 vec![implicit_autoindex_catalog_row(
7089 "table",
7090 "t",
7091 "t",
7092 root_page,
7093 Some("CREATE TABLE t(a)"),
7094 )]
7095 };
7096
7097 let lock_byte_page = fsqlite_pager::lock_byte_page(PageSize::DEFAULT);
7098 assert_implicit_autoindex_catalog_corrupt_with_page_bound(
7099 "lock-byte root",
7100 &table_at(i64::from(lock_byte_page)),
7101 lock_byte_page,
7102 "reserved lock-byte rootpage",
7103 );
7104
7105 let auto_vacuum_header = DatabaseHeader {
7106 largest_root_page: 3,
7107 ..DatabaseHeader::default()
7108 };
7109 assert_implicit_autoindex_catalog_corrupt_with_root_context(
7110 "auto-vacuum pointer-map root",
7111 &table_at(2),
7112 3,
7113 &auto_vacuum_header,
7114 &HashSet::new(),
7115 "pointer-map rootpage 2",
7116 );
7117
7118 let free_page = PageNumber::new(2).unwrap();
7119 assert_implicit_autoindex_catalog_corrupt_with_root_context(
7120 "freelist root",
7121 &table_at(2),
7122 2,
7123 &DatabaseHeader::default(),
7124 &HashSet::from([free_page]),
7125 "uses free rootpage 2",
7126 );
7127 }
7128
7129 #[test]
7130 fn test_index_sql_synthesizes_unrecorded_explicit_index() {
7131 let original_ddl = HashMap::<String, String>::new();
7132
7133 let sql =
7134 index_sql_for_persistence("idx_link_table_a", "link_table", &original_ddl, || {
7135 "CREATE INDEX idx_link_table_a ON link_table(a)".to_owned()
7136 });
7137
7138 assert_eq!(
7139 sql.as_deref(),
7140 Some("CREATE INDEX idx_link_table_a ON link_table(a)")
7141 );
7142 }
7143
7144 #[test]
7145 fn test_index_sql_synthesizes_noncanonical_reserved_prefix_without_ddl() {
7146 let original_ddl = HashMap::<String, String>::new();
7147
7148 let sql = index_sql_for_persistence(
7149 "sqlite_autoindex_link_table_v23_1",
7150 "link_table",
7151 &original_ddl,
7152 || {
7153 "CREATE UNIQUE INDEX \"sqlite_autoindex_link_table_v23_1\" ON \"link_table\"(a,b)"
7154 .to_owned()
7155 },
7156 );
7157
7158 assert_eq!(
7159 sql.as_deref(),
7160 Some(
7161 "CREATE UNIQUE INDEX \"sqlite_autoindex_link_table_v23_1\" ON \"link_table\"(a,b)"
7162 )
7163 );
7164 }
7165
7166 #[test]
7167 fn test_reserved_prefix_explicit_index_survives_without_original_table_ddl() {
7168 asupersync::test_utils::run_test(|| async {
7169 const TABLE_SQL: &str = "CREATE TABLE link_table(\
7170 a INTEGER NOT NULL,\
7171 b INTEGER NOT NULL,\
7172 PRIMARY KEY(a,b)\
7173 )";
7174 const INDEX_NAME: &str = "sqlite_autoindex_link_table_v23_1";
7175 const INDEX_SQL: &str = "CREATE UNIQUE INDEX \
7176 \"sqlite_autoindex_link_table_v23_1\" ON \"link_table\"(a,b)";
7177
7178 let dir = tempfile::tempdir().unwrap();
7179 let source_path = dir.path().join("reserved-prefix-source.db");
7180 let rebuilt_path = dir.path().join("reserved-prefix-rebuilt.db");
7181
7182 {
7183 let sqlite = rusqlite::Connection::open(&source_path).unwrap();
7184 sqlite
7185 .execute_batch(&format!(
7186 r"
7187 {TABLE_SQL};
7188 CREATE UNIQUE INDEX legacy_unique ON link_table(a,b);
7189 INSERT INTO link_table VALUES (1,2), (3,4);
7190 PRAGMA writable_schema=ON;
7191 UPDATE sqlite_master
7192 SET name='{INDEX_NAME}', sql='{INDEX_SQL}'
7193 WHERE type='index' AND name='legacy_unique';
7194 PRAGMA schema_version=2;
7195 "
7196 ))
7197 .unwrap();
7198 }
7199
7200 {
7201 let sqlite = rusqlite::Connection::open(&source_path).unwrap();
7202 let quick_check: String = sqlite
7203 .query_row("PRAGMA quick_check;", [], |row| row.get(0))
7204 .unwrap();
7205 let integrity_check: String = sqlite
7206 .query_row("PRAGMA integrity_check;", [], |row| row.get(0))
7207 .unwrap();
7208 assert_eq!(quick_check, "ok");
7209 assert_eq!(integrity_check, "ok");
7210 }
7211
7212 let loaded = load_test_db(&source_path).await.unwrap();
7213 let table = loaded
7214 .schema
7215 .iter()
7216 .find(|table| table.name == "link_table")
7217 .unwrap();
7218 assert!(
7219 table.indexes.iter().any(|index| index.name == INDEX_NAME),
7220 "a non-NULL CREATE INDEX entry is explicit even with a reserved-prefix name"
7221 );
7222
7223 let mut original_ddl = HashMap::new();
7224 original_ddl.insert(INDEX_NAME.to_owned(), INDEX_SQL.to_owned());
7228 let header = DatabaseHeader {
7229 page_size: DEFAULT_PAGE_SIZE,
7230 schema_cookie: loaded.schema_cookie,
7231 change_counter: loaded.change_counter,
7232 version_valid_for: loaded.change_counter,
7233 ..DatabaseHeader::default()
7234 };
7235 persist_to_sqlite_with_header_and_master_entries(
7236 &Cx::new(),
7237 &rebuilt_path,
7238 &loaded.schema,
7239 &loaded.db,
7240 &header,
7241 &[],
7242 &original_ddl,
7243 )
7244 .await
7245 .unwrap();
7246
7247 let sqlite = rusqlite::Connection::open(&rebuilt_path).unwrap();
7248 let stored_table_sql: String = sqlite
7249 .query_row(
7250 "SELECT sql FROM sqlite_master WHERE type='table' AND name='link_table';",
7251 [],
7252 |row| row.get(0),
7253 )
7254 .unwrap();
7255 let stored_sql: String = sqlite
7256 .query_row(
7257 "SELECT sql FROM sqlite_master WHERE type='index' AND name=?1;",
7258 [INDEX_NAME],
7259 |row| row.get(0),
7260 )
7261 .unwrap();
7262 let row_count: i64 = sqlite
7263 .query_row("SELECT COUNT(*) FROM link_table;", [], |row| row.get(0))
7264 .unwrap();
7265 let quick_check: String = sqlite
7266 .query_row("PRAGMA quick_check;", [], |row| row.get(0))
7267 .unwrap();
7268 let integrity_check: String = sqlite
7269 .query_row("PRAGMA integrity_check;", [], |row| row.get(0))
7270 .unwrap();
7271 assert!(
7272 !stored_table_sql.to_ascii_uppercase().contains("UNIQUE"),
7273 "reconstructed table DDL must not duplicate the explicit reserved-prefix index: {stored_table_sql}"
7274 );
7275 assert_eq!(stored_sql, INDEX_SQL);
7276 assert_eq!(row_count, 2);
7277 assert_eq!(quick_check, "ok");
7278 assert_eq!(integrity_check, "ok");
7279 drop(sqlite);
7280
7281 let reopened = load_test_db(&rebuilt_path).await.unwrap();
7282 let table = reopened
7283 .schema
7284 .iter()
7285 .find(|table| table.name == "link_table")
7286 .unwrap();
7287 assert!(table.indexes.iter().any(|index| index.name == INDEX_NAME));
7288 });
7289 }
7290
7291 #[test]
7292 fn test_build_create_expression_index_sql_does_not_duplicate_collation() {
7293 let expressions = vec!["lower(name) COLLATE NOCASE".to_owned()];
7294 let collations = vec![Some("NOCASE".to_owned())];
7295 let directions = vec![SortDirection::Desc];
7296
7297 let sql = build_create_expression_index_sql(
7298 "idx_expr",
7299 "agents",
7300 false,
7301 &expressions,
7302 &collations,
7303 &directions,
7304 Some("is_active = 1"),
7305 );
7306
7307 assert_eq!(
7308 sql,
7309 "CREATE INDEX \"idx_expr\" ON \"agents\" (lower(name) COLLATE NOCASE DESC) WHERE is_active = 1"
7310 );
7311 }
7312
7313 #[test]
7314 fn test_persist_to_sqlite_keeps_expression_index_btree_and_schema() {
7315 asupersync::test_utils::run_test(|| async {
7316 let dir = tempfile::tempdir().unwrap();
7317 let db_path = dir.path().join("expression-index-persist.db");
7318 let cx = Cx::new();
7319
7320 let mut db = MemDatabase::new();
7321 db.create_table_at(2, 3);
7322 let table_data = db.get_table_mut(2).unwrap();
7323 table_data.insert_row(
7324 1,
7325 vec![
7326 SqliteValue::Integer(1),
7327 SqliteValue::Text("Alpha".into()),
7328 SqliteValue::Integer(1),
7329 ],
7330 );
7331 table_data.insert_row(
7332 2,
7333 vec![
7334 SqliteValue::Integer(2),
7335 SqliteValue::Text("Dormant".into()),
7336 SqliteValue::Integer(0),
7337 ],
7338 );
7339
7340 let schema = vec![TableSchema {
7341 name: "agents".to_owned(),
7342 root_page: 2,
7343 columns: vec![
7344 ColumnInfo {
7345 name: "id".to_owned(),
7346 affinity: 'D',
7347 is_ipk: true,
7348 type_name: Some("INTEGER".to_owned()),
7349 notnull: false,
7350 unique: false,
7351 default_value: None,
7352 strict_type: None,
7353 generated_expr: None,
7354 generated_stored: None,
7355 collation: None,
7356 conflict_action: None,
7357 },
7358 ColumnInfo {
7359 name: "name".to_owned(),
7360 affinity: 'B',
7361 is_ipk: false,
7362 type_name: Some("TEXT".to_owned()),
7363 notnull: true,
7364 unique: false,
7365 default_value: None,
7366 strict_type: None,
7367 generated_expr: None,
7368 generated_stored: None,
7369 collation: None,
7370 conflict_action: None,
7371 },
7372 ColumnInfo {
7373 name: "is_active".to_owned(),
7374 affinity: 'D',
7375 is_ipk: false,
7376 type_name: Some("INTEGER".to_owned()),
7377 notnull: true,
7378 unique: false,
7379 default_value: Some("1".to_owned()),
7380 strict_type: None,
7381 generated_expr: None,
7382 generated_stored: None,
7383 collation: None,
7384 conflict_action: None,
7385 },
7386 ],
7387 indexes: vec![IndexSchema {
7388 name: "uq_agents_name_ci".to_owned(),
7389 root_page: 3,
7390 columns: Vec::new(),
7391 key_expressions: vec!["lower(name)".to_owned()],
7392 key_sort_directions: vec![SortDirection::Asc],
7393 where_clause: Some("is_active = 1".to_owned()),
7394 is_unique: true,
7395 key_collations: vec![None],
7396 conflict_action: None,
7397 }],
7398 strict: false,
7399 without_rowid: false,
7400 primary_key_constraints: vec![vec!["id".to_owned()]],
7401 foreign_keys: Vec::new(),
7402 check_constraints: Vec::new(),
7403 }];
7404 let header = DatabaseHeader {
7405 page_size: DEFAULT_PAGE_SIZE,
7406 schema_cookie: 1,
7407 change_counter: 1,
7408 version_valid_for: 1,
7409 ..DatabaseHeader::default()
7410 };
7411 let mut original_ddl = HashMap::new();
7412 original_ddl.insert(
7413 "agents".to_owned(),
7414 "CREATE TABLE agents (id INTEGER PRIMARY KEY, name TEXT NOT NULL, is_active INTEGER NOT NULL DEFAULT 1)"
7415 .to_owned(),
7416 );
7417 original_ddl.insert(
7418 "uq_agents_name_ci".to_owned(),
7419 "CREATE UNIQUE INDEX uq_agents_name_ci ON agents(lower(name)) WHERE is_active = 1"
7420 .to_owned(),
7421 );
7422
7423 persist_to_sqlite_with_header_and_master_entries(
7424 &cx,
7425 &db_path,
7426 &schema,
7427 &db,
7428 &header,
7429 &[],
7430 &original_ddl,
7431 )
7432 .await
7433 .unwrap();
7434
7435 let conn = rusqlite::Connection::open(&db_path).unwrap();
7436 let integrity: String = conn
7437 .query_row("PRAGMA integrity_check;", [], |row| row.get(0))
7438 .unwrap();
7439 assert_eq!(integrity, "ok");
7440 let index_sql: String = conn
7441 .query_row(
7442 "SELECT sql FROM sqlite_master WHERE type='index' AND name='uq_agents_name_ci';",
7443 [],
7444 |row| row.get(0),
7445 )
7446 .unwrap();
7447 assert!(
7448 index_sql.to_ascii_lowercase().contains("lower(name)")
7449 && index_sql
7450 .to_ascii_lowercase()
7451 .contains("where is_active = 1"),
7452 "expression index SQL should be preserved: {index_sql}"
7453 );
7454 let duplicate = conn.execute(
7455 "INSERT INTO agents(name, is_active) VALUES ('ALPHA', 1);",
7456 [],
7457 );
7458 assert!(
7459 duplicate.is_err(),
7460 "persisted expression index should still enforce active-name uniqueness"
7461 );
7462 });
7463 }
7464
7465 #[test]
7474 fn test_persist_to_sqlite_builds_desc_index_in_declared_key_order() {
7475 asupersync::test_utils::run_test(|| async {
7476 const ROW_COUNT: i64 = 2_000;
7477 const GROUP_COUNT: i64 = 20;
7478 const PROBE_GROUP: i64 = 7;
7479
7480 let dir = tempfile::tempdir().unwrap();
7481 let db_path = dir.path().join("desc-index-persist.db");
7482 let cx = Cx::new();
7483
7484 let mut db = MemDatabase::new();
7485 db.create_table_at(2, 3);
7486 let table_data = db.get_table_mut(2).unwrap();
7487 for id in 1..=ROW_COUNT {
7488 table_data.insert_row(
7489 id,
7490 vec![
7491 SqliteValue::Integer(id),
7492 SqliteValue::Integer(id % GROUP_COUNT),
7493 SqliteValue::Text(format!("payload-{id:0>200}").into()),
7496 ],
7497 );
7498 }
7499
7500 let schema = vec![TableSchema {
7501 name: "live".to_owned(),
7502 root_page: 2,
7503 columns: vec![
7504 ColumnInfo {
7505 name: "id".to_owned(),
7506 affinity: 'D',
7507 is_ipk: true,
7508 type_name: Some("INTEGER".to_owned()),
7509 notnull: false,
7510 unique: false,
7511 default_value: None,
7512 strict_type: None,
7513 generated_expr: None,
7514 generated_stored: None,
7515 collation: None,
7516 conflict_action: None,
7517 },
7518 ColumnInfo {
7519 name: "grp".to_owned(),
7520 affinity: 'D',
7521 is_ipk: false,
7522 type_name: Some("INTEGER".to_owned()),
7523 notnull: true,
7524 unique: false,
7525 default_value: None,
7526 strict_type: None,
7527 generated_expr: None,
7528 generated_stored: None,
7529 collation: None,
7530 conflict_action: None,
7531 },
7532 ColumnInfo {
7533 name: "payload".to_owned(),
7534 affinity: 'B',
7535 is_ipk: false,
7536 type_name: Some("TEXT".to_owned()),
7537 notnull: true,
7538 unique: false,
7539 default_value: None,
7540 strict_type: None,
7541 generated_expr: None,
7542 generated_stored: None,
7543 collation: None,
7544 conflict_action: None,
7545 },
7546 ],
7547 indexes: vec![IndexSchema {
7548 name: "idx_live_grp_desc".to_owned(),
7549 root_page: 3,
7550 columns: vec!["grp".to_owned(), "id".to_owned()],
7551 key_expressions: Vec::new(),
7552 key_sort_directions: vec![SortDirection::Asc, SortDirection::Desc],
7553 where_clause: None,
7554 is_unique: false,
7555 key_collations: vec![None, None],
7556 conflict_action: None,
7557 }],
7558 strict: false,
7559 without_rowid: false,
7560 primary_key_constraints: vec![vec!["id".to_owned()]],
7561 foreign_keys: Vec::new(),
7562 check_constraints: Vec::new(),
7563 }];
7564 let header = DatabaseHeader {
7565 page_size: DEFAULT_PAGE_SIZE,
7566 schema_cookie: 1,
7567 change_counter: 1,
7568 version_valid_for: 1,
7569 ..DatabaseHeader::default()
7570 };
7571 let mut original_ddl = HashMap::new();
7572 original_ddl.insert(
7573 "live".to_owned(),
7574 "CREATE TABLE live (id INTEGER PRIMARY KEY, grp INTEGER NOT NULL, payload TEXT NOT NULL)"
7575 .to_owned(),
7576 );
7577 original_ddl.insert(
7578 "idx_live_grp_desc".to_owned(),
7579 "CREATE INDEX idx_live_grp_desc ON live(grp, id DESC)".to_owned(),
7580 );
7581
7582 persist_to_sqlite_with_header_and_master_entries(
7583 &cx,
7584 &db_path,
7585 &schema,
7586 &db,
7587 &header,
7588 &[],
7589 &original_ddl,
7590 )
7591 .await
7592 .unwrap();
7593
7594 let conn = rusqlite::Connection::open(&db_path).unwrap();
7597
7598 let integrity: String = conn
7599 .query_row("PRAGMA integrity_check;", [], |row| row.get(0))
7600 .unwrap();
7601 assert_eq!(
7602 integrity, "ok",
7603 "stock SQLite must accept a persisted DESC index as structurally sound"
7604 );
7605
7606 let declared_sql: String = conn
7607 .query_row(
7608 "SELECT sql FROM sqlite_master WHERE type='index' AND name='idx_live_grp_desc';",
7609 [],
7610 |row| row.get(0),
7611 )
7612 .unwrap();
7613 assert!(
7614 declared_sql.to_ascii_lowercase().contains("id desc"),
7615 "persisted DDL must keep the DESC term: {declared_sql}"
7616 );
7617
7618 let collect = |sql: &str| -> Vec<i64> {
7619 let mut stmt = conn.prepare(sql).unwrap();
7620 let rows = stmt
7621 .query_map([PROBE_GROUP], |row| row.get::<_, i64>(0))
7622 .unwrap()
7623 .collect::<std::result::Result<Vec<_>, _>>()
7624 .unwrap();
7625 rows
7626 };
7627
7628 let forced = collect(
7629 "SELECT id FROM live INDEXED BY idx_live_grp_desc \
7630 WHERE grp = ?1 ORDER BY id DESC",
7631 );
7632 let scanned =
7633 collect("SELECT id FROM live NOT INDEXED WHERE grp = ?1 ORDER BY id DESC");
7634
7635 assert!(
7636 !scanned.is_empty(),
7637 "probe group must actually contain rows"
7638 );
7639 assert_eq!(
7640 forced,
7641 scanned,
7642 "forced DESC-index lookup must return the same rows as the table scan \
7643 (missing {} of {} rows)",
7644 scanned.len().saturating_sub(forced.len()),
7645 scanned.len()
7646 );
7647 });
7648 }
7649
7650 #[test]
7660 fn test_persist_to_sqlite_builds_collated_desc_partial_index_in_declared_order() {
7661 asupersync::test_utils::run_test(|| async {
7662 const ROW_COUNT: i64 = 400;
7663
7664 let dir = tempfile::tempdir().unwrap();
7665 let db_path = dir.path().join("collated-desc-partial-index.db");
7666 let cx = Cx::new();
7667
7668 let prefixes = ["a", "B", "c", "D"];
7670
7671 let mut db = MemDatabase::new();
7672 db.create_table_at(2, 3);
7673 let table_data = db.get_table_mut(2).unwrap();
7674 for id in 1..=ROW_COUNT {
7675 let prefix = prefixes[usize::try_from(id - 1).unwrap() % prefixes.len()];
7676 table_data.insert_row(
7677 id,
7678 vec![
7679 SqliteValue::Integer(id),
7680 SqliteValue::Text(format!("{prefix}{id:04}").into()),
7681 SqliteValue::Integer(i64::from(id % 3 != 0)),
7683 ],
7684 );
7685 }
7686
7687 let schema = vec![TableSchema {
7688 name: "docs".to_owned(),
7689 root_page: 2,
7690 columns: vec![
7691 ColumnInfo {
7692 name: "id".to_owned(),
7693 affinity: 'D',
7694 is_ipk: true,
7695 type_name: Some("INTEGER".to_owned()),
7696 notnull: false,
7697 unique: false,
7698 default_value: None,
7699 strict_type: None,
7700 generated_expr: None,
7701 generated_stored: None,
7702 collation: None,
7703 conflict_action: None,
7704 },
7705 ColumnInfo {
7706 name: "name".to_owned(),
7707 affinity: 'B',
7708 is_ipk: false,
7709 type_name: Some("TEXT".to_owned()),
7710 notnull: true,
7711 unique: false,
7712 default_value: None,
7713 strict_type: None,
7714 generated_expr: None,
7715 generated_stored: None,
7716 collation: None,
7717 conflict_action: None,
7718 },
7719 ColumnInfo {
7720 name: "active".to_owned(),
7721 affinity: 'D',
7722 is_ipk: false,
7723 type_name: Some("INTEGER".to_owned()),
7724 notnull: true,
7725 unique: false,
7726 default_value: Some("1".to_owned()),
7727 strict_type: None,
7728 generated_expr: None,
7729 generated_stored: None,
7730 collation: None,
7731 conflict_action: None,
7732 },
7733 ],
7734 indexes: vec![IndexSchema {
7735 name: "idx_docs_name_ci_desc".to_owned(),
7736 root_page: 3,
7737 columns: vec!["name".to_owned()],
7738 key_expressions: Vec::new(),
7739 key_sort_directions: vec![SortDirection::Desc],
7740 where_clause: Some("active = 1".to_owned()),
7741 is_unique: false,
7742 key_collations: vec![Some("NOCASE".to_owned())],
7743 conflict_action: None,
7744 }],
7745 strict: false,
7746 without_rowid: false,
7747 primary_key_constraints: vec![vec!["id".to_owned()]],
7748 foreign_keys: Vec::new(),
7749 check_constraints: Vec::new(),
7750 }];
7751 let header = DatabaseHeader {
7752 page_size: DEFAULT_PAGE_SIZE,
7753 schema_cookie: 1,
7754 change_counter: 1,
7755 version_valid_for: 1,
7756 ..DatabaseHeader::default()
7757 };
7758 let mut original_ddl = HashMap::new();
7759 original_ddl.insert(
7760 "docs".to_owned(),
7761 "CREATE TABLE docs (id INTEGER PRIMARY KEY, name TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1)"
7762 .to_owned(),
7763 );
7764 original_ddl.insert(
7765 "idx_docs_name_ci_desc".to_owned(),
7766 "CREATE INDEX idx_docs_name_ci_desc ON docs(name COLLATE NOCASE DESC) WHERE active = 1"
7767 .to_owned(),
7768 );
7769
7770 persist_to_sqlite_with_header_and_master_entries(
7771 &cx,
7772 &db_path,
7773 &schema,
7774 &db,
7775 &header,
7776 &[],
7777 &original_ddl,
7778 )
7779 .await
7780 .unwrap();
7781
7782 let conn = rusqlite::Connection::open(&db_path).unwrap();
7783
7784 let integrity: String = conn
7785 .query_row("PRAGMA integrity_check;", [], |row| row.get(0))
7786 .unwrap();
7787 assert_eq!(
7788 integrity, "ok",
7789 "stock SQLite must accept a persisted COLLATE NOCASE DESC partial index"
7790 );
7791
7792 let collect = |sql: &str| -> Vec<String> {
7793 let mut stmt = conn.prepare(sql).unwrap();
7794 stmt.query_map([], |row| row.get::<_, String>(0))
7795 .unwrap()
7796 .collect::<std::result::Result<Vec<_>, _>>()
7797 .unwrap()
7798 };
7799
7800 let forced = collect(
7801 "SELECT name FROM docs INDEXED BY idx_docs_name_ci_desc \
7802 WHERE active = 1 ORDER BY name COLLATE NOCASE DESC",
7803 );
7804 let scanned = collect(
7805 "SELECT name FROM docs NOT INDEXED \
7806 WHERE active = 1 ORDER BY name COLLATE NOCASE DESC",
7807 );
7808
7809 assert!(
7810 !scanned.is_empty(),
7811 "partial predicate must retain some rows"
7812 );
7813 assert!(
7814 scanned.len() < usize::try_from(ROW_COUNT).unwrap(),
7815 "partial predicate must actually exclude some rows"
7816 );
7817 assert_eq!(
7818 forced, scanned,
7819 "forced collated-DESC partial-index scan must match the table scan"
7820 );
7821 });
7822 }
7823
7824 #[test]
7843 fn test_persist_to_sqlite_builds_unique_mixed_direction_rtrim_index() {
7844 asupersync::test_utils::run_test(|| async {
7845 const ROW_COUNT: i64 = 1_500;
7846 const CODE_GROUPS: i64 = 300;
7847 const TIER_COUNT: i64 = 7;
7848
7849 let dir = tempfile::tempdir().unwrap();
7850 let db_path = dir.path().join("unique-mixed-rtrim.db");
7851 let cx = Cx::new();
7852
7853 let text_column = |name: &str| ColumnInfo {
7854 name: name.to_owned(),
7855 affinity: 'B',
7856 is_ipk: false,
7857 type_name: Some("TEXT".to_owned()),
7858 notnull: false,
7859 unique: false,
7860 default_value: None,
7861 strict_type: None,
7862 generated_expr: None,
7863 generated_stored: None,
7864 collation: None,
7865 conflict_action: None,
7866 };
7867 let int_column = |name: &str, is_ipk: bool| ColumnInfo {
7868 name: name.to_owned(),
7869 affinity: 'D',
7870 is_ipk,
7871 type_name: Some("INTEGER".to_owned()),
7872 notnull: false,
7873 unique: false,
7874 default_value: None,
7875 strict_type: None,
7876 generated_expr: None,
7877 generated_stored: None,
7878 collation: None,
7879 conflict_action: None,
7880 };
7881
7882 let mut db = MemDatabase::new();
7883 db.create_table_at(2, 4);
7884 let table_data = db.get_table_mut(2).unwrap();
7885 for id in 1..=ROW_COUNT {
7886 let base = format!("c{:0>4}", id % CODE_GROUPS);
7897 let occurrence = id / CODE_GROUPS;
7898 let code = if occurrence % 2 == 1 {
7899 format!("{base} ")
7900 } else {
7901 base
7902 };
7903 table_data.insert_row(
7904 id,
7905 vec![
7906 SqliteValue::Integer(id),
7907 SqliteValue::Text(code.into()),
7908 SqliteValue::Integer(id % TIER_COUNT),
7909 SqliteValue::Text(format!("note-{id:0>200}").into()),
7910 ],
7911 );
7912 }
7913
7914 let schema = vec![TableSchema {
7915 name: "catalog".to_owned(),
7916 root_page: 2,
7917 columns: vec![
7918 int_column("id", true),
7919 text_column("code"),
7920 int_column("tier", false),
7921 text_column("note"),
7922 ],
7923 indexes: vec![IndexSchema {
7924 name: "idx_catalog_mixed".to_owned(),
7925 root_page: 3,
7926 columns: vec!["code".to_owned(), "tier".to_owned(), "id".to_owned()],
7927 key_expressions: Vec::new(),
7928 key_sort_directions: vec![
7929 SortDirection::Asc,
7930 SortDirection::Desc,
7931 SortDirection::Asc,
7932 ],
7933 where_clause: None,
7934 is_unique: true,
7935 key_collations: vec![Some("RTRIM".to_owned()), None, None],
7936 conflict_action: None,
7937 }],
7938 strict: false,
7939 without_rowid: false,
7940 primary_key_constraints: vec![vec!["id".to_owned()]],
7941 foreign_keys: Vec::new(),
7942 check_constraints: Vec::new(),
7943 }];
7944 let header = DatabaseHeader {
7945 page_size: DEFAULT_PAGE_SIZE,
7946 schema_cookie: 1,
7947 change_counter: 1,
7948 version_valid_for: 1,
7949 ..DatabaseHeader::default()
7950 };
7951 let mut original_ddl = HashMap::new();
7952 original_ddl.insert(
7953 "catalog".to_owned(),
7954 "CREATE TABLE catalog (id INTEGER PRIMARY KEY, code TEXT, tier INTEGER, note TEXT)"
7955 .to_owned(),
7956 );
7957 original_ddl.insert(
7958 "idx_catalog_mixed".to_owned(),
7959 "CREATE UNIQUE INDEX idx_catalog_mixed \
7960 ON catalog(code COLLATE RTRIM, tier DESC, id)"
7961 .to_owned(),
7962 );
7963
7964 persist_to_sqlite_with_header_and_master_entries(
7965 &cx,
7966 &db_path,
7967 &schema,
7968 &db,
7969 &header,
7970 &[],
7971 &original_ddl,
7972 )
7973 .await
7974 .unwrap();
7975
7976 let conn = rusqlite::Connection::open(&db_path).unwrap();
7977
7978 let quick: String = conn
7979 .query_row("PRAGMA quick_check;", [], |row| row.get(0))
7980 .unwrap();
7981 assert_eq!(
7982 quick, "ok",
7983 "stock SQLite quick_check must accept the rebuilt UNIQUE index"
7984 );
7985 let integrity: String = conn
7986 .query_row("PRAGMA integrity_check;", [], |row| row.get(0))
7987 .unwrap();
7988 assert_eq!(
7989 integrity, "ok",
7990 "stock SQLite integrity_check must accept the rebuilt UNIQUE index"
7991 );
7992
7993 let declared_sql: String = conn
7994 .query_row(
7995 "SELECT sql FROM sqlite_master \
7996 WHERE type='index' AND name='idx_catalog_mixed';",
7997 [],
7998 |row| row.get(0),
7999 )
8000 .unwrap();
8001 let lowered = declared_sql.to_ascii_lowercase();
8002 assert!(
8003 lowered.contains("unique") && lowered.contains("rtrim") && lowered.contains("desc"),
8004 "persisted DDL must keep UNIQUE, RTRIM and DESC: {declared_sql}"
8005 );
8006
8007 let indexed_count: i64 = conn
8010 .query_row(
8011 "SELECT count(*) FROM catalog INDEXED BY idx_catalog_mixed;",
8012 [],
8013 |row| row.get(0),
8014 )
8015 .unwrap();
8016 let scanned_count: i64 = conn
8017 .query_row("SELECT count(*) FROM catalog NOT INDEXED;", [], |row| {
8018 row.get(0)
8019 })
8020 .unwrap();
8021 assert_eq!(scanned_count, ROW_COUNT, "fixture must persist every row");
8022 assert_eq!(
8023 indexed_count, scanned_count,
8024 "index must contain exactly one entry per table row"
8025 );
8026
8027 let collect = |sql: &str| -> Vec<i64> {
8037 let mut stmt = conn.prepare(sql).unwrap();
8038 stmt.query_map([], |row| row.get::<_, i64>(0))
8039 .unwrap()
8040 .collect::<std::result::Result<Vec<_>, _>>()
8041 .unwrap()
8042 };
8043
8044 let rtrim_scan = collect(
8051 "SELECT id FROM catalog NOT INDEXED \
8052 ORDER BY code COLLATE RTRIM, tier DESC, id",
8053 );
8054 let binary_scan = collect(
8055 "SELECT id FROM catalog NOT INDEXED \
8056 ORDER BY code COLLATE BINARY, tier DESC, id",
8057 );
8058 assert_ne!(
8059 rtrim_scan, binary_scan,
8060 "fixture must distinguish RTRIM from BINARY, otherwise a BINARY \
8061 rebuild would satisfy the index-order assertion below"
8062 );
8063 let both_spellings: i64 = conn
8064 .query_row(
8065 "SELECT count(*) FROM (SELECT rtrim(code) AS b FROM catalog \
8066 GROUP BY b HAVING count(DISTINCT code) > 1);",
8067 [],
8068 |row| row.get(0),
8069 )
8070 .unwrap();
8071 assert!(
8072 both_spellings > 0,
8073 "at least one base must appear both trimmed and untrimmed"
8074 );
8075
8076 let forced = collect(
8077 "SELECT id FROM catalog INDEXED BY idx_catalog_mixed \
8078 ORDER BY code COLLATE RTRIM, tier DESC, id",
8079 );
8080 assert_eq!(
8081 forced.len(),
8082 usize::try_from(ROW_COUNT).unwrap(),
8083 "the forced index traversal must visit every row"
8084 );
8085 assert_eq!(
8086 forced, rtrim_scan,
8087 "forced UNIQUE mixed-direction RTRIM index traversal must match the table scan"
8088 );
8089 });
8090 }
8091
8092 #[test]
8118 fn test_persist_to_sqlite_refuses_unresolvable_index_collation() {
8119 asupersync::test_utils::run_test(|| async {
8120 let dir = tempfile::tempdir().unwrap();
8121 let db_path = dir.path().join("unresolvable-collation.db");
8122 let cx = Cx::new();
8123
8124 let mut db = MemDatabase::new();
8125 db.create_table_at(2, 2);
8126 let table_data = db.get_table_mut(2).unwrap();
8127 for id in 1..=8_i64 {
8128 table_data.insert_row(
8129 id,
8130 vec![
8131 SqliteValue::Integer(id),
8132 SqliteValue::Text(format!("v{id}").into()),
8133 ],
8134 );
8135 }
8136
8137 let schema = vec![TableSchema {
8138 name: "t".to_owned(),
8139 root_page: 2,
8140 columns: vec![
8141 ColumnInfo {
8142 name: "id".to_owned(),
8143 affinity: 'D',
8144 is_ipk: true,
8145 type_name: Some("INTEGER".to_owned()),
8146 notnull: false,
8147 unique: false,
8148 default_value: None,
8149 strict_type: None,
8150 generated_expr: None,
8151 generated_stored: None,
8152 collation: None,
8153 conflict_action: None,
8154 },
8155 ColumnInfo {
8156 name: "label".to_owned(),
8157 affinity: 'B',
8158 is_ipk: false,
8159 type_name: Some("TEXT".to_owned()),
8160 notnull: false,
8161 unique: false,
8162 default_value: None,
8163 strict_type: None,
8164 generated_expr: None,
8165 generated_stored: None,
8166 collation: None,
8167 conflict_action: None,
8168 },
8169 ],
8170 indexes: vec![IndexSchema {
8171 name: "idx_t_label_custom".to_owned(),
8172 root_page: 3,
8173 columns: vec!["label".to_owned()],
8174 key_expressions: Vec::new(),
8175 key_sort_directions: vec![SortDirection::Asc],
8176 where_clause: None,
8177 is_unique: false,
8178 key_collations: vec![Some("MYCOLL".to_owned())],
8179 conflict_action: None,
8180 }],
8181 strict: false,
8182 without_rowid: false,
8183 primary_key_constraints: vec![vec!["id".to_owned()]],
8184 foreign_keys: Vec::new(),
8185 check_constraints: Vec::new(),
8186 }];
8187 let header = DatabaseHeader {
8188 page_size: DEFAULT_PAGE_SIZE,
8189 schema_cookie: 1,
8190 change_counter: 1,
8191 version_valid_for: 1,
8192 ..DatabaseHeader::default()
8193 };
8194 let mut original_ddl = HashMap::new();
8195 original_ddl.insert(
8196 "t".to_owned(),
8197 "CREATE TABLE t (id INTEGER PRIMARY KEY, label TEXT)".to_owned(),
8198 );
8199 original_ddl.insert(
8200 "idx_t_label_custom".to_owned(),
8201 "CREATE INDEX idx_t_label_custom ON t(label COLLATE MYCOLL)".to_owned(),
8202 );
8203
8204 let error = persist_to_sqlite_with_header_and_master_entries(
8205 &cx,
8206 &db_path,
8207 &schema,
8208 &db,
8209 &header,
8210 &[],
8211 &original_ddl,
8212 )
8213 .await
8214 .expect_err("an unresolvable index collation must fail closed");
8215 let rendered = error.to_string();
8216 assert!(
8217 rendered.contains("MYCOLL") && rendered.contains("contradict its own declaration"),
8218 "refusal must name the unresolvable collation and why it is refused: {rendered}"
8219 );
8220 assert!(
8223 matches!(error, FrankenError::NotImplemented(_)),
8224 "refusal must be typed as NotImplemented, found {error:?}"
8225 );
8226
8227 let candidate_exists_after_refusal = db_path.exists();
8234
8235 let mut ok_schema = schema;
8242 ok_schema[0].indexes[0].key_collations = vec![Some("NOCASE".to_owned())];
8243 let mut ok_ddl = HashMap::new();
8244 ok_ddl.insert(
8245 "t".to_owned(),
8246 "CREATE TABLE t (id INTEGER PRIMARY KEY, label TEXT)".to_owned(),
8247 );
8248 ok_ddl.insert(
8249 "idx_t_label_custom".to_owned(),
8250 "CREATE INDEX idx_t_label_custom ON t(label COLLATE NOCASE)".to_owned(),
8251 );
8252 let ok_path = dir.path().join("resolvable-collation.db");
8253 persist_to_sqlite_with_header_and_master_entries(
8254 &cx,
8255 &ok_path,
8256 &ok_schema,
8257 &db,
8258 &header,
8259 &[],
8260 &ok_ddl,
8261 )
8262 .await
8263 .expect("a built-in collation must still rebuild");
8264 let conn = rusqlite::Connection::open(&ok_path).unwrap();
8265 let integrity: String = conn
8266 .query_row("PRAGMA integrity_check;", [], |row| row.get(0))
8267 .unwrap();
8268 assert_eq!(integrity, "ok");
8269 let declared: String = conn
8270 .query_row(
8271 "SELECT sql FROM sqlite_master \
8272 WHERE type='index' AND name='idx_t_label_custom';",
8273 [],
8274 |row| row.get(0),
8275 )
8276 .unwrap();
8277 assert!(
8278 declared.to_ascii_uppercase().contains("NOCASE")
8279 && !declared.to_ascii_uppercase().contains("MYCOLL"),
8280 "persisted DDL must declare the collation actually built: {declared}"
8281 );
8282
8283 assert!(
8286 candidate_exists_after_refusal,
8287 "refusal leaves the partial candidate for the caller's reservation to remove; \
8288 if this now fails, cleanup ownership moved into the persist path and the \
8289 comment above it must be updated"
8290 );
8291 });
8292 }
8293
8294 #[test]
8295 fn test_overwrite_existing_file() {
8296 asupersync::test_utils::run_test(|| async {
8297 let dir = tempfile::tempdir().unwrap();
8298 let db_path = dir.path().join("overwrite.db");
8299
8300 let (schema, db) = make_test_schema_and_db();
8302 persist_test_db(&db_path, &schema, &db, 0, 0).await.unwrap();
8303
8304 persist_test_db(&db_path, &[], &MemDatabase::new(), 0, 0)
8306 .await
8307 .unwrap();
8308
8309 let loaded = load_test_db(&db_path).await.unwrap();
8310 assert!(loaded.schema.is_empty());
8311 });
8312 }
8313
8314 #[test]
8315 fn test_load_from_sqlite_keeps_materialized_virtual_tables_with_real_root_page() {
8316 asupersync::test_utils::run_test(|| async {
8317 let dir = tempfile::tempdir().unwrap();
8318 let db_path = dir.path().join("materialized_vtab_load.db");
8319 let db_str = db_path.to_string_lossy().to_string();
8320
8321 {
8322 let conn = crate::connection::Connection::open(&db_str).await.unwrap();
8323 conn.execute(
8324 "CREATE VIRTUAL TABLE docs USING fts5(subject, body, tokenize='porter')",
8325 )
8326 .await
8327 .unwrap();
8328 conn.execute(
8329 "INSERT INTO docs(rowid, subject, body) VALUES (1, 'Hello', 'Rust world')",
8330 )
8331 .await
8332 .unwrap();
8333 conn.execute(
8334 "INSERT INTO docs(rowid, subject, body) VALUES (2, 'Other', 'Nothing')",
8335 )
8336 .await
8337 .unwrap();
8338 conn.close().await.unwrap();
8339 }
8340
8341 let loaded = load_test_db(&db_path).await.unwrap();
8342 assert!(
8350 loaded
8351 .schema
8352 .iter()
8353 .all(|table| !table.name.eq_ignore_ascii_case("docs")),
8354 "rootpage=0 FTS5 virtual-table catalog row must be skipped by the low-level loader"
8355 );
8356
8357 let content = loaded
8360 .schema
8361 .iter()
8362 .find(|table| table.name.eq_ignore_ascii_case("docs_content"))
8363 .expect("FTS5 content shadow table should survive direct load");
8364 let content_columns: Vec<&str> = content
8365 .columns
8366 .iter()
8367 .map(|column| column.name.as_str())
8368 .collect();
8369 assert_eq!(content_columns, vec!["id", "c0", "c1"]);
8370 assert!(
8371 content.root_page > 0,
8372 "the _content shadow table is a real positive-rootpage b-tree"
8373 );
8374 let mem_table = loaded
8375 .db
8376 .get_table(content.root_page)
8377 .expect("loaded content shadow table should exist in MemDatabase");
8378 let rows: Vec<_> = mem_table.iter_rows().collect();
8382 assert_eq!(rows.len(), 2);
8383 assert_eq!(rows[0].0, 1);
8384 assert_eq!(rows[0].1[0], SqliteValue::Integer(1));
8385 assert_eq!(rows[0].1[1], SqliteValue::Text("Hello".into()));
8386 assert_eq!(rows[0].1[2], SqliteValue::Text("Rust world".into()));
8387 assert_eq!(rows[1].0, 2);
8388 assert_eq!(rows[1].1[0], SqliteValue::Integer(2));
8389 assert_eq!(rows[1].1[1], SqliteValue::Text("Other".into()));
8390 assert_eq!(rows[1].1[2], SqliteValue::Text("Nothing".into()));
8391 });
8392 }
8393
8394 #[test]
8395 fn test_load_from_sqlite_rejects_non_virtual_table_with_rootpage_zero() {
8396 asupersync::test_utils::run_test(|| async {
8397 let dir = tempfile::tempdir().unwrap();
8398 let db_path = dir.path().join("compat_corrupt_rootpage_zero.db");
8399
8400 {
8401 let conn = rusqlite::Connection::open(&db_path).unwrap();
8402 conn.execute_batch(
8403 r"
8404 CREATE TABLE docs (id INTEGER PRIMARY KEY, title TEXT);
8405 INSERT INTO docs VALUES (1, 'hello');
8406 PRAGMA writable_schema = ON;
8407 UPDATE sqlite_master SET rootpage = 0 WHERE name = 'docs';
8408 PRAGMA writable_schema = OFF;
8409 ",
8410 )
8411 .unwrap();
8412 }
8413
8414 let err = match load_test_db(&db_path).await {
8415 Ok(_) => panic!("corrupt rootpage should fail load"),
8416 Err(err) => err,
8417 };
8418 let message = err.to_string();
8419 assert!(
8420 message.contains("rootpage 0") || message.contains("root page"),
8421 "unexpected load error: {message}"
8422 );
8423 });
8424 }
8425
8426 #[test]
8427 fn test_load_from_sqlite_rejects_negative_rootpage() {
8428 asupersync::test_utils::run_test(|| async {
8429 let dir = tempfile::tempdir().unwrap();
8430 let db_path = dir.path().join("compat_corrupt_rootpage_negative.db");
8431
8432 {
8433 let conn = rusqlite::Connection::open(&db_path).unwrap();
8434 conn.execute_batch(
8435 r"
8436 CREATE TABLE docs (id INTEGER PRIMARY KEY, title TEXT);
8437 INSERT INTO docs VALUES (1, 'hello');
8438 PRAGMA writable_schema = ON;
8439 UPDATE sqlite_master SET rootpage = -7 WHERE name = 'docs';
8440 PRAGMA writable_schema = OFF;
8441 ",
8442 )
8443 .unwrap();
8444 }
8445
8446 let err = match load_test_db(&db_path).await {
8447 Ok(_) => panic!("negative rootpage should fail load"),
8448 Err(err) => err,
8449 };
8450 let message = err.to_string();
8451 assert!(
8452 message.contains("rootpage -7") || message.contains("invalid rootpage"),
8453 "unexpected load error: {message}"
8454 );
8455 });
8456 }
8457
8458 #[test]
8459 fn test_load_from_sqlite_rejects_rootpage_above_supported_range() {
8460 asupersync::test_utils::run_test(|| async {
8461 let dir = tempfile::tempdir().unwrap();
8462 let db_path = dir.path().join("compat_corrupt_rootpage_large.db");
8463
8464 {
8465 let conn = rusqlite::Connection::open(&db_path).unwrap();
8466 conn.execute_batch(
8467 r"
8468 CREATE TABLE docs (id INTEGER PRIMARY KEY, title TEXT);
8469 INSERT INTO docs VALUES (1, 'hello');
8470 PRAGMA writable_schema = ON;
8471 UPDATE sqlite_master SET rootpage = 2147483648 WHERE name = 'docs';
8472 PRAGMA writable_schema = OFF;
8473 ",
8474 )
8475 .unwrap();
8476 }
8477
8478 let err = match load_test_db(&db_path).await {
8479 Ok(_) => panic!("oversized rootpage should fail load"),
8480 Err(err) => err,
8481 };
8482 let message = err.to_string();
8483 assert!(
8484 message.contains("supported range")
8485 || message.contains("out-of-range")
8486 || message.contains("2147483648"),
8487 "unexpected load error: {message}"
8488 );
8489 });
8490 }
8491
8492 #[test]
8493 fn test_load_from_sqlite_rejects_index_rootpage_above_supported_range() {
8494 asupersync::test_utils::run_test(|| async {
8495 let dir = tempfile::tempdir().unwrap();
8496 let db_path = dir.path().join("compat_corrupt_index_rootpage_large.db");
8497
8498 {
8499 let conn = rusqlite::Connection::open(&db_path).unwrap();
8500 conn.execute_batch(
8501 r"
8502 CREATE TABLE docs (id INTEGER PRIMARY KEY, title TEXT);
8503 CREATE INDEX docs_title_idx ON docs(title);
8504 PRAGMA writable_schema = ON;
8505 UPDATE sqlite_master SET rootpage = 2147483648 WHERE name = 'docs_title_idx';
8506 PRAGMA writable_schema = OFF;
8507 ",
8508 )
8509 .unwrap();
8510 }
8511
8512 let err = match load_test_db(&db_path).await {
8513 Ok(_) => panic!("oversized index rootpage should fail load"),
8514 Err(err) => err,
8515 };
8516 let message = err.to_string();
8517 assert!(
8518 message.contains("docs_title_idx") && message.contains("2147483648"),
8519 "unexpected load error: {message}"
8520 );
8521 });
8522 }
8523
8524 #[test]
8525 fn test_load_from_sqlite_rejects_explicit_index_with_missing_key_column() {
8526 asupersync::test_utils::run_test(|| async {
8527 let dir = tempfile::tempdir().unwrap();
8528 let db_path = dir.path().join("compat_corrupt_index_key_column.db");
8529
8530 {
8531 let sqlite = rusqlite::Connection::open(&db_path).unwrap();
8532 sqlite
8533 .execute_batch(
8534 r"
8535 CREATE TABLE docs (id INTEGER PRIMARY KEY, title TEXT);
8536 CREATE INDEX docs_title_idx ON docs(title);
8537 PRAGMA writable_schema = ON;
8538 UPDATE sqlite_master
8539 SET sql = 'CREATE INDEX docs_title_idx ON docs(missing_title)'
8540 WHERE name = 'docs_title_idx';
8541 PRAGMA writable_schema = OFF;
8542 ",
8543 )
8544 .unwrap();
8545 }
8546
8547 let error = load_test_db(&db_path)
8548 .await
8549 .expect_err("compat reload must reject an unresolved explicit-index key");
8550 assert!(matches!(&error, FrankenError::DatabaseCorrupt { .. }));
8551 let message = error.to_string();
8552 assert!(
8553 message.contains("docs_title_idx") && message.contains("missing_title"),
8554 "unexpected malformed-index compat-load error: {message}"
8555 );
8556 });
8557 }
8558
8559 #[test]
8560 fn test_load_from_sqlite_rejects_schema_qualified_persisted_create_index_sql() {
8561 asupersync::test_utils::run_test(|| async {
8562 let dir = tempfile::tempdir().unwrap();
8563 let db_path = dir.path().join("compat_schema_qualified_index_sql.db");
8564 {
8565 let sqlite = rusqlite::Connection::open(&db_path).unwrap();
8566 sqlite
8567 .execute_batch(
8568 r"
8569 CREATE TABLE docs (id INTEGER PRIMARY KEY, title TEXT);
8570 CREATE INDEX docs_title_idx ON docs(title);
8571 PRAGMA writable_schema = ON;
8572 UPDATE sqlite_master
8573 SET sql = 'CREATE INDEX main.docs_title_idx ON docs(title)'
8574 WHERE name = 'docs_title_idx';
8575 PRAGMA writable_schema = OFF;
8576 ",
8577 )
8578 .unwrap();
8579 }
8580
8581 let error = load_test_db(&db_path)
8582 .await
8583 .expect_err("compat load must reject schema-qualified index SQL");
8584 assert!(matches!(&error, FrankenError::DatabaseCorrupt { .. }));
8585 let message = error.to_string();
8586 assert!(
8587 message.contains("docs_title_idx") && message.contains("schema-qualified"),
8588 "unexpected schema-qualified-index compat-load error: {message}"
8589 );
8590 });
8591 }
8592
8593 #[test]
8594 fn test_load_from_sqlite_rejects_known_invalid_index_functions() {
8595 asupersync::test_utils::run_test(|| async {
8596 let dir = tempfile::tempdir().unwrap();
8597 for (case, create_sql, expected) in [
8598 (
8599 "random",
8600 "CREATE INDEX docs_title_idx ON docs(random())",
8601 "non-deterministic",
8602 ),
8603 (
8604 "aggregate",
8605 "CREATE INDEX docs_title_idx ON docs(sum(title))",
8606 "aggregate",
8607 ),
8608 (
8609 "wrong_arity",
8610 "CREATE INDEX docs_title_idx ON docs(lower(title, title))",
8611 "wrong number of arguments",
8612 ),
8613 (
8614 "current_timestamp",
8615 "CREATE INDEX docs_title_idx ON docs(CURRENT_TIMESTAMP)",
8616 "non-deterministic",
8617 ),
8618 ] {
8619 let db_path = dir
8620 .path()
8621 .join(format!("compat_invalid_index_function_{case}.db"));
8622 {
8623 let sqlite = rusqlite::Connection::open(&db_path).unwrap();
8624 sqlite
8625 .execute_batch(
8626 r"
8627 CREATE TABLE docs (id INTEGER PRIMARY KEY, title TEXT);
8628 CREATE INDEX docs_title_idx ON docs(title);
8629 PRAGMA writable_schema = ON;
8630 ",
8631 )
8632 .unwrap();
8633 sqlite
8634 .execute(
8635 "UPDATE sqlite_master SET sql = ?1 WHERE name = 'docs_title_idx'",
8636 [create_sql],
8637 )
8638 .unwrap();
8639 sqlite
8640 .execute_batch("PRAGMA writable_schema = OFF;")
8641 .unwrap();
8642 }
8643
8644 let error = load_test_db(&db_path)
8645 .await
8646 .expect_err("known-invalid persisted index function must fail compat load");
8647 assert!(matches!(&error, FrankenError::DatabaseCorrupt { .. }));
8648 let message = error.to_string().to_ascii_lowercase();
8649 assert!(
8650 message.contains("docs_title_idx") && message.contains(expected),
8651 "unexpected compat persisted-function error for `{create_sql}`: {error}"
8652 );
8653 }
8654 });
8655 }
8656
8657 #[test]
8658 fn test_load_from_sqlite_rejects_invalid_utf8_in_sqlite_master_record() {
8659 asupersync::test_utils::run_test(|| async {
8660 let dir = tempfile::tempdir().unwrap();
8661 let db_path = dir.path().join("compat_corrupt_master_utf8.db");
8662
8663 {
8664 let conn = rusqlite::Connection::open(&db_path).unwrap();
8665 conn.execute_batch(
8666 r"
8667 CREATE TABLE docs (id INTEGER PRIMARY KEY, title TEXT);
8668 INSERT INTO docs VALUES (1, 'hello');
8669 PRAGMA writable_schema = ON;
8670 UPDATE sqlite_master
8671 SET sql = CAST(x'FF' AS TEXT)
8672 WHERE name = 'docs';
8673 PRAGMA writable_schema = OFF;
8674 ",
8675 )
8676 .unwrap();
8677 }
8678
8679 let err = load_test_db(&db_path)
8686 .await
8687 .expect_err("unparseable sqlite_master SQL must fail schema load");
8688 assert!(matches!(&err, FrankenError::DatabaseCorrupt { .. }));
8689 let message = err.to_string();
8690 assert!(
8691 message.contains("could not parse CREATE TABLE SQL")
8692 || message.to_ascii_lowercase().contains("malformed"),
8693 "unexpected load error class: {message}"
8694 );
8695 });
8696 }
8697
8698 #[test]
8699 fn test_load_from_sqlite_rejects_invalid_utf8_in_table_record() {
8700 asupersync::test_utils::run_test(|| async {
8701 let dir = tempfile::tempdir().unwrap();
8709 let db_path = dir.path().join("compat_corrupt_table_utf8.db");
8710
8711 {
8712 let conn = rusqlite::Connection::open(&db_path).unwrap();
8713 conn.execute_batch(
8714 r"
8715 CREATE TABLE docs (title TEXT);
8716 INSERT INTO docs VALUES (CAST(x'FF' AS TEXT));
8717 ",
8718 )
8719 .unwrap();
8720 }
8721
8722 let loaded = load_test_db(&db_path)
8723 .await
8724 .expect("invalid-UTF-8 TEXT must load byte-preserved, matching stock");
8725 let table = loaded
8726 .db
8727 .tables
8728 .values()
8729 .next()
8730 .expect("docs table present");
8731 let (_rowid, values) = table.iter_rows().next().expect("docs row present");
8732 match values.first() {
8733 Some(SqliteValue::Text(text)) => {
8734 assert_eq!(
8735 text.as_bytes_direct(),
8736 &[0xFF],
8737 "raw TEXT byte must round-trip exactly like stock"
8738 );
8739 }
8740 other => panic!("expected preserved TEXT value, got {other:?}"),
8741 }
8742 });
8743 }
8744}