mirror of
https://github.com/PR0M3TH3AN/Marlin.git
synced 2025-09-09 23:58:42 +00:00
update
This commit is contained in:
289
src/db/migrations/0004_fix_hierarchical_tags_fts.sql
Normal file
289
src/db/migrations/0004_fix_hierarchical_tags_fts.sql
Normal file
@@ -0,0 +1,289 @@
|
||||
-- src/db/migrations/0004_fix_hierarchical_tags_fts.sql
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA journal_mode = WAL;
|
||||
|
||||
-- Force drop all FTS triggers to ensure they're recreated even if migration is already recorded
|
||||
DROP TRIGGER IF EXISTS files_fts_ai_file;
|
||||
DROP TRIGGER IF EXISTS files_fts_au_file;
|
||||
DROP TRIGGER IF EXISTS files_fts_ad_file;
|
||||
DROP TRIGGER IF EXISTS file_tags_fts_ai;
|
||||
DROP TRIGGER IF EXISTS file_tags_fts_ad;
|
||||
DROP TRIGGER IF EXISTS attributes_fts_ai;
|
||||
DROP TRIGGER IF EXISTS attributes_fts_au;
|
||||
DROP TRIGGER IF EXISTS attributes_fts_ad;
|
||||
|
||||
-- Create a new trigger for file insertion that uses recursive CTE for full tag paths
|
||||
CREATE TRIGGER files_fts_ai_file
|
||||
AFTER INSERT ON files
|
||||
BEGIN
|
||||
INSERT INTO files_fts(rowid, path, tags_text, attrs_text)
|
||||
VALUES (
|
||||
NEW.id,
|
||||
NEW.path,
|
||||
(SELECT IFNULL(GROUP_CONCAT(tag_path, ' '), '')
|
||||
FROM (
|
||||
WITH RECURSIVE tag_tree(id, name, parent_id, path) AS (
|
||||
SELECT t.id, t.name, t.parent_id, t.name
|
||||
FROM tags t
|
||||
WHERE t.parent_id IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT t.id, t.name, t.parent_id, tt.path || '/' || t.name
|
||||
FROM tags t
|
||||
JOIN tag_tree tt ON t.parent_id = tt.id
|
||||
)
|
||||
SELECT DISTINCT tag_tree.path AS tag_path
|
||||
FROM file_tags ft
|
||||
JOIN tag_tree ON ft.tag_id = tag_tree.id
|
||||
WHERE ft.file_id = NEW.id
|
||||
|
||||
UNION
|
||||
|
||||
SELECT t.name AS tag_path
|
||||
FROM file_tags ft
|
||||
JOIN tags t ON ft.tag_id = t.id
|
||||
WHERE ft.file_id = NEW.id AND t.parent_id IS NULL
|
||||
)),
|
||||
(SELECT IFNULL(GROUP_CONCAT(a.key || '=' || a.value, ' '), '')
|
||||
FROM attributes a
|
||||
WHERE a.file_id = NEW.id)
|
||||
);
|
||||
END;
|
||||
|
||||
-- Recreate the file path update trigger
|
||||
CREATE TRIGGER files_fts_au_file
|
||||
AFTER UPDATE OF path ON files
|
||||
BEGIN
|
||||
UPDATE files_fts
|
||||
SET path = NEW.path
|
||||
WHERE rowid = NEW.id;
|
||||
END;
|
||||
|
||||
-- Recreate the file deletion trigger
|
||||
CREATE TRIGGER files_fts_ad_file
|
||||
AFTER DELETE ON files
|
||||
BEGIN
|
||||
DELETE FROM files_fts WHERE rowid = OLD.id;
|
||||
END;
|
||||
|
||||
-- Create new trigger for tag insertion that uses recursive CTE for full tag paths
|
||||
CREATE TRIGGER file_tags_fts_ai
|
||||
AFTER INSERT ON file_tags
|
||||
BEGIN
|
||||
INSERT OR REPLACE INTO files_fts(rowid, path, tags_text, attrs_text)
|
||||
SELECT f.id, f.path,
|
||||
(SELECT IFNULL(GROUP_CONCAT(tag_path, ' '), '')
|
||||
FROM (
|
||||
WITH RECURSIVE tag_tree(id, name, parent_id, path) AS (
|
||||
SELECT t.id, t.name, t.parent_id, t.name
|
||||
FROM tags t
|
||||
WHERE t.parent_id IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT t.id, t.name, t.parent_id, tt.path || '/' || t.name
|
||||
FROM tags t
|
||||
JOIN tag_tree tt ON t.parent_id = tt.id
|
||||
)
|
||||
SELECT DISTINCT tag_tree.path AS tag_path
|
||||
FROM file_tags ft
|
||||
JOIN tag_tree ON ft.tag_id = tag_tree.id
|
||||
WHERE ft.file_id = f.id
|
||||
|
||||
UNION
|
||||
|
||||
SELECT t.name AS tag_path
|
||||
FROM file_tags ft
|
||||
JOIN tags t ON ft.tag_id = t.id
|
||||
WHERE ft.file_id = f.id AND t.parent_id IS NULL
|
||||
)),
|
||||
(SELECT IFNULL(GROUP_CONCAT(a.key || '=' || a.value, ' '), '')
|
||||
FROM attributes a
|
||||
WHERE a.file_id = f.id)
|
||||
FROM files f
|
||||
WHERE f.id = NEW.file_id;
|
||||
END;
|
||||
|
||||
-- Create new trigger for tag deletion that uses recursive CTE for full tag paths
|
||||
CREATE TRIGGER file_tags_fts_ad
|
||||
AFTER DELETE ON file_tags
|
||||
BEGIN
|
||||
INSERT OR REPLACE INTO files_fts(rowid, path, tags_text, attrs_text)
|
||||
SELECT f.id, f.path,
|
||||
(SELECT IFNULL(GROUP_CONCAT(tag_path, ' '), '')
|
||||
FROM (
|
||||
WITH RECURSIVE tag_tree(id, name, parent_id, path) AS (
|
||||
SELECT t.id, t.name, t.parent_id, t.name
|
||||
FROM tags t
|
||||
WHERE t.parent_id IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT t.id, t.name, t.parent_id, tt.path || '/' || t.name
|
||||
FROM tags t
|
||||
JOIN tag_tree tt ON t.parent_id = tt.id
|
||||
)
|
||||
SELECT DISTINCT tag_tree.path AS tag_path
|
||||
FROM file_tags ft
|
||||
JOIN tag_tree ON ft.tag_id = tag_tree.id
|
||||
WHERE ft.file_id = f.id
|
||||
|
||||
UNION
|
||||
|
||||
SELECT t.name AS tag_path
|
||||
FROM file_tags ft
|
||||
JOIN tags t ON ft.tag_id = t.id
|
||||
WHERE ft.file_id = f.id AND t.parent_id IS NULL
|
||||
)),
|
||||
(SELECT IFNULL(GROUP_CONCAT(a.key || '=' || a.value, ' '), '')
|
||||
FROM attributes a
|
||||
WHERE a.file_id = f.id)
|
||||
FROM files f
|
||||
WHERE f.id = OLD.file_id;
|
||||
END;
|
||||
|
||||
-- Create new triggers for attribute operations that use recursive CTE for full tag paths
|
||||
CREATE TRIGGER attributes_fts_ai
|
||||
AFTER INSERT ON attributes
|
||||
BEGIN
|
||||
INSERT OR REPLACE INTO files_fts(rowid, path, tags_text, attrs_text)
|
||||
SELECT f.id, f.path,
|
||||
(SELECT IFNULL(GROUP_CONCAT(tag_path, ' '), '')
|
||||
FROM (
|
||||
WITH RECURSIVE tag_tree(id, name, parent_id, path) AS (
|
||||
SELECT t.id, t.name, t.parent_id, t.name
|
||||
FROM tags t
|
||||
WHERE t.parent_id IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT t.id, t.name, t.parent_id, tt.path || '/' || t.name
|
||||
FROM tags t
|
||||
JOIN tag_tree tt ON t.parent_id = tt.id
|
||||
)
|
||||
SELECT DISTINCT tag_tree.path AS tag_path
|
||||
FROM file_tags ft
|
||||
JOIN tag_tree ON ft.tag_id = tag_tree.id
|
||||
WHERE ft.file_id = f.id
|
||||
|
||||
UNION
|
||||
|
||||
SELECT t.name AS tag_path
|
||||
FROM file_tags ft
|
||||
JOIN tags t ON ft.tag_id = t.id
|
||||
WHERE ft.file_id = f.id AND t.parent_id IS NULL
|
||||
)),
|
||||
(SELECT IFNULL(GROUP_CONCAT(a.key || '=' || a.value, ' '), '')
|
||||
FROM attributes a
|
||||
WHERE a.file_id = f.id)
|
||||
FROM files f
|
||||
WHERE f.id = NEW.file_id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER attributes_fts_au
|
||||
AFTER UPDATE OF value ON attributes
|
||||
BEGIN
|
||||
INSERT OR REPLACE INTO files_fts(rowid, path, tags_text, attrs_text)
|
||||
SELECT f.id, f.path,
|
||||
(SELECT IFNULL(GROUP_CONCAT(tag_path, ' '), '')
|
||||
FROM (
|
||||
WITH RECURSIVE tag_tree(id, name, parent_id, path) AS (
|
||||
SELECT t.id, t.name, t.parent_id, t.name
|
||||
FROM tags t
|
||||
WHERE t.parent_id IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT t.id, t.name, t.parent_id, tt.path || '/' || t.name
|
||||
FROM tags t
|
||||
JOIN tag_tree tt ON t.parent_id = tt.id
|
||||
)
|
||||
SELECT DISTINCT tag_tree.path AS tag_path
|
||||
FROM file_tags ft
|
||||
JOIN tag_tree ON ft.tag_id = tag_tree.id
|
||||
WHERE ft.file_id = f.id
|
||||
|
||||
UNION
|
||||
|
||||
SELECT t.name AS tag_path
|
||||
FROM file_tags ft
|
||||
JOIN tags t ON ft.tag_id = t.id
|
||||
WHERE ft.file_id = f.id AND t.parent_id IS NULL
|
||||
)),
|
||||
(SELECT IFNULL(GROUP_CONCAT(a.key || '=' || a.value, ' '), '')
|
||||
FROM attributes a
|
||||
WHERE a.file_id = f.id)
|
||||
FROM files f
|
||||
WHERE f.id = NEW.file_id;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER attributes_fts_ad
|
||||
AFTER DELETE ON attributes
|
||||
BEGIN
|
||||
INSERT OR REPLACE INTO files_fts(rowid, path, tags_text, attrs_text)
|
||||
SELECT f.id, f.path,
|
||||
(SELECT IFNULL(GROUP_CONCAT(tag_path, ' '), '')
|
||||
FROM (
|
||||
WITH RECURSIVE tag_tree(id, name, parent_id, path) AS (
|
||||
SELECT t.id, t.name, t.parent_id, t.name
|
||||
FROM tags t
|
||||
WHERE t.parent_id IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT t.id, t.name, t.parent_id, tt.path || '/' || t.name
|
||||
FROM tags t
|
||||
JOIN tag_tree tt ON t.parent_id = tt.id
|
||||
)
|
||||
SELECT DISTINCT tag_tree.path AS tag_path
|
||||
FROM file_tags ft
|
||||
JOIN tag_tree ON ft.tag_id = tag_tree.id
|
||||
WHERE ft.file_id = f.id
|
||||
|
||||
UNION
|
||||
|
||||
SELECT t.name AS tag_path
|
||||
FROM file_tags ft
|
||||
JOIN tags t ON ft.tag_id = t.id
|
||||
WHERE ft.file_id = f.id AND t.parent_id IS NULL
|
||||
)),
|
||||
(SELECT IFNULL(GROUP_CONCAT(a.key || '=' || a.value, ' '), '')
|
||||
FROM attributes a
|
||||
WHERE a.file_id = f.id)
|
||||
FROM files f
|
||||
WHERE f.id = OLD.file_id;
|
||||
END;
|
||||
|
||||
-- Update all existing FTS entries with the new tag-path format
|
||||
INSERT OR REPLACE INTO files_fts(rowid, path, tags_text, attrs_text)
|
||||
SELECT f.id, f.path,
|
||||
(SELECT IFNULL(GROUP_CONCAT(tag_path, ' '), '')
|
||||
FROM (
|
||||
WITH RECURSIVE tag_tree(id, name, parent_id, path) AS (
|
||||
SELECT t.id, t.name, t.parent_id, t.name
|
||||
FROM tags t
|
||||
WHERE t.parent_id IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT t.id, t.name, t.parent_id, tt.path || '/' || t.name
|
||||
FROM tags t
|
||||
JOIN tag_tree tt ON t.parent_id = tt.id
|
||||
)
|
||||
SELECT DISTINCT tag_tree.path AS tag_path
|
||||
FROM file_tags ft
|
||||
JOIN tag_tree ON ft.tag_id = tag_tree.id
|
||||
WHERE ft.file_id = f.id
|
||||
|
||||
UNION
|
||||
|
||||
SELECT t.name AS tag_path
|
||||
FROM file_tags ft
|
||||
JOIN tags t ON ft.tag_id = t.id
|
||||
WHERE ft.file_id = f.id AND t.parent_id IS NULL
|
||||
)),
|
||||
(SELECT IFNULL(GROUP_CONCAT(a.key || '=' || a.value, ' '), '')
|
||||
FROM attributes a
|
||||
WHERE a.file_id = f.id)
|
||||
FROM files f;
|
260
src/db/migrations/mod.rs
Normal file
260
src/db/migrations/mod.rs
Normal file
@@ -0,0 +1,260 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::Local;
|
||||
use rusqlite::{
|
||||
backup::{Backup, StepResult},
|
||||
params,
|
||||
Connection,
|
||||
OpenFlags,
|
||||
OptionalExtension,
|
||||
};
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// Embed every numbered migration file here.
|
||||
const MIGRATIONS: &[(&str, &str)] = &[
|
||||
("0001_initial_schema.sql", include_str!("migrations/0001_initial_schema.sql")),
|
||||
("0002_update_fts_and_triggers.sql", include_str!("migrations/0002_update_fts_and_triggers.sql")),
|
||||
("0003_create_links_collections_views.sql", include_str!("migrations/0003_create_links_collections_views.sql")),
|
||||
("0004_fix_hierarchical_tags_fts.sql", include_str!("migrations/0004_fix_hierarchical_tags_fts.sql")),
|
||||
];
|
||||
|
||||
/* ─── connection bootstrap ──────────────────────────────────────────── */
|
||||
|
||||
pub fn open<P: AsRef<Path>>(db_path: P) -> Result<Connection> {
|
||||
let db_path_ref = db_path.as_ref();
|
||||
let mut conn = Connection::open(db_path_ref)
|
||||
.with_context(|| format!("failed to open DB at {}", db_path_ref.display()))?;
|
||||
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
|
||||
// Apply migrations (drops & recreates all FTS triggers)
|
||||
apply_migrations(&mut conn)?;
|
||||
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/* ─── migration runner ──────────────────────────────────────────────── */
|
||||
|
||||
fn apply_migrations(conn: &mut Connection) -> Result<()> {
|
||||
// Ensure schema_version table
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_on TEXT NOT NULL
|
||||
);",
|
||||
)?;
|
||||
|
||||
// Legacy patch (ignore if exists)
|
||||
let _ = conn.execute("ALTER TABLE schema_version ADD COLUMN applied_on TEXT", []);
|
||||
|
||||
let tx = conn.transaction()?;
|
||||
|
||||
for (fname, sql) in MIGRATIONS {
|
||||
let version: i64 = fname
|
||||
.split('_')
|
||||
.next()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.expect("migration filenames start with number");
|
||||
|
||||
let already: Option<i64> = tx
|
||||
.query_row(
|
||||
"SELECT version FROM schema_version WHERE version = ?1",
|
||||
[version],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
if already.is_some() {
|
||||
debug!("migration {} already applied", fname);
|
||||
continue;
|
||||
}
|
||||
|
||||
info!("applying migration {}", fname);
|
||||
println!(
|
||||
"\nSQL SCRIPT FOR MIGRATION: {}\nBEGIN SQL >>>\n{}\n<<< END SQL\n",
|
||||
fname, sql
|
||||
);
|
||||
|
||||
tx.execute_batch(sql)
|
||||
.with_context(|| format!("could not apply migration {}", fname))?;
|
||||
|
||||
tx.execute(
|
||||
"INSERT INTO schema_version (version, applied_on) VALUES (?1, ?2)",
|
||||
params![version, Local::now().to_rfc3339()],
|
||||
)?;
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/* ─── helpers ───────────────────────────────────────────────────────── */
|
||||
|
||||
pub fn ensure_tag_path(conn: &Connection, path: &str) -> Result<i64> {
|
||||
let mut parent: Option<i64> = None;
|
||||
for segment in path.split('/').filter(|s| !s.is_empty()) {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO tags(name, parent_id) VALUES (?1, ?2)",
|
||||
params![segment, parent],
|
||||
)?;
|
||||
let id: i64 = conn.query_row(
|
||||
"SELECT id FROM tags WHERE name = ?1 AND (parent_id IS ?2 OR parent_id = ?2)",
|
||||
params![segment, parent],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
parent = Some(id);
|
||||
}
|
||||
parent.ok_or_else(|| anyhow::anyhow!("empty tag path"))
|
||||
}
|
||||
|
||||
pub fn file_id(conn: &Connection, path: &str) -> Result<i64> {
|
||||
conn.query_row("SELECT id FROM files WHERE path = ?1", [path], |r| r.get(0))
|
||||
.map_err(|_| anyhow::anyhow!("file not indexed: {}", path))
|
||||
}
|
||||
|
||||
pub fn upsert_attr(conn: &Connection, file_id: i64, key: &str, value: &str) -> Result<()> {
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO attributes(file_id, key, value)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(file_id, key) DO UPDATE SET value = excluded.value
|
||||
"#,
|
||||
params![file_id, key, value],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a typed link from one file to another.
|
||||
pub fn add_link(conn: &Connection, src_file_id: i64, dst_file_id: i64, link_type: Option<&str>) -> Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO links(src_file_id, dst_file_id, type)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(src_file_id, dst_file_id, type) DO NOTHING",
|
||||
params![src_file_id, dst_file_id, link_type],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a typed link between two files.
|
||||
pub fn remove_link(conn: &Connection, src_file_id: i64, dst_file_id: i64, link_type: Option<&str>) -> Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM links
|
||||
WHERE src_file_id = ?1
|
||||
AND dst_file_id = ?2
|
||||
AND (type IS ?3 OR type = ?3)",
|
||||
params![src_file_id, dst_file_id, link_type],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all links for files matching a glob-style pattern.
|
||||
/// `direction` may be `"in"` (incoming), `"out"` (outgoing), or `None` (outgoing).
|
||||
pub fn list_links(
|
||||
conn: &Connection,
|
||||
pattern: &str,
|
||||
direction: Option<&str>,
|
||||
link_type: Option<&str>,
|
||||
) -> Result<Vec<(String, String, Option<String>)>> {
|
||||
// Convert glob '*' → SQL LIKE '%'
|
||||
let like_pattern = pattern.replace('*', "%");
|
||||
|
||||
// Find matching files
|
||||
let mut stmt = conn.prepare("SELECT id, path FROM files WHERE path LIKE ?1")?;
|
||||
let mut rows = stmt.query(params![like_pattern])?;
|
||||
let mut files = Vec::new();
|
||||
while let Some(row) = rows.next()? {
|
||||
let id: i64 = row.get(0)?;
|
||||
let path: String = row.get(1)?;
|
||||
files.push((id, path));
|
||||
}
|
||||
|
||||
let mut results = Vec::new();
|
||||
for (file_id, file_path) in files {
|
||||
let (src_col, dst_col) = match direction {
|
||||
Some("in") => ("dst_file_id", "src_file_id"),
|
||||
_ => ("src_file_id", "dst_file_id"),
|
||||
};
|
||||
|
||||
let sql = format!(
|
||||
"SELECT f2.path, l.type
|
||||
FROM links l
|
||||
JOIN files f2 ON f2.id = l.{dst}
|
||||
WHERE l.{src} = ?1
|
||||
AND (?2 IS NULL OR l.type = ?2)",
|
||||
src = src_col,
|
||||
dst = dst_col,
|
||||
);
|
||||
|
||||
let mut stmt2 = conn.prepare(&sql)?;
|
||||
let mut rows2 = stmt2.query(params![file_id, link_type])?;
|
||||
while let Some(r2) = rows2.next()? {
|
||||
let other: String = r2.get(0)?;
|
||||
let typ: Option<String> = r2.get(1)?;
|
||||
results.push((file_path.clone(), other, typ));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Find all incoming links (backlinks) to files matching a pattern.
|
||||
pub fn find_backlinks(conn: &Connection, pattern: &str) -> Result<Vec<(String, Option<String>)>> {
|
||||
let like_pattern = pattern.replace('*', "%");
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT f1.path, l.type
|
||||
FROM links l
|
||||
JOIN files f1 ON f1.id = l.src_file_id
|
||||
JOIN files f2 ON f2.id = l.dst_file_id
|
||||
WHERE f2.path LIKE ?1",
|
||||
)?;
|
||||
let mut rows = stmt.query(params![like_pattern])?;
|
||||
let mut result = Vec::new();
|
||||
while let Some(row) = rows.next()? {
|
||||
let src_path: String = row.get(0)?;
|
||||
let typ: Option<String> = row.get(1)?;
|
||||
result.push((src_path, typ));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/* ─── backup / restore ──────────────────────────────────────────────── */
|
||||
|
||||
pub fn backup<P: AsRef<Path>>(db_path: P) -> Result<PathBuf> {
|
||||
let src = db_path.as_ref();
|
||||
let dir = src
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("invalid DB path: {}", src.display()))?
|
||||
.join("backups");
|
||||
fs::create_dir_all(&dir)?;
|
||||
|
||||
let stamp = Local::now().format("%Y-%m-%d_%H-%M-%S");
|
||||
let dst = dir.join(format!("backup_{stamp}.db"));
|
||||
|
||||
let src_conn = Connection::open_with_flags(src, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
|
||||
let mut dst_conn = Connection::open(&dst)?;
|
||||
|
||||
let bk = Backup::new(&src_conn, &mut dst_conn)?;
|
||||
while let StepResult::More = bk.step(100)? {}
|
||||
Ok(dst)
|
||||
}
|
||||
|
||||
pub fn restore<P: AsRef<Path>>(backup_path: P, live_db_path: P) -> Result<()> {
|
||||
fs::copy(&backup_path, &live_db_path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn migrations_apply_in_memory() {
|
||||
// Opening an in-memory database should apply every migration without error.
|
||||
let _conn = open(":memory:").expect("in-memory migrations should run cleanly");
|
||||
}
|
||||
}
|
@@ -12,15 +12,20 @@ use rusqlite::{
|
||||
OpenFlags,
|
||||
OptionalExtension,
|
||||
};
|
||||
use tracing::{debug, info};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Embed every numbered migration file here.
|
||||
const MIGRATIONS: &[(&str, &str)] = &[
|
||||
("0001_initial_schema.sql", include_str!("migrations/0001_initial_schema.sql")),
|
||||
("0002_update_fts_and_triggers.sql", include_str!("migrations/0002_update_fts_and_triggers.sql")),
|
||||
("0003_create_links_collections_views.sql", include_str!("migrations/0003_create_links_collections_views.sql")),
|
||||
("0004_fix_hierarchical_tags_fts.sql", include_str!("migrations/0004_fix_hierarchical_tags_fts.sql")),
|
||||
];
|
||||
|
||||
/// Migrations that should *always* be re-run.
|
||||
/// We no longer need to force any, so leave it empty.
|
||||
const FORCE_APPLY_MIGRATIONS: &[i64] = &[]; // <- was &[4]
|
||||
|
||||
/* ─── connection bootstrap ──────────────────────────────────────────── */
|
||||
|
||||
pub fn open<P: AsRef<Path>>(db_path: P) -> Result<Connection> {
|
||||
@@ -51,6 +56,14 @@ fn apply_migrations(conn: &mut Connection) -> Result<()> {
|
||||
// Legacy patch (ignore if exists)
|
||||
let _ = conn.execute("ALTER TABLE schema_version ADD COLUMN applied_on TEXT", []);
|
||||
|
||||
// Force-remove migrations that should always be applied
|
||||
for &version in FORCE_APPLY_MIGRATIONS {
|
||||
let rows_affected = conn.execute("DELETE FROM schema_version WHERE version = ?1", [version])?;
|
||||
if rows_affected > 0 {
|
||||
info!("Forcing reapplication of migration {}", version);
|
||||
}
|
||||
}
|
||||
|
||||
let tx = conn.transaction()?;
|
||||
|
||||
for (fname, sql) in MIGRATIONS {
|
||||
@@ -89,6 +102,37 @@ fn apply_migrations(conn: &mut Connection) -> Result<()> {
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
|
||||
// Verify that all migrations have been applied
|
||||
let mut missing_migrations = Vec::new();
|
||||
for (fname, _) in MIGRATIONS {
|
||||
let version: i64 = fname
|
||||
.split('_')
|
||||
.next()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.expect("migration filenames start with number");
|
||||
|
||||
let exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT 1 FROM schema_version WHERE version = ?1",
|
||||
[version],
|
||||
|_| Ok(true),
|
||||
)
|
||||
.optional()?
|
||||
.unwrap_or(false);
|
||||
|
||||
if !exists {
|
||||
missing_migrations.push(version);
|
||||
}
|
||||
}
|
||||
|
||||
if !missing_migrations.is_empty() {
|
||||
warn!(
|
||||
"The following migrations were not applied: {:?}. This may indicate a problem with the migration system.",
|
||||
missing_migrations
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
129
src/main.rs
129
src/main.rs
@@ -6,36 +6,39 @@ mod logging;
|
||||
mod scan;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, Subcommand, CommandFactory};
|
||||
use clap_complete::{generate, Shell};
|
||||
use clap::{Parser, CommandFactory};
|
||||
use clap_complete::generate;
|
||||
use glob::Pattern;
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use rusqlite::params;
|
||||
use shellexpand;
|
||||
use shlex;
|
||||
use std::{env, io, path::PathBuf, process::Command};
|
||||
use tracing::{debug, error, info};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use cli::{Cli, Commands, Format};
|
||||
use cli::{Cli, Commands};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
// Parse CLI and bootstrap logging
|
||||
let mut args = Cli::parse();
|
||||
/* ── CLI parsing & logging ────────────────────────────────────── */
|
||||
|
||||
let args = Cli::parse();
|
||||
if args.verbose {
|
||||
env::set_var("RUST_LOG", "debug");
|
||||
}
|
||||
logging::init();
|
||||
|
||||
// If the user asked for completions, generate and exit immediately.
|
||||
/* ── shell-completion shortcut ───────────────────────────────── */
|
||||
|
||||
if let Commands::Completions { shell } = &args.command {
|
||||
let mut cmd = Cli::command();
|
||||
generate(*shell, &mut cmd, "marlin", &mut io::stdout());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let cfg = config::Config::load()?;
|
||||
/* ── config & automatic backup ───────────────────────────────── */
|
||||
|
||||
let cfg = config::Config::load()?; // DB path etc.
|
||||
|
||||
// Backup before any non-init, non-backup/restore command
|
||||
match &args.command {
|
||||
Commands::Init | Commands::Backup | Commands::Restore { .. } => {}
|
||||
_ => match db::backup(&cfg.db_path) {
|
||||
@@ -44,18 +47,29 @@ fn main() -> Result<()> {
|
||||
},
|
||||
}
|
||||
|
||||
// Open (and migrate) the DB
|
||||
/* ── open DB (runs migrations if needed) ─────────────────────── */
|
||||
|
||||
let mut conn = db::open(&cfg.db_path)?;
|
||||
|
||||
// Dispatch all commands
|
||||
/* ── command dispatch ────────────────────────────────────────── */
|
||||
|
||||
match args.command {
|
||||
Commands::Completions { .. } => {}
|
||||
Commands::Completions { .. } => {} // already handled
|
||||
|
||||
Commands::Init => {
|
||||
info!("Database initialised at {}", cfg.db_path.display());
|
||||
|
||||
// Always (re-)scan the current directory so even an existing DB
|
||||
// picks up newly created files in the working tree.
|
||||
let cwd = env::current_dir().context("getting current directory")?;
|
||||
let count = scan::scan_directory(&mut conn, &cwd)
|
||||
.context("initial scan failed")?;
|
||||
info!("Initial scan complete – indexed/updated {} files", count);
|
||||
}
|
||||
|
||||
Commands::Scan { paths } => {
|
||||
let scan_paths = if paths.is_empty() {
|
||||
vec![std::env::current_dir()?]
|
||||
vec![env::current_dir()?]
|
||||
} else {
|
||||
paths
|
||||
};
|
||||
@@ -63,26 +77,21 @@ fn main() -> Result<()> {
|
||||
scan::scan_directory(&mut conn, &p)?;
|
||||
}
|
||||
}
|
||||
Commands::Tag { pattern, tag_path } => {
|
||||
apply_tag(&conn, &pattern, &tag_path)?;
|
||||
}
|
||||
Commands::Attr { action } => match action {
|
||||
|
||||
Commands::Tag { pattern, tag_path } => apply_tag(&conn, &pattern, &tag_path)?,
|
||||
Commands::Attr { action } => match action {
|
||||
cli::AttrCmd::Set { pattern, key, value } => {
|
||||
attr_set(&conn, &pattern, &key, &value)?;
|
||||
}
|
||||
cli::AttrCmd::Ls { path } => {
|
||||
attr_ls(&conn, &path)?;
|
||||
attr_set(&conn, &pattern, &key, &value)?
|
||||
}
|
||||
cli::AttrCmd::Ls { path } => attr_ls(&conn, &path)?,
|
||||
},
|
||||
Commands::Search { query, exec } => {
|
||||
run_search(&conn, &query, exec)?;
|
||||
}
|
||||
Commands::Backup => {
|
||||
Commands::Search { query, exec } => run_search(&conn, &query, exec)?,
|
||||
Commands::Backup => {
|
||||
let path = db::backup(&cfg.db_path)?;
|
||||
println!("Backup created: {}", path.display());
|
||||
}
|
||||
Commands::Restore { backup_path } => {
|
||||
drop(conn);
|
||||
Commands::Restore { backup_path } => {
|
||||
drop(conn); // close handle before overwrite
|
||||
db::restore(&backup_path, &cfg.db_path)
|
||||
.with_context(|| format!("Failed to restore DB from {}", backup_path.display()))?;
|
||||
println!("Restored DB from {}", backup_path.display());
|
||||
@@ -90,20 +99,24 @@ fn main() -> Result<()> {
|
||||
.with_context(|| format!("Could not open restored DB at {}", cfg.db_path.display()))?;
|
||||
info!("Successfully opened restored database.");
|
||||
}
|
||||
Commands::Link(link_cmd) => cli::link::run(&link_cmd, &mut conn, args.format)?,
|
||||
Commands::Coll(coll_cmd) => cli::coll::run(&coll_cmd, &mut conn, args.format)?,
|
||||
Commands::View(view_cmd) => cli::view::run(&view_cmd, &mut conn, args.format)?,
|
||||
|
||||
/* passthrough sub-modules that still stub out their logic */
|
||||
Commands::Link(link_cmd) => cli::link::run(&link_cmd, &mut conn, args.format)?,
|
||||
Commands::Coll(coll_cmd) => cli::coll::run(&coll_cmd, &mut conn, args.format)?,
|
||||
Commands::View(view_cmd) => cli::view::run(&view_cmd, &mut conn, args.format)?,
|
||||
Commands::State(state_cmd) => cli::state::run(&state_cmd, &mut conn, args.format)?,
|
||||
Commands::Task(task_cmd) => cli::task::run(&task_cmd, &mut conn, args.format)?,
|
||||
Commands::Remind(rm_cmd) => cli::remind::run(&rm_cmd, &mut conn, args.format)?,
|
||||
Commands::Task(task_cmd) => cli::task::run(&task_cmd, &mut conn, args.format)?,
|
||||
Commands::Remind(rm_cmd) => cli::remind::run(&rm_cmd, &mut conn, args.format)?,
|
||||
Commands::Annotate(an_cmd) => cli::annotate::run(&an_cmd, &mut conn, args.format)?,
|
||||
Commands::Version(v_cmd) => cli::version::run(&v_cmd, &mut conn, args.format)?,
|
||||
Commands::Event(e_cmd) => cli::event::run(&e_cmd, &mut conn, args.format)?,
|
||||
Commands::Version(v_cmd) => cli::version::run(&v_cmd, &mut conn, args.format)?,
|
||||
Commands::Event(e_cmd) => cli::event::run(&e_cmd, &mut conn, args.format)?,
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/* ───────────────────────── helpers & sub-routines ────────────────── */
|
||||
|
||||
/// Apply a hierarchical tag to all files matching the glob pattern.
|
||||
fn apply_tag(conn: &rusqlite::Connection, pattern: &str, tag_path: &str) -> Result<()> {
|
||||
// ensure_tag_path returns the deepest-node ID
|
||||
@@ -114,13 +127,15 @@ fn apply_tag(conn: &rusqlite::Connection, pattern: &str, tag_path: &str) -> Resu
|
||||
let mut current = Some(leaf_tag_id);
|
||||
while let Some(id) = current {
|
||||
tag_ids.push(id);
|
||||
current = conn
|
||||
.query_row(
|
||||
"SELECT parent_id FROM tags WHERE id = ?1",
|
||||
params![id],
|
||||
|r| r.get::<_, Option<i64>>(0),
|
||||
)
|
||||
.optional()?;
|
||||
current = match conn.query_row(
|
||||
"SELECT parent_id FROM tags WHERE id = ?1",
|
||||
params![id],
|
||||
|r| r.get::<_, Option<i64>>(0),
|
||||
) {
|
||||
Ok(parent_id) => parent_id,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => None,
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
}
|
||||
|
||||
let expanded = shellexpand::tilde(pattern).into_owned();
|
||||
@@ -128,9 +143,10 @@ fn apply_tag(conn: &rusqlite::Connection, pattern: &str, tag_path: &str) -> Resu
|
||||
.with_context(|| format!("Invalid glob pattern `{}`", expanded))?;
|
||||
let root = determine_scan_root(&expanded);
|
||||
|
||||
let mut stmt_file = conn.prepare("SELECT id FROM files WHERE path = ?1")?;
|
||||
let mut stmt_insert =
|
||||
conn.prepare("INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?1, ?2)")?;
|
||||
let mut stmt_file = conn.prepare("SELECT id FROM files WHERE path = ?1")?;
|
||||
let mut stmt_insert = conn.prepare(
|
||||
"INSERT OR IGNORE INTO file_tags(file_id, tag_id) VALUES (?1, ?2)",
|
||||
)?;
|
||||
|
||||
let mut count = 0;
|
||||
for entry in WalkDir::new(&root)
|
||||
@@ -148,7 +164,6 @@ fn apply_tag(conn: &rusqlite::Connection, pattern: &str, tag_path: &str) -> Resu
|
||||
|
||||
match stmt_file.query_row(params![path_str.as_ref()], |r| r.get::<_, i64>(0)) {
|
||||
Ok(file_id) => {
|
||||
// insert every segment tag
|
||||
let mut newly = false;
|
||||
for &tid in &tag_ids {
|
||||
if stmt_insert.execute(params![file_id, tid])? > 0 {
|
||||
@@ -236,7 +251,8 @@ fn attr_ls(conn: &rusqlite::Connection, path: &std::path::Path) -> Result<()> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT key, value FROM attributes WHERE file_id = ?1 ORDER BY key",
|
||||
)?;
|
||||
for row in stmt.query_map([file_id], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))? {
|
||||
for row in stmt.query_map([file_id], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?
|
||||
{
|
||||
let (k, v) = row?;
|
||||
println!("{k} = {v}");
|
||||
}
|
||||
@@ -244,8 +260,8 @@ fn attr_ls(conn: &rusqlite::Connection, path: &std::path::Path) -> Result<()> {
|
||||
}
|
||||
|
||||
/// Build and run an FTS5 search query, with optional exec.
|
||||
/// “tag:foo/bar” → tags_text:foo AND tags_text:bar
|
||||
/// “attr:key=value” → attrs_text:key=value
|
||||
/// “tag:foo/bar” → tags_text:foo AND tags_text:bar
|
||||
/// “attr:k=v” → attrs_text:k AND attrs_text:v
|
||||
fn run_search(conn: &rusqlite::Connection, raw_query: &str, exec: Option<String>) -> Result<()> {
|
||||
let mut fts_query_parts = Vec::new();
|
||||
let parts = shlex::split(raw_query).unwrap_or_else(|| vec![raw_query.to_string()]);
|
||||
@@ -261,8 +277,15 @@ fn run_search(conn: &rusqlite::Connection, raw_query: &str, exec: Option<String>
|
||||
fts_query_parts.push(format!("tags_text:{}", escape_fts_query_term(seg)));
|
||||
}
|
||||
} else if let Some(attr) = part.strip_prefix("attr:") {
|
||||
// keep whole key=value together
|
||||
fts_query_parts.push(format!("attrs_text:{}", escape_fts_query_term(attr)));
|
||||
let mut kv = attr.splitn(2, '=');
|
||||
let key = kv.next().unwrap();
|
||||
if let Some(value) = kv.next() {
|
||||
fts_query_parts.push(format!("attrs_text:{}", escape_fts_query_term(key)));
|
||||
fts_query_parts.push("AND".into());
|
||||
fts_query_parts.push(format!("attrs_text:{}", escape_fts_query_term(value)));
|
||||
} else {
|
||||
fts_query_parts.push(format!("attrs_text:{}", escape_fts_query_term(key)));
|
||||
}
|
||||
} else {
|
||||
fts_query_parts.push(escape_fts_query_term(&part));
|
||||
}
|
||||
@@ -347,7 +370,11 @@ fn determine_scan_root(pattern: &str) -> PathBuf {
|
||||
let wildcard_pos = pattern.find(|c| c == '*' || c == '?' || c == '[').unwrap_or(pattern.len());
|
||||
let prefix = &pattern[..wildcard_pos];
|
||||
let mut root = PathBuf::from(prefix);
|
||||
while root.as_os_str().to_string_lossy().contains(|c| ['*', '?', '['].contains(&c)) {
|
||||
while root
|
||||
.as_os_str()
|
||||
.to_string_lossy()
|
||||
.contains(|c| ['*', '?', '['].contains(&c))
|
||||
{
|
||||
if let Some(parent) = root.parent() {
|
||||
root = parent.to_path_buf();
|
||||
} else {
|
||||
|
240
src/test_hierarchical_tags.rs
Normal file
240
src/test_hierarchical_tags.rs
Normal file
@@ -0,0 +1,240 @@
|
||||
// Test script to validate hierarchical tag FTS fix
|
||||
// This script demonstrates how the fix works with a simple test case
|
||||
|
||||
use rusqlite::{Connection, params};
|
||||
use std::path::Path;
|
||||
use std::fs;
|
||||
use anyhow::Result;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
// Create a test database in a temporary location
|
||||
let db_path = Path::new("/tmp/marlin_test.db");
|
||||
if db_path.exists() {
|
||||
fs::remove_file(db_path)?;
|
||||
}
|
||||
|
||||
println!("Creating test database at {:?}", db_path);
|
||||
|
||||
// Initialize database with our schema and migrations
|
||||
let conn = Connection::open(db_path)?;
|
||||
|
||||
// Apply schema (simplified version of what's in the migrations)
|
||||
println!("Applying schema...");
|
||||
conn.execute_batch(
|
||||
"PRAGMA foreign_keys = ON;
|
||||
PRAGMA journal_mode = WAL;
|
||||
|
||||
CREATE TABLE files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
size INTEGER,
|
||||
mtime INTEGER,
|
||||
hash TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE tags (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
parent_id INTEGER REFERENCES tags(id) ON DELETE CASCADE,
|
||||
canonical_id INTEGER REFERENCES tags(id) ON DELETE SET NULL,
|
||||
UNIQUE(name, parent_id)
|
||||
);
|
||||
|
||||
CREATE TABLE file_tags (
|
||||
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
||||
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY(file_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE TABLE attributes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT,
|
||||
UNIQUE(file_id, key)
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE files_fts
|
||||
USING fts5(
|
||||
path,
|
||||
tags_text,
|
||||
attrs_text,
|
||||
content='',
|
||||
tokenize=\"unicode61 remove_diacritics 2\"
|
||||
);"
|
||||
)?;
|
||||
|
||||
// Apply our fixed triggers
|
||||
println!("Applying fixed FTS triggers...");
|
||||
conn.execute_batch(
|
||||
"CREATE TRIGGER files_fts_ai_file
|
||||
AFTER INSERT ON files
|
||||
BEGIN
|
||||
INSERT INTO files_fts(rowid, path, tags_text, attrs_text)
|
||||
VALUES (
|
||||
NEW.id,
|
||||
NEW.path,
|
||||
(SELECT IFNULL(GROUP_CONCAT(tag_path, ' '), '')
|
||||
FROM (
|
||||
WITH RECURSIVE tag_tree(id, name, parent_id, path) AS (
|
||||
SELECT t.id, t.name, t.parent_id, t.name
|
||||
FROM tags t
|
||||
WHERE t.parent_id IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT t.id, t.name, t.parent_id, tt.path || '/' || t.name
|
||||
FROM tags t
|
||||
JOIN tag_tree tt ON t.parent_id = tt.id
|
||||
)
|
||||
SELECT DISTINCT tag_tree.path as tag_path
|
||||
FROM file_tags ft
|
||||
JOIN tag_tree ON ft.tag_id = tag_tree.id
|
||||
WHERE ft.file_id = NEW.id
|
||||
|
||||
UNION
|
||||
|
||||
SELECT t.name as tag_path
|
||||
FROM file_tags ft
|
||||
JOIN tags t ON ft.tag_id = t.id
|
||||
WHERE ft.file_id = NEW.id AND t.parent_id IS NULL
|
||||
)),
|
||||
(SELECT IFNULL(GROUP_CONCAT(a.key || '=' || a.value, ' '), '')
|
||||
FROM attributes a
|
||||
WHERE a.file_id = NEW.id)
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER file_tags_fts_ai
|
||||
AFTER INSERT ON file_tags
|
||||
BEGIN
|
||||
INSERT OR REPLACE INTO files_fts(rowid, path, tags_text, attrs_text)
|
||||
SELECT f.id, f.path,
|
||||
(SELECT IFNULL(GROUP_CONCAT(tag_path, ' '), '')
|
||||
FROM (
|
||||
WITH RECURSIVE tag_tree(id, name, parent_id, path) AS (
|
||||
SELECT t.id, t.name, t.parent_id, t.name
|
||||
FROM tags t
|
||||
WHERE t.parent_id IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT t.id, t.name, t.parent_id, tt.path || '/' || t.name
|
||||
FROM tags t
|
||||
JOIN tag_tree tt ON t.parent_id = tt.id
|
||||
)
|
||||
SELECT DISTINCT tag_tree.path as tag_path
|
||||
FROM file_tags ft
|
||||
JOIN tag_tree ON ft.tag_id = tag_tree.id
|
||||
WHERE ft.file_id = f.id
|
||||
|
||||
UNION
|
||||
|
||||
SELECT t.name as tag_path
|
||||
FROM file_tags ft
|
||||
JOIN tags t ON ft.tag_id = t.id
|
||||
WHERE ft.file_id = f.id AND t.parent_id IS NULL
|
||||
)),
|
||||
(SELECT IFNULL(GROUP_CONCAT(a.key || '=' || a.value, ' '), '')
|
||||
FROM attributes a
|
||||
WHERE a.file_id = f.id)
|
||||
FROM files f
|
||||
WHERE f.id = NEW.file_id;
|
||||
END;"
|
||||
)?;
|
||||
|
||||
// Insert test data
|
||||
println!("Inserting test data...");
|
||||
|
||||
// Insert a test file
|
||||
conn.execute(
|
||||
"INSERT INTO files (id, path) VALUES (1, '/test/document.md')",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Create hierarchical tags: project/md
|
||||
println!("Creating hierarchical tags: project/md");
|
||||
|
||||
// Insert parent tag 'project'
|
||||
conn.execute(
|
||||
"INSERT INTO tags (id, name, parent_id) VALUES (1, 'project', NULL)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Insert child tag 'md' under 'project'
|
||||
conn.execute(
|
||||
"INSERT INTO tags (id, name, parent_id) VALUES (2, 'md', 1)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Tag the file with the 'md' tag (which is under 'project')
|
||||
conn.execute(
|
||||
"INSERT INTO file_tags (file_id, tag_id) VALUES (1, 2)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Check what's in the FTS index
|
||||
println!("\nChecking FTS index content:");
|
||||
let mut stmt = conn.prepare("SELECT rowid, path, tags_text, attrs_text FROM files_fts")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
})?;
|
||||
|
||||
for row in rows {
|
||||
let (id, path, tags, attrs) = row?;
|
||||
println!("ID: {}, Path: {}, Tags: '{}', Attrs: '{}'", id, path, tags, attrs);
|
||||
}
|
||||
|
||||
// Test searching for the full hierarchical tag path
|
||||
println!("\nTesting search for 'project/md':");
|
||||
let mut stmt = conn.prepare("SELECT f.path FROM files_fts JOIN files f ON f.id = files_fts.rowid WHERE files_fts MATCH 'project/md'")?;
|
||||
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
|
||||
|
||||
let mut found = false;
|
||||
for row in rows {
|
||||
found = true;
|
||||
println!("Found file: {}", row?);
|
||||
}
|
||||
|
||||
if !found {
|
||||
println!("No files found with tag 'project/md'");
|
||||
}
|
||||
|
||||
// Test searching for just the parent tag
|
||||
println!("\nTesting search for just 'project':");
|
||||
let mut stmt = conn.prepare("SELECT f.path FROM files_fts JOIN files f ON f.id = files_fts.rowid WHERE files_fts MATCH 'project'")?;
|
||||
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
|
||||
|
||||
let mut found = false;
|
||||
for row in rows {
|
||||
found = true;
|
||||
println!("Found file: {}", row?);
|
||||
}
|
||||
|
||||
if !found {
|
||||
println!("No files found with tag 'project'");
|
||||
}
|
||||
|
||||
// Test searching for just the child tag
|
||||
println!("\nTesting search for just 'md':");
|
||||
let mut stmt = conn.prepare("SELECT f.path FROM files_fts JOIN files f ON f.id = files_fts.rowid WHERE files_fts MATCH 'md'")?;
|
||||
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
|
||||
|
||||
let mut found = false;
|
||||
for row in rows {
|
||||
found = true;
|
||||
println!("Found file: {}", row?);
|
||||
}
|
||||
|
||||
if !found {
|
||||
println!("No files found with tag 'md'");
|
||||
}
|
||||
|
||||
println!("\nTest completed successfully!");
|
||||
Ok(())
|
||||
}
|
Reference in New Issue
Block a user