use crate::model::IndexDoc;
use anyhow::Result;
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::TransactionBehavior;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub enum IndexMutation {
Add(IndexDoc),
Upsert(IndexDoc),
DeleteById(String),
DeleteManyById(Vec<String>),
}
#[derive(Debug, Clone, Default)]
pub struct SearchQuery {
pub text: Option<String>,
pub node_type: Option<String>,
pub parent_id: Option<String>,
pub path_prefix: Option<String>,
pub marks: Vec<String>,
pub mark_attrs: Vec<(String, String, String)>,
pub attrs: Vec<(String, String)>,
pub limit: usize,
pub offset: usize,
pub sort_by: Option<String>,
pub sort_asc: bool,
pub include_descendants: bool,
pub range_field: Option<String>,
pub range_min: Option<i64>,
pub range_max: Option<i64>,
}
pub struct SqliteBackend {
pool: Pool<SqliteConnectionManager>,
index_dir: PathBuf,
_temp_dir: Option<tempfile::TempDir>,
}
impl SqliteBackend {
pub fn new_in_dir(dir: &std::path::Path) -> Result<Self> {
std::fs::create_dir_all(dir)?;
let db_path = dir.join("index.db");
let manager = SqliteConnectionManager::file(&db_path);
let pool = Pool::new(manager)?;
pool.get()?.execute_batch(
"-- 开启 WAL 模式(高并发)
PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA cache_size=-64000; -- 64MB cache
PRAGMA temp_store=MEMORY;
-- 创建主表
CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY,
node_type TEXT NOT NULL,
parent_id TEXT,
path TEXT NOT NULL,
marks TEXT,
marks_json TEXT,
attrs TEXT,
attrs_json TEXT,
text TEXT,
order_i64 INTEGER,
created_at_i64 INTEGER,
updated_at_i64 INTEGER
);
-- 创建索引
CREATE INDEX IF NOT EXISTS idx_node_type ON nodes(node_type);
CREATE INDEX IF NOT EXISTS idx_parent_id ON nodes(parent_id);
CREATE INDEX IF NOT EXISTS idx_path ON nodes(path);
CREATE INDEX IF NOT EXISTS idx_created_at ON nodes(created_at_i64);
CREATE INDEX IF NOT EXISTS idx_updated_at ON nodes(updated_at_i64);
CREATE INDEX IF NOT EXISTS idx_order ON nodes(order_i64);
-- 创建 FTS5 全文索引表
CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
id UNINDEXED,
text,
content='nodes',
content_rowid='rowid'
);
-- 创建触发器:自动同步 FTS5
CREATE TRIGGER IF NOT EXISTS nodes_ai AFTER INSERT ON nodes BEGIN
INSERT INTO nodes_fts(rowid, id, text)
VALUES (new.rowid, new.id, new.text);
END;
CREATE TRIGGER IF NOT EXISTS nodes_ad AFTER DELETE ON nodes BEGIN
INSERT INTO nodes_fts(nodes_fts, rowid, id, text)
VALUES('delete', old.rowid, old.id, old.text);
END;
CREATE TRIGGER IF NOT EXISTS nodes_au AFTER UPDATE ON nodes BEGIN
INSERT INTO nodes_fts(nodes_fts, rowid, id, text)
VALUES('delete', old.rowid, old.id, old.text);
INSERT INTO nodes_fts(rowid, id, text)
VALUES (new.rowid, new.id, new.text);
END;"
)?;
Ok(Self {
pool,
index_dir: dir.to_path_buf(),
_temp_dir: None, })
}
pub fn new_in_system_temp() -> Result<Self> {
let temp_dir = tempfile::Builder::new()
.prefix("mf_index_")
.tempdir()?;
let db_path = temp_dir.path().join("index.db");
let manager = SqliteConnectionManager::file(&db_path);
let pool = Pool::new(manager)?;
pool.get()?.execute_batch(
"-- 开启 WAL 模式(高并发)
PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA cache_size=-64000; -- 64MB cache
PRAGMA temp_store=MEMORY;
-- 创建主表
CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY,
node_type TEXT NOT NULL,
parent_id TEXT,
path TEXT NOT NULL,
marks TEXT,
marks_json TEXT,
attrs TEXT,
attrs_json TEXT,
text TEXT,
order_i64 INTEGER,
created_at_i64 INTEGER,
updated_at_i64 INTEGER
);
-- 创建索引
CREATE INDEX IF NOT EXISTS idx_node_type ON nodes(node_type);
CREATE INDEX IF NOT EXISTS idx_parent_id ON nodes(parent_id);
CREATE INDEX IF NOT EXISTS idx_path ON nodes(path);
CREATE INDEX IF NOT EXISTS idx_created_at ON nodes(created_at_i64);
CREATE INDEX IF NOT EXISTS idx_updated_at ON nodes(updated_at_i64);
CREATE INDEX IF NOT EXISTS idx_order ON nodes(order_i64);
-- 创建 FTS5 全文索引表
CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
id UNINDEXED,
text,
content='nodes',
content_rowid='rowid'
);
-- 创建触发器:自动同步 FTS5
CREATE TRIGGER IF NOT EXISTS nodes_ai AFTER INSERT ON nodes BEGIN
INSERT INTO nodes_fts(rowid, id, text)
VALUES (new.rowid, new.id, new.text);
END;
CREATE TRIGGER IF NOT EXISTS nodes_ad AFTER DELETE ON nodes BEGIN
INSERT INTO nodes_fts(nodes_fts, rowid, id, text)
VALUES('delete', old.rowid, old.id, old.text);
END;
CREATE TRIGGER IF NOT EXISTS nodes_au AFTER UPDATE ON nodes BEGIN
INSERT INTO nodes_fts(nodes_fts, rowid, id, text)
VALUES('delete', old.rowid, old.id, old.text);
INSERT INTO nodes_fts(rowid, id, text)
VALUES (new.rowid, new.id, new.text);
END;"
)?;
Ok(Self {
pool,
index_dir: temp_dir.path().to_path_buf(),
_temp_dir: Some(temp_dir), })
}
pub fn new_in_temp_root(temp_root: &std::path::Path) -> Result<Self> {
let temp_dir = tempfile::Builder::new()
.prefix("mf_index_")
.tempdir_in(temp_root)?;
let db_path = temp_dir.path().join("index.db");
let manager = SqliteConnectionManager::file(&db_path);
let pool = Pool::new(manager)?;
pool.get()?.execute_batch(
"-- 开启 WAL 模式(高并发)
PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA cache_size=-64000; -- 64MB cache
PRAGMA temp_store=MEMORY;
-- 创建主表
CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY,
node_type TEXT NOT NULL,
parent_id TEXT,
path TEXT NOT NULL,
marks TEXT,
marks_json TEXT,
attrs TEXT,
attrs_json TEXT,
text TEXT,
order_i64 INTEGER,
created_at_i64 INTEGER,
updated_at_i64 INTEGER
);
-- 创建索引
CREATE INDEX IF NOT EXISTS idx_node_type ON nodes(node_type);
CREATE INDEX IF NOT EXISTS idx_parent_id ON nodes(parent_id);
CREATE INDEX IF NOT EXISTS idx_path ON nodes(path);
CREATE INDEX IF NOT EXISTS idx_created_at ON nodes(created_at_i64);
CREATE INDEX IF NOT EXISTS idx_updated_at ON nodes(updated_at_i64);
CREATE INDEX IF NOT EXISTS idx_order ON nodes(order_i64);
-- 创建 FTS5 全文索引表
CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
id UNINDEXED,
text,
content='nodes',
content_rowid='rowid'
);
-- 创建触发器:自动同步 FTS5
CREATE TRIGGER IF NOT EXISTS nodes_ai AFTER INSERT ON nodes BEGIN
INSERT INTO nodes_fts(rowid, id, text)
VALUES (new.rowid, new.id, new.text);
END;
CREATE TRIGGER IF NOT EXISTS nodes_ad AFTER DELETE ON nodes BEGIN
INSERT INTO nodes_fts(nodes_fts, rowid, id, text)
VALUES('delete', old.rowid, old.id, old.text);
END;
CREATE TRIGGER IF NOT EXISTS nodes_au AFTER UPDATE ON nodes BEGIN
INSERT INTO nodes_fts(nodes_fts, rowid, id, text)
VALUES('delete', old.rowid, old.id, old.text);
INSERT INTO nodes_fts(rowid, id, text)
VALUES (new.rowid, new.id, new.text);
END;"
)?;
Ok(Self {
pool,
index_dir: temp_dir.path().to_path_buf(),
_temp_dir: Some(temp_dir), })
}
pub fn index_dir(&self) -> &std::path::Path {
&self.index_dir
}
pub async fn apply(&self, mutations: Vec<IndexMutation>) -> Result<()> {
let mut conn = self.pool.get()?;
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
for mutation in mutations {
match mutation {
IndexMutation::Add(doc) | IndexMutation::Upsert(doc) => {
self.upsert_doc(&tx, &doc)?;
}
IndexMutation::DeleteById(id) => {
tx.execute("DELETE FROM nodes WHERE id = ?", [&id])?;
}
IndexMutation::DeleteManyById(ids) => {
for id in ids {
tx.execute("DELETE FROM nodes WHERE id = ?", [&id])?;
}
}
}
}
tx.commit()?;
Ok(())
}
fn upsert_doc(
&self,
tx: &rusqlite::Transaction,
doc: &IndexDoc,
) -> Result<()> {
let marks_types_json = serde_json::to_string(&doc.marks)?;
let attrs_flat_json = serde_json::to_string(&doc.attrs_flat)?;
let path_str = format!("/{}", doc.path.join("/"));
tx.execute(
"INSERT OR REPLACE INTO nodes
(id, node_type, parent_id, path, marks, marks_json, attrs, attrs_json, text,
order_i64, created_at_i64, updated_at_i64)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
rusqlite::params![
&doc.node_id,
&doc.node_type,
&doc.parent_id,
&path_str,
&marks_types_json,
&doc.marks_json,
&attrs_flat_json,
&doc.attrs_json,
&doc.text,
doc.order_i64,
doc.created_at_i64,
doc.updated_at_i64,
],
)?;
Ok(())
}
pub async fn rebuild_all(&self, docs: Vec<IndexDoc>) -> Result<()> {
let mut conn = self.pool.get()?;
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
tx.execute("DELETE FROM nodes", [])?;
for doc in docs {
self.upsert_doc(&tx, &doc)?;
}
tx.commit()?;
Ok(())
}
pub async fn search_ids(&self, query: SearchQuery) -> Result<Vec<String>> {
let conn = self.pool.get()?;
if query.include_descendants && query.parent_id.is_some() {
return self.search_tree(&conn, &query);
}
if query.text.is_some() {
return self.search_fulltext(&conn, &query);
}
self.search_structured(&conn, &query)
}
pub async fn search_docs(&self, query: SearchQuery) -> Result<Vec<IndexDoc>> {
let ids = self.search_ids(query).await?;
self.get_docs_by_ids(&ids).await
}
pub async fn get_docs_by_ids(&self, ids: &[String]) -> Result<Vec<IndexDoc>> {
if ids.is_empty() {
return Ok(Vec::new());
}
let conn = self.pool.get()?;
let placeholders = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
let sql = format!(
"SELECT id, node_type, parent_id, path, marks, marks_json, attrs, attrs_json, text,
order_i64, created_at_i64, updated_at_i64
FROM nodes WHERE id IN ({})",
placeholders
);
let mut stmt = conn.prepare(&sql)?;
let params: Vec<&dyn rusqlite::ToSql> = ids.iter()
.map(|id| id as &dyn rusqlite::ToSql)
.collect();
let docs = stmt.query_map(¶ms[..], |row| {
let marks_json: String = row.get(5)?;
let marks_objects: Vec<mf_model::mark::Mark> = serde_json::from_str(&marks_json)
.unwrap_or_default();
let marks: Vec<String> = marks_objects.iter()
.map(|m| m.r#type.clone())
.collect();
let attrs_json: String = row.get(7)?;
let attrs_map: imbl::HashMap<String, serde_json::Value> = serde_json::from_str(&attrs_json)
.unwrap_or_default();
let attrs_flat: Vec<(String, String)> = attrs_map.iter()
.map(|(k, v)| {
let value_str = match v {
serde_json::Value::Null => "null".to_string(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::String(s) => s.clone(),
_ => serde_json::to_string(v).unwrap_or_default(),
};
(k.clone(), value_str)
})
.collect();
let path_str: String = row.get(3)?;
let path: Vec<String> = path_str
.trim_start_matches('/')
.split('/')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
Ok(IndexDoc {
node_id: row.get(0)?,
node_type: row.get(1)?,
parent_id: row.get(2)?,
path,
marks,
marks_json: row.get(5)?,
attrs_flat,
attrs_json: row.get(7)?,
text: row.get(8)?,
order_i64: row.get(9)?,
created_at_i64: row.get(10)?,
updated_at_i64: row.get(11)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(docs)
}
fn search_tree(
&self,
conn: &rusqlite::Connection,
query: &SearchQuery,
) -> Result<Vec<String>> {
let parent_id = query.parent_id.as_ref().unwrap();
let mut sql = String::from(
"WITH RECURSIVE tree(id, level) AS (
SELECT id, 0 as level FROM nodes WHERE id = ?1
UNION ALL
SELECT n.id, t.level + 1
FROM nodes n
JOIN tree t ON n.parent_id = t.id
WHERE t.level < 100
)
SELECT id FROM tree WHERE 1=1"
);
let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(parent_id.clone())];
if let Some(node_type) = &query.node_type {
sql.push_str(" AND id IN (SELECT id FROM nodes WHERE node_type = ?)");
params.push(Box::new(node_type.clone()));
}
if let Some(sort_by) = &query.sort_by {
let direction = if query.sort_asc { "ASC" } else { "DESC" };
sql.push_str(&format!(
" ORDER BY (SELECT {} FROM nodes WHERE nodes.id = tree.id) {}",
sort_by, direction
));
}
let limit = if query.limit == 0 { 1000 } else { query.limit };
sql.push_str(&format!(" LIMIT {} OFFSET {}", limit, query.offset));
let mut stmt = conn.prepare(&sql)?;
let params_ref: Vec<&dyn rusqlite::ToSql> = params
.iter()
.map(|p| p.as_ref() as &dyn rusqlite::ToSql)
.collect();
let ids: Vec<String> = stmt
.query_map(¶ms_ref[..], |row| row.get(0))?
.collect::<Result<Vec<_>, _>>()?;
Ok(ids)
}
fn search_fulltext(
&self,
conn: &rusqlite::Connection,
query: &SearchQuery,
) -> Result<Vec<String>> {
let text = query.text.as_ref().unwrap();
let mut sql = String::from(
"SELECT nodes.id FROM nodes_fts
JOIN nodes ON nodes_fts.id = nodes.id
WHERE nodes_fts.text MATCH ?1"
);
let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(text.clone())];
let mut param_index = 2;
if let Some(node_type) = &query.node_type {
sql.push_str(&format!(" AND nodes.node_type = ?{}", param_index));
params.push(Box::new(node_type.clone()));
param_index += 1;
}
if let Some(parent_id) = &query.parent_id {
sql.push_str(&format!(" AND nodes.parent_id = ?{}", param_index));
params.push(Box::new(parent_id.clone()));
param_index += 1;
}
for mark in &query.marks {
sql.push_str(&format!(" AND nodes.marks LIKE ?{}", param_index));
params.push(Box::new(format!("%\"{}\"%%", mark)));
param_index += 1;
}
if let Some(sort_by) = &query.sort_by {
let direction = if query.sort_asc { "ASC" } else { "DESC" };
sql.push_str(&format!(" ORDER BY nodes.{} {}", sort_by, direction));
} else {
sql.push_str(" ORDER BY rank"); }
let limit = if query.limit == 0 { 50 } else { query.limit };
sql.push_str(&format!(" LIMIT {} OFFSET {}", limit, query.offset));
let mut stmt = conn.prepare(&sql)?;
let params_ref: Vec<&dyn rusqlite::ToSql> = params
.iter()
.map(|p| p.as_ref() as &dyn rusqlite::ToSql)
.collect();
let ids: Vec<String> = stmt
.query_map(¶ms_ref[..], |row| row.get(0))?
.collect::<Result<Vec<_>, _>>()?;
Ok(ids)
}
fn search_structured(
&self,
conn: &rusqlite::Connection,
query: &SearchQuery,
) -> Result<Vec<String>> {
let mut sql = String::from("SELECT id FROM nodes WHERE 1=1");
let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![];
let mut param_index = 1;
if let Some(node_type) = &query.node_type {
sql.push_str(&format!(" AND node_type = ?{}", param_index));
params.push(Box::new(node_type.clone()));
param_index += 1;
}
if let Some(parent_id) = &query.parent_id {
sql.push_str(&format!(" AND parent_id = ?{}", param_index));
params.push(Box::new(parent_id.clone()));
param_index += 1;
}
if let Some(path_prefix) = &query.path_prefix {
sql.push_str(&format!(" AND path LIKE ?{}", param_index));
params.push(Box::new(format!("{}%", path_prefix)));
param_index += 1;
}
for mark in &query.marks {
sql.push_str(&format!(" AND marks LIKE ?{}", param_index));
params.push(Box::new(format!("%\"{}\"%%", mark)));
param_index += 1;
}
for (mark_type, attr_key, attr_value) in &query.mark_attrs {
sql.push_str(&format!(
" AND EXISTS (
SELECT 1 FROM json_each(marks_json)
WHERE json_extract(value, '$.type') = ?{}
AND json_extract(value, '$.attrs.{}') = ?{}
)",
param_index, attr_key, param_index + 1
));
params.push(Box::new(mark_type.clone()));
params.push(Box::new(attr_value.clone()));
param_index += 2;
}
for (key, value) in &query.attrs {
sql.push_str(&format!(
" AND json_extract(attrs, '$.{}') = ?{}",
key, param_index
));
params.push(Box::new(value.clone()));
param_index += 1;
}
if let Some(field) = &query.range_field {
if let Some(min) = query.range_min {
sql.push_str(&format!(" AND {} >= ?{}", field, param_index));
params.push(Box::new(min));
param_index += 1;
}
if let Some(max) = query.range_max {
sql.push_str(&format!(" AND {} <= ?{}", field, param_index));
params.push(Box::new(max));
}
}
if let Some(sort_by) = &query.sort_by {
let direction = if query.sort_asc { "ASC" } else { "DESC" };
sql.push_str(&format!(" ORDER BY {} {}", sort_by, direction));
}
let limit = if query.limit == 0 { 50 } else { query.limit };
sql.push_str(&format!(" LIMIT {} OFFSET {}", limit, query.offset));
let mut stmt = conn.prepare(&sql)?;
let params_ref: Vec<&dyn rusqlite::ToSql> = params
.iter()
.map(|p| p.as_ref() as &dyn rusqlite::ToSql)
.collect();
let ids: Vec<String> = stmt
.query_map(¶ms_ref[..], |row| row.get(0))?
.collect::<Result<Vec<_>, _>>()?;
Ok(ids)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_basic_operations() {
let backend = SqliteBackend::new_in_system_temp().unwrap();
let doc = IndexDoc {
node_id: "test1".to_string(),
node_type: "paragraph".to_string(),
parent_id: Some("root".to_string()),
path: vec!["root".to_string(), "test1".to_string()],
marks: vec!["bold".to_string()],
marks_json: r#"[{"type":"bold","attrs":{}}]"#.to_string(),
attrs_flat: vec![("status".to_string(), "published".to_string())],
attrs_json: r#"{"status":"published"}"#.to_string(),
text: Some("测试文本".to_string()),
order_i64: Some(1),
created_at_i64: Some(1000),
updated_at_i64: Some(2000),
};
backend.apply(vec![IndexMutation::Add(doc)]).await.unwrap();
let results = backend
.search_ids(SearchQuery {
node_type: Some("paragraph".to_string()),
limit: 10,
..Default::default()
})
.await
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0], "test1");
}
#[tokio::test]
async fn test_tree_query() {
let backend = SqliteBackend::new_in_system_temp().unwrap();
let docs = vec![
IndexDoc {
node_id: "root".to_string(),
node_type: "doc".to_string(),
parent_id: None,
path: vec!["root".to_string()],
marks: vec![],
marks_json: "[]".to_string(),
attrs_flat: vec![],
attrs_json: "{}".to_string(),
text: None,
order_i64: None,
created_at_i64: None,
updated_at_i64: None,
},
IndexDoc {
node_id: "child1".to_string(),
node_type: "section".to_string(),
parent_id: Some("root".to_string()),
path: vec!["root".to_string(), "child1".to_string()],
marks: vec![],
marks_json: "[]".to_string(),
attrs_flat: vec![],
attrs_json: "{}".to_string(),
text: None,
order_i64: None,
created_at_i64: None,
updated_at_i64: None,
},
IndexDoc {
node_id: "child2".to_string(),
node_type: "paragraph".to_string(),
parent_id: Some("child1".to_string()),
path: vec!["root".to_string(), "child1".to_string(), "child2".to_string()],
marks: vec![],
marks_json: "[]".to_string(),
attrs_flat: vec![],
attrs_json: "{}".to_string(),
text: None,
order_i64: None,
created_at_i64: None,
updated_at_i64: None,
},
];
backend.rebuild_all(docs).await.unwrap();
let results = backend
.search_ids(SearchQuery {
parent_id: Some("root".to_string()),
include_descendants: true,
limit: 100,
..Default::default()
})
.await
.unwrap();
assert_eq!(results.len(), 3); }
#[tokio::test]
async fn test_search_docs() {
let backend = SqliteBackend::new_in_system_temp().unwrap();
let docs = vec![
IndexDoc {
node_id: "doc1".to_string(),
node_type: "paragraph".to_string(),
parent_id: Some("root".to_string()),
path: vec!["root".to_string(), "doc1".to_string()],
marks: vec!["bold".to_string()],
marks_json: r#"[{"type":"bold","attrs":{}}]"#.to_string(),
attrs_flat: vec![("status".to_string(), "published".to_string())],
attrs_json: r#"{"status":"published"}"#.to_string(),
text: Some("第一篇文档".to_string()),
order_i64: Some(1),
created_at_i64: Some(1000),
updated_at_i64: Some(1500),
},
IndexDoc {
node_id: "doc2".to_string(),
node_type: "paragraph".to_string(),
parent_id: Some("root".to_string()),
path: vec!["root".to_string(), "doc2".to_string()],
marks: vec!["italic".to_string()],
marks_json: r#"[{"type":"italic","attrs":{}}]"#.to_string(),
attrs_flat: vec![("status".to_string(), "draft".to_string())],
attrs_json: r#"{"status":"draft"}"#.to_string(),
text: Some("第二篇文档".to_string()),
order_i64: Some(2),
created_at_i64: Some(2000),
updated_at_i64: Some(2500),
},
];
backend.rebuild_all(docs).await.unwrap();
let results = backend
.search_docs(SearchQuery {
node_type: Some("paragraph".to_string()),
limit: 10,
..Default::default()
})
.await
.unwrap();
assert_eq!(results.len(), 2);
let doc1 = results.iter().find(|d| d.node_id == "doc1").unwrap();
assert_eq!(doc1.node_type, "paragraph");
assert_eq!(doc1.text, Some("第一篇文档".to_string()));
assert_eq!(doc1.marks, vec!["bold".to_string()]);
assert_eq!(doc1.attrs_flat, vec![("status".to_string(), "published".to_string())]);
assert_eq!(doc1.order_i64, Some(1));
let doc2 = results.iter().find(|d| d.node_id == "doc2").unwrap();
assert_eq!(doc2.text, Some("第二篇文档".to_string()));
assert_eq!(doc2.marks, vec!["italic".to_string()]);
}
#[tokio::test]
async fn test_get_docs_by_ids() {
let backend = SqliteBackend::new_in_system_temp().unwrap();
let doc = IndexDoc {
node_id: "test123".to_string(),
node_type: "heading".to_string(),
parent_id: None,
path: vec!["test123".to_string()],
marks: vec![],
marks_json: "[]".to_string(),
attrs_flat: vec![("level".to_string(), "1".to_string())],
attrs_json: r#"{"level":"1"}"#.to_string(),
text: Some("标题文本".to_string()),
order_i64: None,
created_at_i64: None,
updated_at_i64: None,
};
backend.apply(vec![IndexMutation::Add(doc)]).await.unwrap();
let docs = backend.get_docs_by_ids(&["test123".to_string()]).await.unwrap();
assert_eq!(docs.len(), 1);
assert_eq!(docs[0].node_id, "test123");
assert_eq!(docs[0].node_type, "heading");
assert_eq!(docs[0].text, Some("标题文本".to_string()));
let empty = backend.get_docs_by_ids(&[]).await.unwrap();
assert_eq!(empty.len(), 0);
let not_found = backend.get_docs_by_ids(&["nonexistent".to_string()]).await.unwrap();
assert_eq!(not_found.len(), 0);
}
}