1pub mod catalog;
2pub mod codec;
3pub mod page;
4
5use std::{collections::HashSet, path::Path};
6
7use crate::{
8 block::Block,
9 document::Document,
10 error::MqdbError,
11 storage::{
12 catalog::{CatalogEntry, CustomTableEntry, read_catalog, write_catalog},
13 codec::{decode_block, decode_table_rows, encode_block, encode_table_rows},
14 page::{
15 PAGE_BODY_SIZE, PAGE_HEADER_SIZE, PAGE_TYPE_BLOCK_DATA, PAGE_TYPE_CATALOG,
16 PAGE_TYPE_INDEX, PAGE_TYPE_OVERFLOW, PAGE_TYPE_TABLE_DATA, PageFile, make_page,
17 parse_page_header,
18 },
19 },
20};
21
22const TABLE_ROW_PAGE_CAPACITY: usize = PAGE_BODY_SIZE - 2;
25
26pub struct Storage {
27 page_file: PageFile,
28}
29
30fn invalid_data(message: impl Into<String>) -> MqdbError {
31 MqdbError::Storage(message.into())
32}
33
34impl Storage {
35 pub fn create(path: &Path) -> Result<Self, MqdbError> {
37 let mut page_file = PageFile::create(path)?;
38 let empty_catalog = 0u32.to_le_bytes();
39 let page = make_page(PAGE_TYPE_CATALOG, 1, 0, &empty_catalog);
40 let page_id = page_file.append_page(&page)?;
41 if page_id != 1 {
42 return Err(invalid_data(format!(
43 "expected catalog page id 1, found {page_id}"
44 )));
45 }
46 Ok(Self { page_file })
47 }
48
49 pub fn open(path: &Path) -> Result<Self, MqdbError> {
51 Ok(Self {
52 page_file: PageFile::open(path)?,
53 })
54 }
55
56 pub fn write_document(&mut self, doc: &Document) -> Result<u32, MqdbError> {
58 let mut bytes = Vec::new();
59 for block in &doc.blocks {
60 bytes.extend_from_slice(&encode_block(block));
61 }
62
63 let chunks: Vec<&[u8]> = if bytes.is_empty() {
64 vec![&[]]
65 } else {
66 bytes.chunks(PAGE_BODY_SIZE).collect()
67 };
68
69 let placeholder = make_page(PAGE_TYPE_BLOCK_DATA, 0, 0, &[]);
70 let mut page_ids = Vec::with_capacity(chunks.len());
71 for _ in 0..chunks.len() {
72 page_ids.push(self.page_file.append_page(&placeholder)?);
73 }
74
75 for (index, chunk) in chunks.iter().enumerate() {
76 let page_id = page_ids[index];
77 let next_page = page_ids.get(index + 1).copied().unwrap_or(0);
78 let page_type = if index == 0 {
79 PAGE_TYPE_BLOCK_DATA
80 } else {
81 PAGE_TYPE_OVERFLOW
82 };
83 let page = make_page(page_type, page_id, next_page, chunk);
84 self.page_file.write_page(page_id, &page)?;
85 }
86
87 page_ids
88 .first()
89 .copied()
90 .ok_or_else(|| invalid_data("document page chain is empty"))
91 }
92
93 pub fn read_blocks(
95 &mut self,
96 first_page: u32,
97 num_blocks: u32,
98 ) -> Result<Vec<Block>, MqdbError> {
99 if num_blocks == 0 {
100 return Ok(Vec::new());
101 }
102
103 let mut bytes = Vec::new();
104 let mut page_id = first_page;
105 let mut visited = HashSet::new();
106 let mut first = true;
107
108 loop {
109 if !visited.insert(page_id) {
110 return Err(invalid_data("block page chain contains a cycle"));
111 }
112
113 let page = self.page_file.read_page(page_id)?;
114 let (page_type, _, stored_page_id, next_page) = parse_page_header(&page);
115 let expected_type = if first {
116 PAGE_TYPE_BLOCK_DATA
117 } else {
118 PAGE_TYPE_OVERFLOW
119 };
120 if page_type != expected_type {
121 return Err(invalid_data(format!(
122 "unexpected page type {page_type} in block chain; expected {expected_type}"
123 )));
124 }
125 if stored_page_id != page_id {
126 return Err(invalid_data(format!(
127 "block page header mismatch: expected {page_id}, found {stored_page_id}"
128 )));
129 }
130
131 bytes.extend_from_slice(&page[PAGE_HEADER_SIZE..]);
132
133 if next_page == 0 {
134 break;
135 }
136 page_id = next_page;
137 first = false;
138 }
139
140 let mut blocks = Vec::with_capacity(num_blocks as usize);
141 let mut offset = 0usize;
142 for _ in 0..num_blocks {
143 let (block, consumed) = decode_block(&bytes[offset..])?;
144 offset = offset
145 .checked_add(consumed)
146 .ok_or_else(|| invalid_data("block byte offset overflow"))?;
147 blocks.push(block);
148 }
149
150 Ok(blocks)
151 }
152
153 pub fn flush_catalog(
155 &mut self,
156 entries: &[CatalogEntry],
157 custom_tables: &[CustomTableEntry],
158 ) -> Result<(), MqdbError> {
159 write_catalog(&mut self.page_file, entries, custom_tables)?;
160 self.page_file.sync_header()
161 }
162
163 pub fn load_catalog(
165 &mut self,
166 ) -> Result<(Vec<CatalogEntry>, Vec<CustomTableEntry>), MqdbError> {
167 read_catalog(&mut self.page_file)
168 }
169
170 pub fn write_index(&mut self, bytes: &[u8]) -> Result<u32, MqdbError> {
172 let chunks: Vec<&[u8]> = if bytes.is_empty() {
173 vec![&[]]
174 } else {
175 bytes.chunks(PAGE_BODY_SIZE).collect()
176 };
177
178 let placeholder = make_page(PAGE_TYPE_INDEX, 0, 0, &[]);
179 let mut page_ids = Vec::with_capacity(chunks.len());
180 for _ in 0..chunks.len() {
181 page_ids.push(self.page_file.append_page(&placeholder)?);
182 }
183
184 for (i, chunk) in chunks.iter().enumerate() {
185 let page_id = page_ids[i];
186 let next_page = page_ids.get(i + 1).copied().unwrap_or(0);
187 let page_type = if i == 0 {
188 PAGE_TYPE_INDEX
189 } else {
190 PAGE_TYPE_OVERFLOW
191 };
192 let page = make_page(page_type, page_id, next_page, chunk);
193 self.page_file.write_page(page_id, &page)?;
194 }
195
196 page_ids
197 .first()
198 .copied()
199 .ok_or_else(|| invalid_data("empty index page chain"))
200 }
201
202 pub fn read_index_bytes(&mut self, first_page: u32) -> Result<Vec<u8>, MqdbError> {
204 let mut bytes = Vec::new();
205 let mut page_id = first_page;
206 let mut visited = HashSet::new();
207 let mut first = true;
208
209 loop {
210 if !visited.insert(page_id) {
211 return Err(invalid_data("index page chain contains a cycle"));
212 }
213
214 let page = self.page_file.read_page(page_id)?;
215 let (page_type, _, stored_page_id, next_page) = parse_page_header(&page);
216
217 let expected = if first {
218 PAGE_TYPE_INDEX
219 } else {
220 PAGE_TYPE_OVERFLOW
221 };
222 if page_type != expected {
223 return Err(invalid_data(format!(
224 "unexpected page type {page_type} in index chain; expected {expected}"
225 )));
226 }
227 if stored_page_id != page_id {
228 return Err(invalid_data(format!(
229 "index page header mismatch: expected {page_id}, found {stored_page_id}"
230 )));
231 }
232
233 bytes.extend_from_slice(&page[PAGE_HEADER_SIZE..]);
234
235 if next_page == 0 {
236 break;
237 }
238 page_id = next_page;
239 first = false;
240 }
241
242 Ok(bytes)
243 }
244
245 pub fn write_table_rows(&mut self, rows: &[Vec<String>]) -> Result<(u32, u32), MqdbError> {
249 self.write_table_row_chunks(rows, PAGE_TYPE_TABLE_DATA)
250 }
251
252 pub fn append_table_rows(
256 &mut self,
257 tail_page: u32,
258 rows: &[Vec<String>],
259 ) -> Result<u32, MqdbError> {
260 if rows.is_empty() {
261 return Ok(tail_page);
262 }
263
264 let (first_new, last_new) = self.write_table_row_chunks(rows, PAGE_TYPE_OVERFLOW)?;
268 self.relink_next(tail_page, first_new)?;
269 Ok(last_new)
270 }
271
272 fn write_table_row_chunks(
280 &mut self,
281 rows: &[Vec<String>],
282 head_page_type: u32,
283 ) -> Result<(u32, u32), MqdbError> {
284 if rows.is_empty() {
285 return Ok((0, 0));
286 }
287
288 let bytes = encode_table_rows(rows);
289 let chunks: Vec<&[u8]> = bytes.chunks(TABLE_ROW_PAGE_CAPACITY).collect();
290
291 let placeholder = make_page(PAGE_TYPE_OVERFLOW, 0, 0, &[]);
292 let mut page_ids = Vec::with_capacity(chunks.len());
293 for _ in 0..chunks.len() {
294 page_ids.push(self.page_file.append_page(&placeholder)?);
295 }
296
297 for (index, chunk) in chunks.iter().enumerate() {
298 let page_id = page_ids[index];
299 let next_page = page_ids.get(index + 1).copied().unwrap_or(0);
300 let page_type = if index == 0 {
301 head_page_type
302 } else {
303 PAGE_TYPE_OVERFLOW
304 };
305 let mut body = Vec::with_capacity(2 + chunk.len());
306 body.extend_from_slice(&(chunk.len() as u16).to_le_bytes());
307 body.extend_from_slice(chunk);
308 let page = make_page(page_type, page_id, next_page, &body);
309 self.page_file.write_page(page_id, &page)?;
310 }
311
312 let first = *page_ids.first().expect("checked non-empty above");
313 let last = *page_ids.last().expect("checked non-empty above");
314 Ok((first, last))
315 }
316
317 fn relink_next(&mut self, page_id: u32, next_page: u32) -> Result<(), MqdbError> {
321 let page = self.page_file.read_page(page_id)?;
322 let (page_type, _, stored_page_id, _) = parse_page_header(&page);
323 let body = &page[PAGE_HEADER_SIZE..];
324 let relinked = make_page(page_type, stored_page_id, next_page, body);
325 self.page_file.write_page(page_id, &relinked)
326 }
327
328 pub fn read_table_rows(
330 &mut self,
331 first_page: u32,
332 num_rows: u32,
333 num_cols: usize,
334 ) -> Result<Vec<Vec<String>>, MqdbError> {
335 if first_page == 0 || num_rows == 0 {
336 return Ok(Vec::new());
337 }
338
339 let mut bytes = Vec::new();
340 let mut page_id = first_page;
341 let mut visited = HashSet::new();
342 let mut first = true;
343
344 loop {
345 if !visited.insert(page_id) {
346 return Err(invalid_data("table row page chain contains a cycle"));
347 }
348
349 let page = self.page_file.read_page(page_id)?;
350 let (page_type, _, stored_page_id, next_page) = parse_page_header(&page);
351 let expected_type = if first {
352 PAGE_TYPE_TABLE_DATA
353 } else {
354 PAGE_TYPE_OVERFLOW
355 };
356 if page_type != expected_type {
357 return Err(invalid_data(format!(
358 "unexpected page type {page_type} in table row chain; expected {expected_type}"
359 )));
360 }
361 if stored_page_id != page_id {
362 return Err(invalid_data(format!(
363 "table row page header mismatch: expected {page_id}, found {stored_page_id}"
364 )));
365 }
366
367 let body = &page[PAGE_HEADER_SIZE..];
368 let chunk_len = usize::from(u16::from_le_bytes([body[0], body[1]]));
369 let chunk_end = chunk_len
370 .checked_add(2)
371 .ok_or_else(|| invalid_data("table row page chunk length overflow"))?;
372 if chunk_end > body.len() {
373 return Err(invalid_data("table row page chunk length out of bounds"));
374 }
375 bytes.extend_from_slice(&body[2..chunk_end]);
376
377 if next_page == 0 {
378 break;
379 }
380 page_id = next_page;
381 first = false;
382 }
383
384 decode_table_rows(&bytes, num_rows as usize, num_cols)
385 }
386}
387
388#[cfg(test)]
389mod tests {
390 use std::{
391 path::{Path, PathBuf},
392 sync::atomic::{AtomicU64, Ordering},
393 time::{SystemTime, UNIX_EPOCH},
394 };
395
396 use super::*;
397 use rstest::rstest;
398
399 use crate::{
400 DocumentStore,
401 block::{BlockType, Properties, PropertyValue, Span},
402 document::{Document, ZoneMaps},
403 storage::{
404 catalog::CatalogEntry,
405 codec::{decode_block, decode_zone_map, encode_block, encode_zone_map},
406 },
407 };
408
409 static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
410
411 fn test_file_path(name: &str) -> PathBuf {
412 let unique = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
413 let timestamp = SystemTime::now()
414 .duration_since(UNIX_EPOCH)
415 .expect("system time before UNIX_EPOCH")
416 .as_nanos();
417 let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
418 .join("target")
419 .join("mq-db-storage-tests");
420 std::fs::create_dir_all(&dir).unwrap();
421 dir.join(format!("{name}-{timestamp}-{unique}.mq-db"))
422 }
423
424 fn cleanup(path: &Path) {
425 let _ = std::fs::remove_file(path);
426 let tmp_path = PathBuf::from(format!("{}.tmp", path.to_string_lossy()));
427 let _ = std::fs::remove_file(tmp_path);
428 }
429
430 fn sample_block(block_type: BlockType, id: u32) -> Block {
431 let mut properties = Properties::new();
432 properties.set("name", format!("block-{id}"));
433 properties.set("count", i64::from(id));
434 properties.set("score", PropertyValue::Float(1.5f64 + f64::from(id)));
435 properties.set("flag", id.is_multiple_of(2));
436 properties.set(
437 "items",
438 PropertyValue::Array(vec![
439 PropertyValue::Null,
440 PropertyValue::String("value".to_string()),
441 PropertyValue::Int(-3),
442 PropertyValue::Float(2.25),
443 PropertyValue::Bool(true),
444 PropertyValue::Array(vec![PropertyValue::String("nested".to_string())]),
445 ]),
446 );
447
448 Block {
449 id,
450 document_id: 7,
451 block_type,
452 content: format!("content-{id}"),
453 span: Some(Span {
454 start_line: 1,
455 start_col: 2,
456 end_line: 3,
457 end_col: 4,
458 }),
459 pre: id * 2,
460 post: id * 2 + 1,
461 properties,
462 }
463 }
464
465 #[test]
466 fn block_codec_round_trip_all_block_types() {
467 let block_types = [
468 BlockType::Heading,
469 BlockType::Paragraph,
470 BlockType::Code,
471 BlockType::List,
472 BlockType::TableCell,
473 BlockType::TableRow,
474 BlockType::TableAlign,
475 BlockType::Blockquote,
476 BlockType::HorizontalRule,
477 BlockType::Html,
478 BlockType::Yaml,
479 BlockType::Toml,
480 BlockType::Math,
481 BlockType::Definition,
482 BlockType::Footnote,
483 ];
484
485 for (index, block_type) in block_types.into_iter().enumerate() {
486 let block = sample_block(block_type, index as u32 + 1);
487 let encoded = encode_block(&block);
488 let (decoded, consumed) = decode_block(&encoded).unwrap();
489 assert_eq!(consumed, encoded.len());
490 assert_eq!(decoded, block);
491 }
492 }
493
494 #[test]
495 fn zone_map_codec_round_trip() {
496 let mut zone_maps = ZoneMaps {
497 max_heading_depth: 4,
498 heading_slugs: ["intro".to_string(), "usage".to_string()]
499 .into_iter()
500 .collect(),
501 heading_contents: ["Intro".to_string(), "Usage".to_string()]
502 .into_iter()
503 .collect(),
504 code_languages: ["rust".to_string(), "python".to_string()]
505 .into_iter()
506 .collect(),
507 frontmatter_keys: ["title".to_string(), "tags".to_string()]
508 .into_iter()
509 .collect(),
510 title: Some("Storage Spec".to_string()),
511 tags: vec!["db".to_string(), "markdown".to_string()],
512 };
513 let encoded = encode_zone_map(&zone_maps);
514 let decoded = decode_zone_map(&encoded).unwrap();
515 assert_eq!(decoded, zone_maps);
516
517 zone_maps.title = None;
518 let encoded_without_title = encode_zone_map(&zone_maps);
519 let decoded_without_title = decode_zone_map(&encoded_without_title).unwrap();
520 assert_eq!(decoded_without_title, zone_maps);
521 }
522
523 #[test]
524 fn storage_round_trip_multi_page_document() {
525 let path = test_file_path("multi-page");
526 cleanup(&path);
527
528 let blocks: Vec<Block> = (0..32)
529 .map(|id| {
530 let mut block = sample_block(BlockType::Paragraph, id + 1);
531 block.content = "x".repeat(PAGE_BODY_SIZE / 2);
532 block.pre = id * 2;
533 block.post = id * 2 + 1;
534 block
535 })
536 .collect();
537 let document = Document::new(1, None, blocks.clone());
538
539 let mut storage = Storage::create(&path).unwrap();
540 let first_page = storage.write_document(&document).unwrap();
541 let catalog_entry = CatalogEntry {
542 document_id: document.id,
543 path: None,
544 first_block_page: first_page,
545 num_blocks: document.blocks.len() as u32,
546 zone_map_bytes: encode_zone_map(&document.zone_maps),
547 index_start_page: 0,
548 };
549 storage.flush_catalog(&[catalog_entry], &[]).unwrap();
550 drop(storage);
551
552 let mut reopened = Storage::open(&path).unwrap();
553 let (catalog, _) = reopened.load_catalog().unwrap();
554 assert_eq!(catalog.len(), 1);
555 assert_eq!(
556 decode_zone_map(&catalog[0].zone_map_bytes).unwrap(),
557 document.zone_maps
558 );
559 let decoded_blocks = reopened
560 .read_blocks(first_page, document.blocks.len() as u32)
561 .unwrap();
562 assert_eq!(decoded_blocks, blocks);
563
564 cleanup(&path);
565 }
566
567 #[test]
568 fn document_store_save_load_round_trip() {
569 let path = test_file_path("store-save-load");
570 cleanup(&path);
571
572 let mut store = DocumentStore::new();
573 store
574 .add_str(
575 "---\ntitle: Demo\ntags: [db, rust]\n---\n# Intro\n\nParagraph\n\n```rust\nfn main() {}\n```\n",
576 )
577 .unwrap();
578 store
579 .add_str("## Usage\n\n- item one\n- item two\n")
580 .unwrap();
581
582 store.save(&path).unwrap();
583 let loaded = DocumentStore::load(&path).unwrap();
584
585 assert_eq!(loaded.len(), store.len());
586 for (l, s) in loaded.documents().iter().zip(store.documents().iter()) {
589 assert_eq!(l.id, s.id);
590 assert_eq!(l.blocks, s.blocks);
591 assert_eq!(l.zone_maps, s.zone_maps);
592 }
593
594 cleanup(&path);
595 }
596
597 #[test]
598 fn persisted_index_round_trip() {
599 use crate::{SqlEngine, indexes::DocumentIndex};
600
601 let path = test_file_path("index-round-trip");
602 cleanup(&path);
603
604 let mut store = DocumentStore::new();
605 store
606 .add_str("# Hello\n\n## Arch\n\nDetails\n\n```rust\ncode\n```\n")
607 .unwrap();
608 store.add_str("## Usage\n\n- item\n").unwrap();
609 store.save(&path).unwrap();
610
611 let mut opened = DocumentStore::open(&path).unwrap();
613 assert!(
614 opened.documents()[0].blocks.is_empty(),
615 "blocks not loaded yet"
616 );
617
618 opened.load_all_blocks().unwrap();
620 opened.load_all_indexes().unwrap();
621
622 assert!(!opened.documents()[0].blocks.is_empty(), "blocks loaded");
623 assert!(opened.get_doc_index(0).is_some(), "index cached");
624
625 for (i, doc) in opened.documents().iter().enumerate() {
627 let from_file = opened.get_doc_index(i).unwrap().clone();
628 let from_blocks = DocumentIndex::build(&doc.blocks);
629 assert_eq!(
630 from_file.to_bytes(),
631 from_blocks.to_bytes(),
632 "index mismatch for doc {i}"
633 );
634 }
635
636 let engine = SqlEngine::new(&opened).unwrap();
638 let out = engine.execute("SELECT count(*) FROM blocks").unwrap();
639 assert!(!out.rows.is_empty());
640
641 cleanup(&path);
642 }
643
644 #[test]
645 fn persisted_index_round_trip_large_block_content() {
646 use crate::indexes::DocumentIndex;
647
648 let path = test_file_path("index-round-trip-large-block");
651 cleanup(&path);
652
653 let big_code = "x".repeat(70_000);
654 let content = format!("# Title\n\n```text\n{big_code}\n```\n");
655
656 let mut store = DocumentStore::new();
657 store.add_str(&content).unwrap();
658 store.save(&path).unwrap();
659
660 let mut opened = DocumentStore::open(&path).unwrap();
661 opened.load_all_blocks().unwrap();
662 opened.load_all_indexes().unwrap();
663
664 let doc = &opened.documents()[0];
665 let from_file = opened.get_doc_index(0).unwrap().clone();
666 let from_blocks = DocumentIndex::build(&doc.blocks);
667 assert_eq!(from_file.to_bytes(), from_blocks.to_bytes());
668
669 cleanup(&path);
670 }
671
672 #[rstest]
673 #[case(BlockType::Heading)]
674 #[case(BlockType::Paragraph)]
675 #[case(BlockType::Code)]
676 #[case(BlockType::List)]
677 #[case(BlockType::TableCell)]
678 #[case(BlockType::TableRow)]
679 #[case(BlockType::TableAlign)]
680 #[case(BlockType::Blockquote)]
681 #[case(BlockType::HorizontalRule)]
682 #[case(BlockType::Html)]
683 #[case(BlockType::Yaml)]
684 #[case(BlockType::Toml)]
685 #[case(BlockType::Math)]
686 #[case(BlockType::Definition)]
687 #[case(BlockType::Footnote)]
688 fn block_codec_round_trip_param(#[case] block_type: BlockType) {
689 let block = sample_block(block_type, 42);
690 let encoded = encode_block(&block);
691 let (decoded, consumed) = decode_block(&encoded).unwrap();
692 assert_eq!(consumed, encoded.len());
693 assert_eq!(decoded, block);
694 }
695
696 #[test]
697 fn table_row_chain_round_trip_across_multiple_appends() {
698 let path = test_file_path("table-row-chain-append");
705 cleanup(&path);
706
707 let mut storage = Storage::create(&path).unwrap();
708 storage.flush_catalog(&[], &[]).unwrap();
709
710 let batch1 = vec![
711 vec!["1".to_string(), "a".to_string()],
712 vec!["2".to_string(), "b".to_string()],
713 ];
714 let batch2 = vec![vec!["3".to_string(), "c".to_string()]];
715 let batch3 = vec![
716 vec!["4".to_string(), "d".to_string()],
717 vec!["5".to_string(), "e".to_string()],
718 ];
719
720 let (first_page, last_page) = storage.write_table_rows(&batch1).unwrap();
721 let last_page = storage.append_table_rows(last_page, &batch2).unwrap();
722 let last_page = storage.append_table_rows(last_page, &batch3).unwrap();
723 assert_ne!(last_page, 0);
724
725 let all_rows = storage.read_table_rows(first_page, 5, 2).unwrap();
726 let expected: Vec<Vec<String>> = batch1.into_iter().chain(batch2).chain(batch3).collect();
727 assert_eq!(all_rows, expected);
728
729 let big_batch: Vec<Vec<String>> = (0..10)
732 .map(|i| vec![i.to_string(), "x".repeat(PAGE_BODY_SIZE)])
733 .collect();
734 let last_page = storage.append_table_rows(last_page, &big_batch).unwrap();
735 assert_ne!(last_page, 0);
736
737 let all_rows = storage.read_table_rows(first_page, 15, 2).unwrap();
738 let expected: Vec<Vec<String>> = expected.into_iter().chain(big_batch).collect();
739 assert_eq!(all_rows, expected);
740
741 cleanup(&path);
742 }
743
744 #[test]
745 fn custom_table_round_trip() {
746 let path = test_file_path("custom-table-round-trip");
747 cleanup(&path);
748
749 let mut store = DocumentStore::new();
750 store.add_str("# Hello\n\nWorld\n").unwrap();
751 store.save(&path).unwrap();
752
753 let mut opened = DocumentStore::open(&path).unwrap();
755 opened.load_all_blocks().unwrap();
756 opened.load_all_indexes().unwrap();
757 let engine = crate::SqlEngine::new(&opened).unwrap();
758 engine
759 .execute("CREATE TABLE notes (id TEXT, body TEXT)")
760 .unwrap();
761 engine
762 .execute("INSERT INTO notes VALUES ('1', 'hello')")
763 .unwrap();
764 engine
765 .execute("INSERT INTO notes VALUES ('2', 'world')")
766 .unwrap();
767 drop(engine);
768 drop(opened);
769
770 let mut reopened = DocumentStore::open(&path).unwrap();
772 reopened.load_all_blocks().unwrap();
773 reopened.load_all_indexes().unwrap();
774 let engine2 = crate::SqlEngine::new(&reopened).unwrap();
775 let out = engine2
776 .execute("SELECT body FROM notes WHERE id = '1'")
777 .unwrap();
778 assert_eq!(out.rows.len(), 1);
779 assert_eq!(out.rows[0][0], "hello");
780 let all = engine2.execute("SELECT * FROM notes").unwrap();
781 assert_eq!(all.rows.len(), 2);
782
783 cleanup(&path);
784 }
785
786 #[rstest]
787 #[case(Some("My Title"))]
788 #[case(None)]
789 #[case(Some("title with spaces and unicode: こんにちは"))]
790 fn zone_map_title_round_trip_param(#[case] title: Option<&str>) {
791 let zone_maps = ZoneMaps {
792 max_heading_depth: 3,
793 heading_slugs: ["intro", "usage"].iter().map(|s| s.to_string()).collect(),
794 heading_contents: ["Intro", "Usage"].iter().map(|s| s.to_string()).collect(),
795 code_languages: ["rust", "python"].iter().map(|s| s.to_string()).collect(),
796 frontmatter_keys: ["title", "tags"].iter().map(|s| s.to_string()).collect(),
797 title: title.map(|s| s.to_string()),
798 tags: vec!["db".to_string(), "markdown".to_string()],
799 };
800 let encoded = encode_zone_map(&zone_maps);
801 let decoded = decode_zone_map(&encoded).unwrap();
802 assert_eq!(decoded, zone_maps);
803 }
804}