Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion crates/paavo-db/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,18 @@ description = "SQLite schema, migrations, and typed queries for paavo."

[dependencies]
paavo-proto = { workspace = true }
rusqlite = { workspace = true }
rusqlite = { workspace = true, features = ["functions"] }
refinery = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
ulid = { workspace = true }
chrono = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
# Skim-style fuzzy matcher backing the `fuzzy_score` SQLite scalar function
# (crate::db). Not workspace-pinned — paavo-db is the only crate that ranks
# fuzzy matches. Mirrors the dep paavo-web previously carried.
fuzzy-matcher = "0.3"

[dev-dependencies]
tempfile = { workspace = true }
16 changes: 1 addition & 15 deletions crates/paavo-db/src/board.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Board table typed helpers.

use crate::error::{DbError, Result};
use crate::like::escape_like;
use paavo_proto::{BoardHealth, BoardSelector, BoardSpec, ProbeSelector};
use rusqlite::{params, Connection, Error as RusqliteError, ErrorCode, OptionalExtension, Row};

Expand Down Expand Up @@ -363,21 +364,6 @@ fn require_one_row(n: usize, id: &str) -> Result<()> {
}
}

/// Escape `LIKE` wildcards in a user-supplied substring so `%`, `_`, and
/// `\` are matched literally (paired with `ESCAPE '\'` in the query). Used
/// by [`BoardRow::list_page`] / [`BoardRow::count`] so a filter like `a_b`
/// matches the literal characters rather than `_` standing for any char.
fn escape_like(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if c == '%' || c == '_' || c == '\\' {
out.push('\\');
}
out.push(c);
}
out
}

fn health_to_str(h: BoardHealth) -> &'static str {
match h {
BoardHealth::Healthy => "healthy",
Expand Down
63 changes: 62 additions & 1 deletion crates/paavo-db/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
//! (paavo-web). WAL mode + busy timeout.

use crate::error::{DbError, Result};
use rusqlite::{Connection, OpenFlags};
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use rusqlite::functions::FunctionFlags;
use rusqlite::{Connection, Error, OpenFlags};
use std::path::Path;

mod embedded {
Expand Down Expand Up @@ -59,5 +62,63 @@ fn configure(conn: &mut Connection, readonly: bool) -> Result<()> {
conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
}
register_fuzzy_score(conn)?;
Ok(())
}

/// Register `fuzzy_score(haystack, needle) -> Option<i64>`: the
/// `SkimMatcherV2` ranking the web UI used to run in memory, now callable
/// from SQL by `JobRow::search_index_page`. Registered on every connection
/// (RW + RO); only the RO search path invokes it. `None` (no match) maps to
/// SQL `NULL`.
fn register_fuzzy_score(conn: &Connection) -> Result<()> {
// Built once and captured: the function runs per matched row, so
// rebuilding the matcher each call would waste its scoring tables.
// SkimMatcherV2 is plain config data (Send + 'static); the closure bounds
// are satisfied and create_scalar_function is safe (no `unsafe`).
let matcher = SkimMatcherV2::default();
conn.create_scalar_function(
"fuzzy_score",
2,
FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC,
move |ctx| {
// get_raw().as_str() borrows the column bytes — no per-row String.
let haystack = ctx
.get_raw(0)
.as_str()
.map_err(|e| Error::UserFunctionError(e.into()))?;
let needle = ctx
.get_raw(1)
.as_str()
.map_err(|e| Error::UserFunctionError(e.into()))?;
Ok(matcher.fuzzy_match(haystack, needle))
},
)?;
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;

#[test]
fn fuzzy_score_some_for_subsequence_none_otherwise() {
let dir = tempdir().unwrap();
let db = Db::open(dir.path().join("t.sqlite")).unwrap();
let c = db.raw_conn();
let hit: Option<i64> = c
.query_row("SELECT fuzzy_score('alice mcxa266-01', 'almcx')", [], |r| {
r.get(0)
})
.unwrap();
assert!(
hit.is_some(),
"almcx is a subsequence of 'alice mcxa266-01'"
);
let miss: Option<i64> = c
.query_row("SELECT fuzzy_score('bob', 'almcx')", [], |r| r.get(0))
.unwrap();
assert!(miss.is_none(), "almcx is not a subsequence of 'bob'");
}
}
Loading
Loading