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