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