use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
use rusqlite::Connection;
use crate::error::Result;
const TMP_SUFFIX: &str = ".zkvtmp";
#[derive(Debug)]
pub struct Database {
conn: Connection,
backing_tmp: Option<PathBuf>,
}
impl Database {
pub fn conn(&self) -> &Connection {
&self.conn
}
pub fn open_in_memory() -> Result<Database> {
let conn = Connection::open_in_memory()?;
let db = Database {
conn,
backing_tmp: None,
};
db.migrate()?;
Ok(db)
}
pub fn migrate(&self) -> Result<()> {
let c = &self.conn;
c.execute_batch(
r#"
-- 分类:支持层级(parent_id 自引用)
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
parent_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
-- 标签
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
-- 条目:核心表,统一承载所有类型
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY,
type TEXT NOT NULL,
title TEXT NOT NULL,
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
data TEXT NOT NULL,
favorite INTEGER NOT NULL DEFAULT 0,
search_text TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_items_category ON items(category_id);
CREATE INDEX IF NOT EXISTS idx_items_type ON items(type);
-- 条目 ↔ 标签(多对多)
CREATE TABLE IF NOT EXISTS item_tags (
item_id INTEGER REFERENCES items(id) ON DELETE CASCADE,
tag_id INTEGER REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (item_id, tag_id)
);
-- 附件:图片/电子档内嵌为 BLOB
CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY,
item_id INTEGER REFERENCES items(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
mime_type TEXT,
size INTEGER NOT NULL,
blob BLOB NOT NULL,
created_at INTEGER NOT NULL
);
"#,
)?;
c.execute_batch(
r#"
CREATE VIRTUAL TABLE IF NOT EXISTS items_fts USING fts5(
title, search_text,
content='items', content_rowid='id'
);
-- items 增/改时重建对应行的 FTS 索引
CREATE TRIGGER IF NOT EXISTS items_ai AFTER INSERT ON items BEGIN
INSERT INTO items_fts(rowid, title, search_text)
VALUES (new.id, new.title, new.search_text);
END;
CREATE TRIGGER IF NOT EXISTS items_ad AFTER DELETE ON items BEGIN
INSERT INTO items_fts(items_fts, rowid, title, search_text)
VALUES ('delete', old.id, old.title, old.search_text);
END;
CREATE TRIGGER IF NOT EXISTS items_au AFTER UPDATE ON items BEGIN
INSERT INTO items_fts(items_fts, rowid, title, search_text)
VALUES ('delete', old.id, old.title, old.search_text);
INSERT INTO items_fts(rowid, title, search_text)
VALUES (new.id, new.title, new.search_text);
END;
"#,
)?;
Ok(())
}
pub fn dump_bytes(&self) -> Result<Vec<u8>> {
let tmp = secure_tmp_path("zkv_dump");
let sql = format!("VACUUM INTO '{}'", tmp.display());
let result = (|| -> Result<Vec<u8>> {
self.conn.execute_batch(&sql)?;
let bytes = fs::read(&tmp)?;
Ok(bytes)
})();
let _ = fs::remove_file(&tmp);
result
}
pub fn from_bytes(bytes: &[u8]) -> Result<Database> {
let tmp = secure_tmp_path("zkv_load");
{
use std::io::Write;
let mut f = open_secure(&tmp)?;
f.write_all(bytes)?;
f.sync_all()?;
}
let res = (|| -> Result<Database> {
let src = Connection::open(&tmp)?;
let mut dst = Connection::open_in_memory()?;
{
let b = rusqlite::backup::Backup::new(&src, &mut dst)?;
b.run_to_completion(100, Duration::from_millis(250), None)?;
}
drop(src);
Ok(Database {
conn: dst,
backing_tmp: None,
})
})();
let _ = fs::remove_file(&tmp);
res
}
}
impl Drop for Database {
fn drop(&mut self) {
if let Some(p) = self.backing_tmp.take() {
let _ = fs::remove_file(&p);
}
}
}
fn secure_tmp_base(prefix: &str) -> PathBuf {
let mut name = String::from(prefix);
name.push('-');
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
name.push_str(&format!(
"{}{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0),
n
));
std::env::temp_dir().join(name)
}
fn secure_tmp_path(prefix: &str) -> PathBuf {
let mut p = secure_tmp_base(prefix);
p.set_extension(TMP_SUFFIX.trim_start_matches('.'));
p
}
#[cfg(unix)]
fn open_secure(path: &Path) -> Result<std::fs::File> {
use std::os::unix::fs::OpenOptionsExt;
Ok(std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.truncate(true)
.mode(0o600)
.open(path)?)
}
#[cfg(not(unix))]
fn open_secure(path: &Path) -> Result<std::fs::File> {
Ok(std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.truncate(true)
.open(path)?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_in_memory_and_migrate_ok() {
let db = Database::open_in_memory().expect("open_in_memory");
let names: Vec<String> = db
.conn()
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.unwrap()
.query_map([], |r| r.get::<_, String>(0))
.unwrap()
.filter_map(|r| r.ok())
.collect();
for expected in [
"categories",
"tags",
"items",
"item_tags",
"attachments",
"items_fts",
] {
assert!(
names.iter().any(|n| n == expected),
"missing table: {expected} (have {:?})",
names
);
}
let trigs: Vec<String> = db
.conn()
.prepare("SELECT name FROM sqlite_master WHERE type='trigger' ORDER BY name")
.unwrap()
.query_map([], |r| r.get::<_, String>(0))
.unwrap()
.filter_map(|r| r.ok())
.collect();
assert!(trigs.iter().any(|n| n == "items_ai"));
assert!(trigs.iter().any(|n| n == "items_ad"));
assert!(trigs.iter().any(|n| n == "items_au"));
}
#[test]
fn insert_item_and_fts_hit() {
let db = Database::open_in_memory().unwrap();
let now = 1_700_000_000i64;
db.conn()
.execute(
"INSERT INTO items(type,title,data,search_text,created_at,updated_at)
VALUES (?1,?2,?3,?4,?5,?6)",
rusqlite::params![
"password",
"GitHub Login",
"{}",
"github.com myuser",
now,
now,
],
)
.unwrap();
let hit: bool = db
.conn()
.prepare("SELECT 1 FROM items_fts WHERE items_fts MATCH ?1 LIMIT 1")
.unwrap()
.query_map(["github"], |r| r.get::<_, i64>(0))
.unwrap()
.filter_map(|r| r.ok())
.next()
.is_some();
assert!(hit, "FTS5 应命中 'github'");
let miss: bool = db
.conn()
.prepare("SELECT 1 FROM items_fts WHERE items_fts MATCH ?1 LIMIT 1")
.unwrap()
.query_map(["nomatchxyz"], |r| r.get::<_, i64>(0))
.unwrap()
.filter_map(|r| r.ok())
.next()
.is_some();
assert!(!miss);
}
#[test]
fn dump_from_bytes_roundtrip_preserves_data() {
let db = Database::open_in_memory().unwrap();
let now = 1_700_000_000i64;
db.conn()
.execute(
"INSERT INTO items(type,title,data,search_text,created_at,updated_at)
VALUES (?1,?2,?3,?4,?5,?6)",
rusqlite::params!["note", "My Note", "{\"content\":\"hi\"}", "hi body", now, now],
)
.unwrap();
db.conn()
.execute(
"INSERT INTO tags(name) VALUES ('personal')",
[],
)
.unwrap();
let bytes = db.dump_bytes().expect("dump");
assert!(!bytes.is_empty(), "dump 应产出非空字节");
let db2 = Database::from_bytes(&bytes).expect("from_bytes");
let cnt: i64 = db2
.conn()
.query_row("SELECT COUNT(*) FROM items", [], |r| r.get::<_, i64>(0))
.unwrap();
assert_eq!(cnt, 1);
let tag_cnt: i64 = db2
.conn()
.query_row("SELECT COUNT(*) FROM tags", [], |r| r.get::<_, i64>(0))
.unwrap();
assert_eq!(tag_cnt, 1);
let fts_hit: bool = db2
.conn()
.prepare("SELECT 1 FROM items_fts WHERE items_fts MATCH ?1 LIMIT 1")
.unwrap()
.query_map(["note"], |r| r.get::<_, i64>(0))
.unwrap()
.filter_map(|r| r.ok())
.next()
.is_some();
assert!(fts_hit, "round-trip 后 FTS5 仍应命中");
}
}