Skip to main content

mf_search/
backend_sqlite.rs

1use crate::model::IndexDoc;
2use anyhow::Result;
3use rbatis::{executor::Executor, RBatis};
4use rbdc_sqlite::Driver;
5use rbs::Value;
6use serde::{Deserialize, Serialize};
7use std::{
8    collections::HashMap,
9    path::{Path, PathBuf},
10    sync::Arc,
11};
12
13const SCHEMA_SQL: &str = r#"
14    PRAGMA journal_mode=WAL;
15    PRAGMA synchronous=NORMAL;
16    PRAGMA cache_size=-64000;
17    PRAGMA temp_store=MEMORY;
18
19    CREATE TABLE IF NOT EXISTS nodes (
20        id TEXT PRIMARY KEY,
21        node_type TEXT NOT NULL,
22        parent_id TEXT,
23        path TEXT NOT NULL,
24        marks TEXT,
25        marks_json TEXT,
26        attrs TEXT,
27        attrs_json TEXT,
28        text TEXT,
29        order_i64 INTEGER,
30        created_at_i64 INTEGER,
31        updated_at_i64 INTEGER
32    );
33
34    CREATE INDEX IF NOT EXISTS idx_node_type ON nodes(node_type);
35    CREATE INDEX IF NOT EXISTS idx_parent_id ON nodes(parent_id);
36    CREATE INDEX IF NOT EXISTS idx_path ON nodes(path);
37    CREATE INDEX IF NOT EXISTS idx_created_at ON nodes(created_at_i64);
38    CREATE INDEX IF NOT EXISTS idx_updated_at ON nodes(updated_at_i64);
39    CREATE INDEX IF NOT EXISTS idx_order ON nodes(order_i64);
40
41    CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
42        id UNINDEXED,
43        text,
44        content='nodes',
45        content_rowid='rowid'
46    );
47
48    CREATE TRIGGER IF NOT EXISTS nodes_ai AFTER INSERT ON nodes BEGIN
49        INSERT INTO nodes_fts(rowid, id, text)
50        VALUES (new.rowid, new.id, new.text);
51    END;
52
53    CREATE TRIGGER IF NOT EXISTS nodes_ad AFTER DELETE ON nodes BEGIN
54        INSERT INTO nodes_fts(nodes_fts, rowid, id, text)
55        VALUES('delete', old.rowid, old.id, old.text);
56    END;
57
58    CREATE TRIGGER IF NOT EXISTS nodes_au AFTER UPDATE ON nodes BEGIN
59        INSERT INTO nodes_fts(nodes_fts, rowid, id, text)
60        VALUES('delete', old.rowid, old.id, old.text);
61        INSERT INTO nodes_fts(rowid, id, text)
62        VALUES (new.rowid, new.id, new.text);
63    END;
64"#;
65
66/// SQLite 后端索引增量变更
67#[derive(Debug, Clone)]
68pub enum IndexMutation {
69    Add(IndexDoc),
70    Upsert(IndexDoc),
71    DeleteById(String),
72    DeleteManyById(Vec<String>),
73}
74
75/// 查询条件
76#[derive(Debug, Clone, Default)]
77pub struct SearchQuery {
78    /// 全文查询(走 FTS5)
79    pub text: Option<String>,
80    /// 节点类型精确匹配
81    pub node_type: Option<String>,
82    /// 父节点精确匹配
83    pub parent_id: Option<String>,
84    /// 路径前缀匹配(如 "/root/section")
85    pub path_prefix: Option<String>,
86    /// 标记类型(简单查询:只检查是否包含某类型 marks)
87    pub marks: Vec<String>,
88    /// 带属性的精确 mark 查询(mark_type, attr_key, attr_value)
89    pub mark_attrs: Vec<(String, String, String)>,
90    /// JSON 属性匹配(支持复杂查询)
91    pub attrs: Vec<(String, String)>,
92    /// 返回条数限制
93    pub limit: usize,
94    /// 偏移量
95    pub offset: usize,
96    /// 排序字段
97    pub sort_by: Option<String>,
98    /// 排序方向 true=升序,false=降序
99    pub sort_asc: bool,
100    /// 树形查询:返回子树所有节点
101    pub include_descendants: bool,
102    /// 范围查询
103    pub range_field: Option<String>,
104    pub range_min: Option<i64>,
105    pub range_max: Option<i64>,
106}
107
108/// SQLite 后端实现
109pub struct SqliteBackend {
110    pool: Arc<RBatis>,
111    index_dir: PathBuf,
112    _temp_dir: Option<tempfile::TempDir>,
113}
114
115impl SqliteBackend {
116    /// 在指定目录创建或打开数据库
117    pub async fn new_in_dir(dir: &Path) -> Result<Self> {
118        std::fs::create_dir_all(dir)?;
119        let db_path = dir.join("index.db");
120        Self::new_with_path(db_path, None).await
121    }
122
123    /// 使用系统临时目录
124    pub async fn new_in_system_temp() -> Result<Self> {
125        let temp_dir =
126            tempfile::Builder::new().prefix("mf_index_").tempdir()?;
127        let db_path = temp_dir.path().join("index.db");
128        Self::new_with_path(db_path, Some(temp_dir)).await
129    }
130
131    /// 在指定临时根目录下创建临时索引
132    pub async fn new_in_temp_root(temp_root: &Path) -> Result<Self> {
133        let temp_dir = tempfile::Builder::new()
134            .prefix("mf_index_")
135            .tempdir_in(temp_root)?;
136        let db_path = temp_dir.path().join("index.db");
137        Self::new_with_path(db_path, Some(temp_dir)).await
138    }
139
140    async fn new_with_path(
141        db_path: PathBuf,
142        temp_dir: Option<tempfile::TempDir>,
143    ) -> Result<Self> {
144        let rb = RBatis::new();
145        rb.link(Driver {}, &format!("sqlite://{}", db_path.display())).await?;
146        rb.acquire().await?.exec(SCHEMA_SQL, vec![]).await?;
147
148        Ok(Self {
149            pool: Arc::new(rb),
150            index_dir: db_path
151                .parent()
152                .map(Path::to_path_buf)
153                .unwrap_or_else(|| PathBuf::from(".")),
154            _temp_dir: temp_dir,
155        })
156    }
157
158    /// 获取索引目录
159    pub fn index_dir(&self) -> &Path {
160        &self.index_dir
161    }
162
163    /// 应用增量变更
164    pub async fn apply(
165        &self,
166        mutations: Vec<IndexMutation>,
167    ) -> Result<()> {
168        if mutations.is_empty() {
169            return Ok(());
170        }
171
172        let tx = self.pool.acquire_begin().await?;
173        for mutation in mutations {
174            match mutation {
175                IndexMutation::Add(doc) | IndexMutation::Upsert(doc) => {
176                    self.upsert_doc(&tx, &doc).await?;
177                },
178                IndexMutation::DeleteById(id) => {
179                    tx.exec(
180                        "DELETE FROM nodes WHERE id = ?1",
181                        vec![to_value(id)],
182                    )
183                    .await?;
184                },
185                IndexMutation::DeleteManyById(ids) => {
186                    for id in ids {
187                        tx.exec(
188                            "DELETE FROM nodes WHERE id = ?1",
189                            vec![to_value(id)],
190                        )
191                        .await?;
192                    }
193                },
194            }
195        }
196        tx.commit().await?;
197        Ok(())
198    }
199
200    async fn upsert_doc<E>(
201        &self,
202        exec: &E,
203        doc: &IndexDoc,
204    ) -> Result<()>
205    where
206        E: Executor + ?Sized,
207    {
208        let marks_types_json = serde_json::to_string(&doc.marks)?;
209        let attrs_map: serde_json::Map<String, serde_json::Value> = doc
210            .attrs_flat
211            .iter()
212            .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
213            .collect();
214        let attrs_flat_json = serde_json::to_string(&attrs_map)?;
215        let path_str = format!("/{}", doc.path.join("/"));
216
217        exec.exec(
218            "INSERT OR REPLACE INTO nodes
219             (id, node_type, parent_id, path, marks, marks_json, attrs, attrs_json, text,
220              order_i64, created_at_i64, updated_at_i64)
221             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
222            vec![
223                to_value(doc.node_id.clone()),
224                to_value(doc.node_type.clone()),
225                to_value(doc.parent_id.clone()),
226                to_value(path_str),
227                to_value(marks_types_json),
228                to_value(doc.marks_json.clone()),
229                to_value(attrs_flat_json),
230                to_value(doc.attrs_json.clone()),
231                to_value(doc.text.clone()),
232                to_value(doc.order_i64),
233                to_value(doc.created_at_i64),
234                to_value(doc.updated_at_i64),
235            ],
236        )
237        .await?;
238        Ok(())
239    }
240
241    /// 重建全部索引
242    pub async fn rebuild_all(
243        &self,
244        docs: Vec<IndexDoc>,
245    ) -> Result<()> {
246        let tx = self.pool.acquire_begin().await?;
247        tx.exec("DELETE FROM nodes", vec![]).await?;
248        for doc in &docs {
249            self.upsert_doc(&tx, doc).await?;
250        }
251        tx.commit().await?;
252        Ok(())
253    }
254
255    /// 搜索节点 ID
256    pub async fn search_ids(
257        &self,
258        query: SearchQuery,
259    ) -> Result<Vec<String>> {
260        if query.include_descendants && query.parent_id.is_some() {
261            return self.search_tree(&query).await;
262        }
263        if query.text.is_some() {
264            return self.search_fulltext(&query).await;
265        }
266        self.search_structured(&query).await
267    }
268
269    /// 搜索并返回完整文档
270    pub async fn search_docs(
271        &self,
272        query: SearchQuery,
273    ) -> Result<Vec<IndexDoc>> {
274        let ids = self.search_ids(query).await?;
275        self.get_docs_by_ids(&ids).await
276    }
277
278    /// 根据 ID 列表获取完整文档
279    pub async fn get_docs_by_ids(
280        &self,
281        ids: &[String],
282    ) -> Result<Vec<IndexDoc>> {
283        if ids.is_empty() {
284            return Ok(Vec::new());
285        }
286
287        let placeholders = std::iter::repeat("?")
288            .take(ids.len())
289            .collect::<Vec<_>>()
290            .join(",");
291        let sql = format!(
292            "SELECT id, node_type, parent_id, path, marks_json, attrs_json, text,
293                    order_i64, created_at_i64, updated_at_i64
294             FROM nodes WHERE id IN ({})",
295            placeholders
296        );
297        let params = ids.iter().cloned().map(to_value).collect::<Vec<Value>>();
298
299        let conn = self.pool.acquire().await?;
300        let rows: Vec<NodeRow> = conn.query_decode(&sql, params).await?;
301        let mut docs_by_id: HashMap<String, IndexDoc> =
302            HashMap::with_capacity(rows.len());
303        for row in rows {
304            let doc = IndexDoc::try_from(row)?;
305            docs_by_id.insert(doc.node_id.clone(), doc);
306        }
307
308        let mut ordered = Vec::with_capacity(ids.len());
309        for id in ids {
310            if let Some(doc) = docs_by_id.remove(id.as_str()) {
311                ordered.push(doc);
312            }
313        }
314        Ok(ordered)
315    }
316
317    async fn search_tree(
318        &self,
319        query: &SearchQuery,
320    ) -> Result<Vec<String>> {
321        let parent_id = query.parent_id.as_ref().unwrap();
322        let mut sql = String::from(
323            "WITH RECURSIVE tree(id, level) AS (
324                SELECT id, 0 as level FROM nodes WHERE id = ?1
325                UNION ALL
326                SELECT n.id, t.level + 1
327                FROM nodes n
328                JOIN tree t ON n.parent_id = t.id
329                WHERE t.level < 100
330            )
331            SELECT id FROM tree WHERE 1=1",
332        );
333
334        let mut params = vec![to_value(parent_id.clone())];
335        if let Some(node_type) = &query.node_type {
336            sql.push_str(
337                " AND id IN (SELECT id FROM nodes WHERE node_type = ?)",
338            );
339            params.push(to_value(node_type.clone()));
340        }
341
342        if let Some(sort_by) = &query.sort_by {
343            let direction = if query.sort_asc { "ASC" } else { "DESC" };
344            sql.push_str(&format!(
345                " ORDER BY (SELECT {} FROM nodes WHERE nodes.id = tree.id) {}",
346                sort_by, direction
347            ));
348        }
349
350        let limit = if query.limit == 0 { 1000 } else { query.limit };
351        sql.push_str(&format!(" LIMIT {} OFFSET {}", limit, query.offset));
352
353        let conn = self.pool.acquire().await?;
354        let rows: Vec<IdRow> = conn.query_decode(&sql, params).await?;
355        Ok(rows.into_iter().map(|r| r.id).collect())
356    }
357
358    async fn search_fulltext(
359        &self,
360        query: &SearchQuery,
361    ) -> Result<Vec<String>> {
362        let text = query.text.as_ref().unwrap();
363        let mut sql = String::from(
364            "SELECT nodes.id FROM nodes_fts
365             JOIN nodes ON nodes_fts.id = nodes.id
366             WHERE nodes_fts.text MATCH ?",
367        );
368        let mut params = vec![to_value(text.clone())];
369
370        if let Some(node_type) = &query.node_type {
371            sql.push_str(" AND nodes.node_type = ?");
372            params.push(to_value(node_type.clone()));
373        }
374        if let Some(parent_id) = &query.parent_id {
375            sql.push_str(" AND nodes.parent_id = ?");
376            params.push(to_value(parent_id.clone()));
377        }
378
379        for mark in &query.marks {
380            sql.push_str(" AND nodes.marks LIKE ?");
381            params.push(to_value(format!("%\"{}\"%%", mark)));
382        }
383
384        for (mark_type, attr_key, attr_value) in &query.mark_attrs {
385            sql.push_str(&format!(
386                " AND EXISTS (
387                    SELECT 1 FROM json_each(nodes.marks_json)
388                    WHERE json_extract(value, '$.type') = ?
389                    AND json_extract(value, '$.attrs.{}') = ?
390                )",
391                attr_key
392            ));
393            params.push(to_value(mark_type.clone()));
394            params.push(to_value(attr_value.clone()));
395        }
396
397        if let Some(sort_by) = &query.sort_by {
398            let direction = if query.sort_asc { "ASC" } else { "DESC" };
399            sql.push_str(&format!(" ORDER BY nodes.{} {}", sort_by, direction));
400        } else {
401            sql.push_str(" ORDER BY rank");
402        }
403
404        let limit = if query.limit == 0 { 50 } else { query.limit };
405        sql.push_str(&format!(" LIMIT {} OFFSET {}", limit, query.offset));
406
407        let conn = self.pool.acquire().await?;
408        let rows: Vec<IdRow> = conn.query_decode(&sql, params).await?;
409        Ok(rows.into_iter().map(|r| r.id).collect())
410    }
411
412    async fn search_structured(
413        &self,
414        query: &SearchQuery,
415    ) -> Result<Vec<String>> {
416        let mut sql = String::from("SELECT id FROM nodes WHERE 1=1");
417        let mut params: Vec<Value> = Vec::new();
418
419        if let Some(node_type) = &query.node_type {
420            sql.push_str(" AND node_type = ?");
421            params.push(to_value(node_type.clone()));
422        }
423        if let Some(parent_id) = &query.parent_id {
424            sql.push_str(" AND parent_id = ?");
425            params.push(to_value(parent_id.clone()));
426        }
427        if let Some(path_prefix) = &query.path_prefix {
428            sql.push_str(" AND path LIKE ?");
429            params.push(to_value(format!("{}%", path_prefix)));
430        }
431
432        for mark in &query.marks {
433            sql.push_str(" AND marks LIKE ?");
434            params.push(to_value(format!("%\"{}\"%%", mark)));
435        }
436
437        for (mark_type, attr_key, attr_value) in &query.mark_attrs {
438            sql.push_str(&format!(
439                " AND EXISTS (
440                    SELECT 1 FROM json_each(marks_json)
441                    WHERE json_extract(value, '$.type') = ?
442                    AND json_extract(value, '$.attrs.{}') = ?
443                )",
444                attr_key
445            ));
446            params.push(to_value(mark_type.clone()));
447            params.push(to_value(attr_value.clone()));
448        }
449
450        for (key, value) in &query.attrs {
451            sql.push_str(&format!(" AND json_extract(attrs, '$.{}') = ?", key));
452            params.push(to_value(value.clone()));
453        }
454
455        if let Some(field) = &query.range_field {
456            if let Some(min) = query.range_min {
457                sql.push_str(&format!(" AND {} >= ?", field));
458                params.push(to_value(min));
459            }
460            if let Some(max) = query.range_max {
461                sql.push_str(&format!(" AND {} <= ?", field));
462                params.push(to_value(max));
463            }
464        }
465
466        if let Some(sort_by) = &query.sort_by {
467            let direction = if query.sort_asc { "ASC" } else { "DESC" };
468            sql.push_str(&format!(" ORDER BY {} {}", sort_by, direction));
469        }
470
471        let limit = if query.limit == 0 { 50 } else { query.limit };
472        sql.push_str(&format!(" LIMIT {} OFFSET {}", limit, query.offset));
473
474        let conn = self.pool.acquire().await?;
475        let rows: Vec<IdRow> = conn.query_decode(&sql, params).await?;
476        Ok(rows.into_iter().map(|r| r.id).collect())
477    }
478}
479
480fn to_value<T: Serialize>(value: T) -> Value {
481    rbs::value_def(value)
482}
483
484#[derive(Debug, Deserialize)]
485struct IdRow {
486    id: String,
487}
488
489#[derive(Debug, Deserialize)]
490struct NodeRow {
491    id: String,
492    node_type: String,
493    parent_id: Option<String>,
494    path: String,
495    marks_json: serde_json::Value,
496    attrs_json: serde_json::Value,
497    text: Option<String>,
498    order_i64: Option<i64>,
499    created_at_i64: Option<i64>,
500    updated_at_i64: Option<i64>,
501}
502
503impl TryFrom<NodeRow> for IndexDoc {
504    type Error = anyhow::Error;
505
506    fn try_from(row: NodeRow) -> Result<Self> {
507        let marks_json_str = json_to_string(&row.marks_json);
508        let attrs_json_str = json_to_string(&row.attrs_json);
509
510        Ok(IndexDoc {
511            node_id: row.id,
512            node_type: row.node_type,
513            parent_id: row.parent_id,
514            path: parse_path(&row.path),
515            marks: marks_from_value(&row.marks_json),
516            marks_json: marks_json_str,
517            attrs_flat: flatten_attrs(&row.attrs_json),
518            attrs_json: attrs_json_str,
519            text: row.text,
520            order_i64: row.order_i64,
521            created_at_i64: row.created_at_i64,
522            updated_at_i64: row.updated_at_i64,
523        })
524    }
525}
526
527fn parse_path(path: &str) -> Vec<String> {
528    path.trim_start_matches('/')
529        .split('/')
530        .filter(|s| !s.is_empty())
531        .map(|s| s.to_string())
532        .collect()
533}
534
535fn marks_from_value(value: &serde_json::Value) -> Vec<String> {
536    match value {
537        serde_json::Value::Array(items) => items
538            .iter()
539            .filter_map(|mark| {
540                mark.get("type").and_then(|v| v.as_str()).map(ToOwned::to_owned)
541            })
542            .collect(),
543        serde_json::Value::String(raw) => serde_json::from_str::<
544            serde_json::Value,
545        >(raw)
546        .ok()
547        .and_then(|parsed| parsed.as_array().cloned())
548        .unwrap_or_default()
549        .into_iter()
550        .filter_map(|mark| {
551            mark.get("type").and_then(|v| v.as_str()).map(ToOwned::to_owned)
552        })
553        .collect(),
554        _ => Vec::new(),
555    }
556}
557
558fn flatten_attrs(value: &serde_json::Value) -> Vec<(String, String)> {
559    match value {
560        serde_json::Value::Object(map) => map
561            .iter()
562            .map(|(k, v)| (k.clone(), json_value_to_string(v)))
563            .collect(),
564        serde_json::Value::String(raw) => serde_json::from_str::<
565            serde_json::Map<String, serde_json::Value>,
566        >(raw)
567        .map(|map| {
568            map.into_iter()
569                .map(|(k, v)| (k, json_value_to_string(&v)))
570                .collect()
571        })
572        .unwrap_or_default(),
573        _ => Vec::new(),
574    }
575}
576
577fn json_to_string(value: &serde_json::Value) -> String {
578    match value {
579        serde_json::Value::String(s) => s.clone(),
580        other => other.to_string(),
581    }
582}
583
584fn json_value_to_string(value: &serde_json::Value) -> String {
585    match value {
586        serde_json::Value::Null => "null".to_string(),
587        serde_json::Value::Bool(b) => b.to_string(),
588        serde_json::Value::Number(n) => n.to_string(),
589        serde_json::Value::String(s) => s.clone(),
590        _ => value.to_string(),
591    }
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    #[tokio::test]
599    async fn test_basic_operations() {
600        let backend = SqliteBackend::new_in_system_temp().await.unwrap();
601
602        let doc = IndexDoc {
603            node_id: "test1".to_string(),
604            node_type: "paragraph".to_string(),
605            parent_id: Some("root".to_string()),
606            path: vec!["root".to_string(), "test1".to_string()],
607            marks: vec!["bold".to_string()],
608            marks_json: r#"[{"type":"bold","attrs":{}}]"#.to_string(),
609            attrs_flat: vec![("status".to_string(), "published".to_string())],
610            attrs_json: r#"{"status":"published"}"#.to_string(),
611            text: Some("测试文本".to_string()),
612            order_i64: Some(1),
613            created_at_i64: Some(1000),
614            updated_at_i64: Some(2000),
615        };
616
617        backend.apply(vec![IndexMutation::Add(doc.clone())]).await.unwrap();
618
619        let ids = backend
620            .search_ids(SearchQuery {
621                node_type: Some("paragraph".to_string()),
622                limit: 10,
623                ..Default::default()
624            })
625            .await
626            .unwrap();
627        assert_eq!(ids, vec!["test1"]);
628
629        let docs = backend.get_docs_by_ids(&ids).await.unwrap();
630        assert_eq!(docs.len(), 1);
631        assert_eq!(docs[0].node_id, "test1");
632    }
633
634    #[tokio::test]
635    async fn test_tree_query() {
636        let backend = SqliteBackend::new_in_system_temp().await.unwrap();
637
638        let docs = vec![
639            IndexDoc {
640                node_id: "root".to_string(),
641                node_type: "doc".to_string(),
642                parent_id: None,
643                path: vec!["root".to_string()],
644                marks: vec![],
645                marks_json: "[]".to_string(),
646                attrs_flat: vec![],
647                attrs_json: "{}".to_string(),
648                text: None,
649                order_i64: None,
650                created_at_i64: None,
651                updated_at_i64: None,
652            },
653            IndexDoc {
654                node_id: "child1".to_string(),
655                node_type: "paragraph".to_string(),
656                parent_id: Some("root".to_string()),
657                path: vec!["root".to_string(), "child1".to_string()],
658                marks: vec![],
659                marks_json: "[]".to_string(),
660                attrs_flat: vec![],
661                attrs_json: "{}".to_string(),
662                text: None,
663                order_i64: None,
664                created_at_i64: None,
665                updated_at_i64: None,
666            },
667            IndexDoc {
668                node_id: "child2".to_string(),
669                node_type: "paragraph".to_string(),
670                parent_id: Some("root".to_string()),
671                path: vec!["root".to_string(), "child2".to_string()],
672                marks: vec![],
673                marks_json: "[]".to_string(),
674                attrs_flat: vec![],
675                attrs_json: "{}".to_string(),
676                text: None,
677                order_i64: None,
678                created_at_i64: None,
679                updated_at_i64: None,
680            },
681        ];
682
683        backend.rebuild_all(docs).await.unwrap();
684
685        let results = backend
686            .search_ids(SearchQuery {
687                parent_id: Some("root".to_string()),
688                include_descendants: true,
689                limit: 100,
690                ..Default::default()
691            })
692            .await
693            .unwrap();
694
695        assert_eq!(results.len(), 3); // root + 2 children
696    }
697
698    #[tokio::test]
699    async fn test_search_docs() {
700        let backend = SqliteBackend::new_in_system_temp().await.unwrap();
701
702        let docs = vec![
703            IndexDoc {
704                node_id: "doc1".to_string(),
705                node_type: "paragraph".to_string(),
706                parent_id: Some("root".to_string()),
707                path: vec!["root".to_string(), "doc1".to_string()],
708                marks: vec!["bold".to_string()],
709                marks_json: r#"[{"type":"bold","attrs":{}}]"#.to_string(),
710                attrs_flat: vec![(
711                    "status".to_string(),
712                    "published".to_string(),
713                )],
714                attrs_json: r#"{"status":"published"}"#.to_string(),
715                text: Some("第一篇文章".to_string()),
716                order_i64: Some(1),
717                created_at_i64: Some(1000),
718                updated_at_i64: Some(1500),
719            },
720            IndexDoc {
721                node_id: "doc2".to_string(),
722                node_type: "paragraph".to_string(),
723                parent_id: Some("root".to_string()),
724                path: vec!["root".to_string(), "doc2".to_string()],
725                marks: vec!["italic".to_string()],
726                marks_json: r#"[{"type":"italic","attrs":{}}]"#.to_string(),
727                attrs_flat: vec![("status".to_string(), "draft".to_string())],
728                attrs_json: r#"{"status":"draft"}"#.to_string(),
729                text: Some("第二篇文章".to_string()),
730                order_i64: Some(2),
731                created_at_i64: Some(2000),
732                updated_at_i64: Some(2500),
733            },
734        ];
735
736        backend.rebuild_all(docs).await.unwrap();
737
738        let results = backend
739            .search_docs(SearchQuery {
740                node_type: Some("paragraph".to_string()),
741                limit: 10,
742                ..Default::default()
743            })
744            .await
745            .unwrap();
746
747        assert_eq!(results.len(), 2);
748        let doc1 = results.iter().find(|d| d.node_id == "doc1").unwrap();
749        assert_eq!(doc1.text, Some("第一篇文章".to_string()));
750        assert_eq!(doc1.marks, vec!["bold".to_string()]);
751
752        let doc2 = results.iter().find(|d| d.node_id == "doc2").unwrap();
753        assert_eq!(doc2.text, Some("第二篇文章".to_string()));
754        assert_eq!(doc2.marks, vec!["italic".to_string()]);
755    }
756
757    #[tokio::test]
758    async fn test_get_docs_by_ids() {
759        let backend = SqliteBackend::new_in_system_temp().await.unwrap();
760
761        let doc = IndexDoc {
762            node_id: "test123".to_string(),
763            node_type: "heading".to_string(),
764            parent_id: None,
765            path: vec!["test123".to_string()],
766            marks: vec![],
767            marks_json: "[]".to_string(),
768            attrs_flat: vec![("level".to_string(), "1".to_string())],
769            attrs_json: r#"{"level":"1"}"#.to_string(),
770            text: Some("标题文本".to_string()),
771            order_i64: None,
772            created_at_i64: None,
773            updated_at_i64: None,
774        };
775
776        backend.apply(vec![IndexMutation::Add(doc)]).await.unwrap();
777
778        let docs =
779            backend.get_docs_by_ids(&["test123".to_string()]).await.unwrap();
780
781        assert_eq!(docs.len(), 1);
782        assert_eq!(docs[0].node_id, "test123");
783        assert_eq!(docs[0].node_type, "heading");
784        assert_eq!(docs[0].text, Some("标题文本".to_string()));
785
786        let empty = backend.get_docs_by_ids(&[]).await.unwrap();
787        assert_eq!(empty.len(), 0);
788    }
789}