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