forked from akai/readest
chore(database): unit testing and feature detect for fts and vector search (#3476)
This commit is contained in:
@@ -99,6 +99,7 @@
|
||||
"@tauri-apps/plugin-shell": "~2.3.5",
|
||||
"@tauri-apps/plugin-updater": "^2.10.0",
|
||||
"@tauri-apps/plugin-websocket": "~2.4.2",
|
||||
"@tursodatabase/database-common": "^0.5.0",
|
||||
"@tursodatabase/database-wasm": "^0.5.0",
|
||||
"@tybys/wasm-util": "^0.10.1",
|
||||
"@zip.js/zip.js": "^2.8.16",
|
||||
|
||||
Submodule apps/readest-app/src-tauri/plugins/tauri-plugin-libsql updated: b1b1d671c5...4fc646e66b
@@ -0,0 +1,329 @@
|
||||
import { it, expect } from 'vitest';
|
||||
import { DatabaseService } from '@/types/database';
|
||||
|
||||
/**
|
||||
* Shared full-text search test cases for Turso's native FTS (Tantivy-based).
|
||||
* Call this inside a describe() block after setting up a DatabaseService instance
|
||||
* opened with { experimental: ['index_method'] }.
|
||||
*
|
||||
* API reference (turso v0.5):
|
||||
* - CREATE INDEX ... USING fts (columns) [WITH (tokenizer=..., weights=...)]
|
||||
* - fts_match(col1, col2, ..., query) — boolean filter
|
||||
* - fts_score(col1, col2, ..., query) — BM25 relevance score
|
||||
* - fts_highlight(col, open, close, query) — wrap matched terms
|
||||
* - OPTIMIZE INDEX idx_name
|
||||
*
|
||||
* Reference: https://turso.tech/blog/beyond-fts5
|
||||
*/
|
||||
export function ftsTests(getDb: () => DatabaseService) {
|
||||
let ftsProbed = false;
|
||||
let ftsSupported = false;
|
||||
|
||||
/**
|
||||
* Wrapper around it() that probes FTS support on first run (lazily,
|
||||
* after beforeEach has created the db) and skips when unavailable.
|
||||
*/
|
||||
function ftsIt(name: string, fn: () => Promise<void>) {
|
||||
it(name, async ({ skip }) => {
|
||||
if (!ftsProbed) {
|
||||
ftsProbed = true;
|
||||
const db = getDb();
|
||||
try {
|
||||
await db.execute('CREATE TABLE _fts_probe (id INTEGER PRIMARY KEY, t TEXT)');
|
||||
await db.execute('CREATE INDEX _fts_probe_idx ON _fts_probe USING fts (t)');
|
||||
await db.execute('DROP INDEX _fts_probe_idx');
|
||||
await db.execute('DROP TABLE _fts_probe');
|
||||
ftsSupported = true;
|
||||
} catch {
|
||||
try {
|
||||
await db.execute('DROP TABLE IF EXISTS _fts_probe');
|
||||
} catch {
|
||||
/* ignore cleanup errors */
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!ftsSupported) {
|
||||
skip();
|
||||
return;
|
||||
}
|
||||
await fn();
|
||||
});
|
||||
}
|
||||
|
||||
async function seedArticles(db: DatabaseService) {
|
||||
await db.execute('CREATE TABLE articles (id INTEGER PRIMARY KEY, title TEXT, body TEXT)');
|
||||
await db.execute('INSERT INTO articles (title, body) VALUES (?, ?)', [
|
||||
'Introduction to SQLite',
|
||||
'SQLite is a lightweight relational database engine widely used in embedded systems.',
|
||||
]);
|
||||
await db.execute('INSERT INTO articles (title, body) VALUES (?, ?)', [
|
||||
'Understanding WAL Mode',
|
||||
'Write-Ahead Logging improves concurrency in SQLite database operations.',
|
||||
]);
|
||||
await db.execute('INSERT INTO articles (title, body) VALUES (?, ?)', [
|
||||
'Full-Text Search Basics',
|
||||
'Full-text search allows efficient querying of large text datasets.',
|
||||
]);
|
||||
await db.execute('INSERT INTO articles (title, body) VALUES (?, ?)', [
|
||||
'Rust and WebAssembly',
|
||||
'Rust compiles to WebAssembly for high-performance browser applications.',
|
||||
]);
|
||||
await db.execute('CREATE INDEX idx_articles_fts ON articles USING fts (title, body)');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FTS index creation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ftsIt('creates an FTS index on a table', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE docs (id INTEGER PRIMARY KEY, content TEXT)');
|
||||
await db.execute('CREATE INDEX idx_docs_fts ON docs USING fts (content)');
|
||||
await db.execute('INSERT INTO docs (content) VALUES (?)', ['hello world']);
|
||||
const rows = await db.select<{ id: number; content: string }>(
|
||||
"SELECT * FROM docs WHERE fts_match(content, 'hello')",
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.content).toBe('hello world');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fts_match() — full-text filtering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ftsIt('fts_match() returns relevant rows', async () => {
|
||||
const db = getDb();
|
||||
await seedArticles(db);
|
||||
|
||||
const rows = await db.select<{ id: number; title: string }>(
|
||||
"SELECT id, title FROM articles WHERE fts_match(title, body, 'SQLite')",
|
||||
);
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
const titles = rows.map((r) => r.title);
|
||||
expect(titles).toContain('Introduction to SQLite');
|
||||
});
|
||||
|
||||
ftsIt('fts_match() returns empty result for non-matching query', async () => {
|
||||
const db = getDb();
|
||||
await seedArticles(db);
|
||||
|
||||
const rows = await db.select<{ id: number }>(
|
||||
"SELECT id FROM articles WHERE fts_match(title, body, 'blockchain')",
|
||||
);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
ftsIt('fts_match() works with parameterized queries', async () => {
|
||||
const db = getDb();
|
||||
await seedArticles(db);
|
||||
|
||||
const rows = await db.select<{ id: number; title: string }>(
|
||||
'SELECT id, title FROM articles WHERE fts_match(title, body, ?)',
|
||||
['WebAssembly'],
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.title).toBe('Rust and WebAssembly');
|
||||
});
|
||||
|
||||
ftsIt('fts_match() on single indexed column', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE tags (id INTEGER PRIMARY KEY, label TEXT)');
|
||||
await db.execute('INSERT INTO tags (label) VALUES (?)', ['typescript']);
|
||||
await db.execute('INSERT INTO tags (label) VALUES (?)', ['javascript']);
|
||||
await db.execute('INSERT INTO tags (label) VALUES (?)', ['python']);
|
||||
await db.execute('CREATE INDEX idx_tags_fts ON tags USING fts (label)');
|
||||
|
||||
const rows = await db.select<{ id: number; label: string }>(
|
||||
"SELECT id, label FROM tags WHERE fts_match(label, 'typescript')",
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.label).toBe('typescript');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fts_score() — BM25 ranking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ftsIt('fts_score() returns relevance scores ordered by rank', async () => {
|
||||
const db = getDb();
|
||||
await seedArticles(db);
|
||||
|
||||
const rows = await db.select<{ score: number; title: string }>(
|
||||
"SELECT fts_score(title, body, 'database') AS score, title FROM articles WHERE fts_match(title, body, 'database') ORDER BY score DESC",
|
||||
);
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
for (const row of rows) {
|
||||
expect(row.score).toBeGreaterThan(0);
|
||||
}
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
expect(rows[i - 1]!.score).toBeGreaterThanOrEqual(rows[i]!.score);
|
||||
}
|
||||
});
|
||||
|
||||
ftsIt('fts_score() with multi-term query', async () => {
|
||||
const db = getDb();
|
||||
await seedArticles(db);
|
||||
|
||||
const rows = await db.select<{ score: number; title: string }>(
|
||||
"SELECT fts_score(title, body, 'SQLite database') AS score, title FROM articles WHERE fts_match(title, body, 'SQLite database') ORDER BY score DESC",
|
||||
);
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
for (const row of rows) {
|
||||
expect(row.score).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fts_highlight() — result highlighting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ftsIt('fts_highlight() wraps matched terms with markers', async () => {
|
||||
const db = getDb();
|
||||
await seedArticles(db);
|
||||
|
||||
const rows = await db.select<{ highlighted: string }>(
|
||||
"SELECT fts_highlight(title, '<b>', '</b>', 'SQLite') AS highlighted FROM articles WHERE fts_match(title, body, 'SQLite') LIMIT 1",
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.highlighted).toContain('<b>');
|
||||
expect(rows[0]!.highlighted).toContain('</b>');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Column weights
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ftsIt('FTS index with column weights affects ranking', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT, body TEXT)');
|
||||
// "database" appears only in title of post 1, only in body of post 2
|
||||
await db.execute('INSERT INTO posts (title, body) VALUES (?, ?)', [
|
||||
'Database Internals',
|
||||
'This book covers storage engines and distributed systems.',
|
||||
]);
|
||||
await db.execute('INSERT INTO posts (title, body) VALUES (?, ?)', [
|
||||
'Systems Programming',
|
||||
'Learn about database drivers, networking, and concurrency.',
|
||||
]);
|
||||
await db.execute(
|
||||
"CREATE INDEX idx_posts_fts ON posts USING fts (title, body) WITH (weights = 'title=5.0,body=1.0')",
|
||||
);
|
||||
|
||||
const rows = await db.select<{ score: number; title: string }>(
|
||||
"SELECT fts_score(title, body, 'database') AS score, title FROM posts WHERE fts_match(title, body, 'database') ORDER BY score DESC",
|
||||
);
|
||||
expect(rows.length).toBe(2);
|
||||
// Post with "database" in the heavily-weighted title should rank higher
|
||||
expect(rows[0]!.title).toBe('Database Internals');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tokenizers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ftsIt('ngram tokenizer enables substring matching', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
await db.execute('INSERT INTO products (name) VALUES (?)', ['JavaScript']);
|
||||
await db.execute('INSERT INTO products (name) VALUES (?)', ['TypeScript']);
|
||||
await db.execute('INSERT INTO products (name) VALUES (?)', ['Python']);
|
||||
await db.execute(
|
||||
"CREATE INDEX idx_products_fts ON products USING fts (name) WITH (tokenizer = 'ngram')",
|
||||
);
|
||||
|
||||
const rows = await db.select<{ name: string }>(
|
||||
"SELECT name FROM products WHERE fts_match(name, 'Script') ORDER BY name",
|
||||
);
|
||||
expect(rows.length).toBe(2);
|
||||
const names = rows.map((r) => r.name);
|
||||
expect(names).toContain('JavaScript');
|
||||
expect(names).toContain('TypeScript');
|
||||
});
|
||||
|
||||
ftsIt('raw tokenizer performs exact matching', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE tags (id INTEGER PRIMARY KEY, tag TEXT)');
|
||||
await db.execute('INSERT INTO tags (tag) VALUES (?)', ['v1.0.0']);
|
||||
await db.execute('INSERT INTO tags (tag) VALUES (?)', ['v1.0.1']);
|
||||
await db.execute('INSERT INTO tags (tag) VALUES (?)', ['v2.0.0']);
|
||||
await db.execute("CREATE INDEX idx_tags_raw ON tags USING fts (tag) WITH (tokenizer = 'raw')");
|
||||
|
||||
const rows = await db.select<{ tag: string }>(
|
||||
"SELECT tag FROM tags WHERE fts_match(tag, 'v1.0.0')",
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.tag).toBe('v1.0.0');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FTS with data mutations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ftsIt('FTS index reflects newly inserted rows', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE notes (id INTEGER PRIMARY KEY, text TEXT)');
|
||||
await db.execute('CREATE INDEX idx_notes_fts ON notes USING fts (text)');
|
||||
|
||||
await db.execute('INSERT INTO notes (text) VALUES (?)', ['first note about testing']);
|
||||
|
||||
let rows = await db.select<{ id: number }>(
|
||||
"SELECT id FROM notes WHERE fts_match(text, 'testing')",
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
|
||||
await db.execute('INSERT INTO notes (text) VALUES (?)', [
|
||||
'second note about testing strategies',
|
||||
]);
|
||||
|
||||
rows = await db.select<{ id: number }>("SELECT id FROM notes WHERE fts_match(text, 'testing')");
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
ftsIt('FTS index reflects deleted rows', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE notes (id INTEGER PRIMARY KEY, text TEXT)');
|
||||
await db.execute('CREATE INDEX idx_notes_fts ON notes USING fts (text)');
|
||||
await db.execute('INSERT INTO notes (text) VALUES (?)', ['important meeting notes']);
|
||||
await db.execute('INSERT INTO notes (text) VALUES (?)', ['grocery list items']);
|
||||
|
||||
await db.execute('DELETE FROM notes WHERE id = 2');
|
||||
|
||||
const rows = await db.select<{ text: string }>(
|
||||
"SELECT text FROM notes WHERE fts_match(text, 'meeting')",
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.text).toContain('meeting');
|
||||
|
||||
const all = await db.select('SELECT * FROM notes');
|
||||
expect(all).toHaveLength(1);
|
||||
});
|
||||
|
||||
ftsIt('FTS index reflects updated rows', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE notes (id INTEGER PRIMARY KEY, text TEXT)');
|
||||
await db.execute('CREATE INDEX idx_notes_fts ON notes USING fts (text)');
|
||||
await db.execute('INSERT INTO notes (text) VALUES (?)', ['old content about cats']);
|
||||
|
||||
await db.execute('UPDATE notes SET text = ? WHERE id = ?', ['new content about dogs', 1]);
|
||||
|
||||
const catRows = await db.select<{ id: number }>(
|
||||
"SELECT id FROM notes WHERE fts_match(text, 'cats')",
|
||||
);
|
||||
expect(catRows).toHaveLength(0);
|
||||
|
||||
const dogRows = await db.select<{ id: number }>(
|
||||
"SELECT id FROM notes WHERE fts_match(text, 'dogs')",
|
||||
);
|
||||
expect(dogRows).toHaveLength(1);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OPTIMIZE INDEX
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ftsIt('OPTIMIZE INDEX runs without error', async () => {
|
||||
const db = getDb();
|
||||
await seedArticles(db);
|
||||
await db.execute('OPTIMIZE INDEX idx_articles_fts');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { it, expect } from 'vitest';
|
||||
import { DatabaseService } from '@/types/database';
|
||||
|
||||
/**
|
||||
* Shared vector search test cases for Turso's built-in vector functions.
|
||||
* Call this inside a describe() block after setting up a DatabaseService instance.
|
||||
*
|
||||
* Vector support is available in both the native (Node.js) and WASM backends.
|
||||
*
|
||||
* API reference:
|
||||
* - vector32 / vector / vector64 / vector8 / vector1bit — constructors
|
||||
* - vector_distance_cos(a, b) — cosine distance
|
||||
* - vector_distance_l2(a, b) — Euclidean distance
|
||||
* - vector_distance_dot(a, b) — negative dot product
|
||||
* - vector_extract(blob) — BLOB → JSON text
|
||||
* - vector_concat(a, b) — merge vectors
|
||||
* - vector_slice(blob, s, e) — extract sub-vector
|
||||
*
|
||||
* Reference: https://docs.turso.tech/sql-reference/functions/vector
|
||||
*/
|
||||
export function vectorTests(getDb: () => DatabaseService) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vector creation & storage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('stores and retrieves vector32 embeddings', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, embedding BLOB)');
|
||||
await db.execute('INSERT INTO items (embedding) VALUES (vector32(?))', [
|
||||
'[1.0, 2.0, 3.0, 4.0]',
|
||||
]);
|
||||
|
||||
const rows = await db.select<{ id: number; json: string }>(
|
||||
'SELECT id, vector_extract(embedding) AS json FROM items',
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
const parsed: number[] = JSON.parse(rows[0]!.json);
|
||||
expect(parsed).toHaveLength(4);
|
||||
expect(parsed[0]).toBeCloseTo(1.0);
|
||||
expect(parsed[3]).toBeCloseTo(4.0);
|
||||
});
|
||||
|
||||
it('vector() is an alias for vector32()', async () => {
|
||||
const db = getDb();
|
||||
const rows = await db.select<{ eq: number }>(
|
||||
"SELECT vector_extract(vector('[1,2,3]')) = vector_extract(vector32('[1,2,3]')) AS eq",
|
||||
);
|
||||
expect(rows[0]!.eq).toBe(1);
|
||||
});
|
||||
|
||||
it('stores and retrieves vector64 embeddings', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, embedding BLOB)');
|
||||
await db.execute('INSERT INTO items (embedding) VALUES (vector64(?))', ['[1.0, 2.0, 3.0]']);
|
||||
|
||||
const rows = await db.select<{ json: string }>(
|
||||
'SELECT vector_extract(embedding) AS json FROM items',
|
||||
);
|
||||
const parsed: number[] = JSON.parse(rows[0]!.json);
|
||||
expect(parsed).toHaveLength(3);
|
||||
expect(parsed[0]).toBeCloseTo(1.0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Distance functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('vector_distance_cos() returns 0 for identical vectors', async () => {
|
||||
const db = getDb();
|
||||
const rows = await db.select<{ d: number }>(
|
||||
"SELECT vector_distance_cos(vector32('[1,2,3]'), vector32('[1,2,3]')) AS d",
|
||||
);
|
||||
expect(rows[0]!.d).toBeCloseTo(0, 5);
|
||||
});
|
||||
|
||||
it('vector_distance_cos() returns higher distance for dissimilar vectors', async () => {
|
||||
const db = getDb();
|
||||
const rows = await db.select<{ d_similar: number; d_different: number }>(
|
||||
`SELECT
|
||||
vector_distance_cos(vector32('[1,0,0]'), vector32('[0.9,0.1,0]')) AS d_similar,
|
||||
vector_distance_cos(vector32('[1,0,0]'), vector32('[0,0,1]')) AS d_different`,
|
||||
);
|
||||
expect(rows[0]!.d_similar).toBeLessThan(rows[0]!.d_different);
|
||||
});
|
||||
|
||||
it('vector_distance_l2() returns 0 for identical vectors', async () => {
|
||||
const db = getDb();
|
||||
const rows = await db.select<{ d: number }>(
|
||||
"SELECT vector_distance_l2(vector32('[3,4]'), vector32('[3,4]')) AS d",
|
||||
);
|
||||
expect(rows[0]!.d).toBeCloseTo(0, 5);
|
||||
});
|
||||
|
||||
it('vector_distance_l2() computes correct Euclidean distance', async () => {
|
||||
const db = getDb();
|
||||
// distance between [0,0] and [3,4] should be 5
|
||||
const rows = await db.select<{ d: number }>(
|
||||
"SELECT vector_distance_l2(vector32('[0,0]'), vector32('[3,4]')) AS d",
|
||||
);
|
||||
expect(rows[0]!.d).toBeCloseTo(5.0, 4);
|
||||
});
|
||||
|
||||
it('vector_distance_dot() returns more negative for similar vectors', async () => {
|
||||
const db = getDb();
|
||||
const rows = await db.select<{ d_similar: number; d_different: number }>(
|
||||
`SELECT
|
||||
vector_distance_dot(vector32('[1,1,1]'), vector32('[1,1,1]')) AS d_similar,
|
||||
vector_distance_dot(vector32('[1,1,1]'), vector32('[-1,-1,-1]')) AS d_different`,
|
||||
);
|
||||
// dot returns negative dot product; more negative = more similar
|
||||
expect(rows[0]!.d_similar).toBeLessThan(rows[0]!.d_different);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Similarity search pattern
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('finds nearest neighbors by cosine distance', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE docs (id INTEGER PRIMARY KEY, title TEXT, embedding BLOB)');
|
||||
await db.execute("INSERT INTO docs (title, embedding) VALUES (?, vector32('[1,0,0,0]'))", [
|
||||
'Doc A',
|
||||
]);
|
||||
await db.execute("INSERT INTO docs (title, embedding) VALUES (?, vector32('[0,1,0,0]'))", [
|
||||
'Doc B',
|
||||
]);
|
||||
await db.execute(
|
||||
"INSERT INTO docs (title, embedding) VALUES (?, vector32('[0.95,0.05,0,0]'))",
|
||||
['Doc C'],
|
||||
);
|
||||
|
||||
// Query vector is close to [1,0,0,0]
|
||||
const rows = await db.select<{ title: string; distance: number }>(
|
||||
`SELECT title,
|
||||
vector_distance_cos(embedding, vector32('[1,0,0,0]')) AS distance
|
||||
FROM docs ORDER BY distance ASC LIMIT 2`,
|
||||
);
|
||||
expect(rows).toHaveLength(2);
|
||||
// Doc A (exact match) and Doc C (very close) should be the nearest
|
||||
expect(rows[0]!.title).toBe('Doc A');
|
||||
expect(rows[1]!.title).toBe('Doc C');
|
||||
expect(rows[0]!.distance).toBeCloseTo(0, 4);
|
||||
expect(rows[0]!.distance).toBeLessThan(rows[1]!.distance);
|
||||
});
|
||||
|
||||
it('finds nearest neighbors by L2 distance', async () => {
|
||||
const db = getDb();
|
||||
await db.execute('CREATE TABLE points (id INTEGER PRIMARY KEY, pos BLOB)');
|
||||
await db.execute("INSERT INTO points (pos) VALUES (vector32('[0,0]'))");
|
||||
await db.execute("INSERT INTO points (pos) VALUES (vector32('[3,4]'))");
|
||||
await db.execute("INSERT INTO points (pos) VALUES (vector32('[1,1]'))");
|
||||
|
||||
const rows = await db.select<{ id: number; dist: number }>(
|
||||
`SELECT id, vector_distance_l2(pos, vector32('[0,0]')) AS dist
|
||||
FROM points ORDER BY dist ASC`,
|
||||
);
|
||||
expect(rows).toHaveLength(3);
|
||||
// Origin first, then [1,1] (dist ~1.41), then [3,4] (dist 5)
|
||||
expect(rows[0]!.dist).toBeCloseTo(0, 4);
|
||||
expect(rows[1]!.dist).toBeCloseTo(Math.sqrt(2), 4);
|
||||
expect(rows[2]!.dist).toBeCloseTo(5, 4);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// vector_extract()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('vector_extract() converts BLOB back to JSON', async () => {
|
||||
const db = getDb();
|
||||
const rows = await db.select<{ json: string }>(
|
||||
"SELECT vector_extract(vector32('[10.5, 20.5, 30.5]')) AS json",
|
||||
);
|
||||
const parsed: number[] = JSON.parse(rows[0]!.json);
|
||||
expect(parsed).toHaveLength(3);
|
||||
expect(parsed[0]).toBeCloseTo(10.5);
|
||||
expect(parsed[2]).toBeCloseTo(30.5);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// vector_slice()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('vector_slice() extracts a sub-vector', async () => {
|
||||
const db = getDb();
|
||||
const rows = await db.select<{ json: string }>(
|
||||
"SELECT vector_extract(vector_slice(vector32('[10,20,30,40,50]'), 1, 4)) AS json",
|
||||
);
|
||||
const parsed: number[] = JSON.parse(rows[0]!.json);
|
||||
// zero-based: elements at index 1,2,3
|
||||
expect(parsed).toHaveLength(3);
|
||||
expect(parsed[0]).toBeCloseTo(20);
|
||||
expect(parsed[1]).toBeCloseTo(30);
|
||||
expect(parsed[2]).toBeCloseTo(40);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// vector_concat()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('vector_concat() merges two vectors', async () => {
|
||||
const db = getDb();
|
||||
const rows = await db.select<{ json: string }>(
|
||||
"SELECT vector_extract(vector_concat(vector32('[1,2]'), vector32('[3,4]'))) AS json",
|
||||
);
|
||||
const parsed: number[] = JSON.parse(rows[0]!.json);
|
||||
expect(parsed).toHaveLength(4);
|
||||
expect(parsed).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.closeTo(1),
|
||||
expect.closeTo(2),
|
||||
expect.closeTo(3),
|
||||
expect.closeTo(4),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mixed precision
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('vector_distance_cos() works with vector64', async () => {
|
||||
const db = getDb();
|
||||
const rows = await db.select<{ d: number }>(
|
||||
"SELECT vector_distance_cos(vector64('[1,0,0]'), vector64('[1,0,0]')) AS d",
|
||||
);
|
||||
expect(rows[0]!.d).toBeCloseTo(0, 5);
|
||||
});
|
||||
|
||||
it('vector_distance_l2() works with vector64', async () => {
|
||||
const db = getDb();
|
||||
const rows = await db.select<{ d: number }>(
|
||||
"SELECT vector_distance_l2(vector64('[0,0]'), vector64('[3,4]')) AS d",
|
||||
);
|
||||
expect(rows[0]!.d).toBeCloseTo(5.0, 4);
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { NodeDatabaseService } from '@/services/database/nodeDatabaseService';
|
||||
import { DatabaseService, DatabaseExecResult } from '@/types/database';
|
||||
import { ftsTests } from './suites/fts-tests';
|
||||
import { vectorTests } from './suites/vector-tests';
|
||||
|
||||
/**
|
||||
* Integration tests using a real in-memory SQLite database via @tursodatabase/database.
|
||||
@@ -12,7 +14,7 @@ describe('NodeDatabaseService (real in-memory SQLite)', () => {
|
||||
let db: DatabaseService;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await NodeDatabaseService.open(':memory:');
|
||||
db = await NodeDatabaseService.open(':memory:', { experimental: ['index_method'] });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -171,4 +173,20 @@ describe('NodeDatabaseService (real in-memory SQLite)', () => {
|
||||
expect(typeof result.rowsAffected).toBe('number');
|
||||
expect(typeof result.lastInsertId).toBe('number');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Full-text search (Turso native FTS, Tantivy-based)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('Full-Text Search', () => {
|
||||
ftsTests(() => db);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Vector search
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('Vector Search', () => {
|
||||
vectorTests(() => db);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { WebDatabaseService } from '@/services/database/webDatabaseService';
|
||||
import { DatabaseService, DatabaseExecResult } from '@/types/database';
|
||||
import { ftsTests } from './suites/fts-tests';
|
||||
import { vectorTests } from './suites/vector-tests';
|
||||
|
||||
/**
|
||||
* Browser-based integration tests for WebDatabaseService using @tursodatabase/database-wasm.
|
||||
@@ -11,7 +13,7 @@ describe('WebDatabaseService (browser WASM, in-memory SQLite)', () => {
|
||||
let db: DatabaseService;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await WebDatabaseService.open(':memory:');
|
||||
db = await WebDatabaseService.open(':memory:', { experimental: ['index_method'] });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -170,4 +172,20 @@ describe('WebDatabaseService (browser WASM, in-memory SQLite)', () => {
|
||||
expect(typeof result.rowsAffected).toBe('number');
|
||||
expect(typeof result.lastInsertId).toBe('number');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Full-text search (Turso native FTS, Tantivy-based)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('Full-Text Search', () => {
|
||||
ftsTests(() => db);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Vector search
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('Vector Search', () => {
|
||||
vectorTests(() => db);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
SelectDirectoryMode,
|
||||
} from '@/types/system';
|
||||
import { FileSystem, BaseDir, DeleteAction } from '@/types/system';
|
||||
import { DatabaseService } from '@/types/database';
|
||||
import { DatabaseOpts, DatabaseService } from '@/types/database';
|
||||
import {
|
||||
Book,
|
||||
BookConfig,
|
||||
@@ -133,7 +133,7 @@ export abstract class BaseAppService implements AppService {
|
||||
mimeType?: string,
|
||||
): Promise<boolean>;
|
||||
abstract ask(message: string): Promise<boolean>;
|
||||
abstract openDatabase(path: string, base: BaseDir): Promise<DatabaseService>;
|
||||
abstract openDatabase(path: string, base: BaseDir, opts?: DatabaseOpts): Promise<DatabaseService>;
|
||||
|
||||
protected async runMigrations(lastMigrationVersion: number): Promise<void> {
|
||||
if (lastMigrationVersion < 20251124) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Database, QueryResult } from 'tauri-plugin-libsql';
|
||||
import { DatabaseService, DatabaseExecResult, DatabaseRow } from '@/types/database';
|
||||
import { DatabaseService, DatabaseExecResult, DatabaseRow, DatabaseOpts } from '@/types/database';
|
||||
|
||||
export class NativeDatabaseService implements DatabaseService {
|
||||
private db: Database;
|
||||
@@ -8,7 +8,7 @@ export class NativeDatabaseService implements DatabaseService {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
static async open(path: string): Promise<NativeDatabaseService> {
|
||||
static async open(path: string, _opts?: DatabaseOpts): Promise<NativeDatabaseService> {
|
||||
const db = await Database.load(path);
|
||||
return new NativeDatabaseService(db);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DatabaseService, DatabaseExecResult, DatabaseRow } from '@/types/database';
|
||||
import { DatabaseService, DatabaseExecResult, DatabaseRow, DatabaseOpts } from '@/types/database';
|
||||
|
||||
interface TursoRunResult {
|
||||
changes: number;
|
||||
@@ -28,9 +28,9 @@ export class NodeDatabaseService implements DatabaseService {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
static async open(path: string): Promise<NodeDatabaseService> {
|
||||
static async open(path: string, opts?: DatabaseOpts): Promise<NodeDatabaseService> {
|
||||
const mod = await import('@tursodatabase/database');
|
||||
const db = (await mod.connect(path)) as unknown as TursoDatabase;
|
||||
const db = (await mod.connect(path, opts)) as unknown as TursoDatabase;
|
||||
return new NodeDatabaseService(db);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DatabaseService, DatabaseExecResult, DatabaseRow } from '@/types/database';
|
||||
import { DatabaseService, DatabaseExecResult, DatabaseRow, DatabaseOpts } from '@/types/database';
|
||||
|
||||
interface WasmRunResult {
|
||||
changes: number;
|
||||
@@ -23,9 +23,9 @@ export class WebDatabaseService implements DatabaseService {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
static async open(path: string): Promise<WebDatabaseService> {
|
||||
static async open(path: string, opts?: DatabaseOpts): Promise<WebDatabaseService> {
|
||||
const mod = await import('@tursodatabase/database-wasm');
|
||||
const db = (await mod.connect(path)) as unknown as WasmDatabase;
|
||||
const db = (await mod.connect(path, opts)) as unknown as WasmDatabase;
|
||||
return new WebDatabaseService(db);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ import { copyURIToPath, getStorefrontRegionCode } from '@/utils/bridge';
|
||||
import { copyFiles } from '@/utils/files';
|
||||
|
||||
import { BaseAppService } from './appService';
|
||||
import { DatabaseService } from '@/types/database';
|
||||
import { DatabaseOpts, DatabaseService } from '@/types/database';
|
||||
import {
|
||||
DATA_SUBDIR,
|
||||
LOCAL_BOOKS_SUBDIR,
|
||||
@@ -565,10 +565,10 @@ export class NativeAppService extends BaseAppService {
|
||||
return await ask(message);
|
||||
}
|
||||
|
||||
async openDatabase(path: string, base: BaseDir): Promise<DatabaseService> {
|
||||
async openDatabase(path: string, base: BaseDir, opts?: DatabaseOpts): Promise<DatabaseService> {
|
||||
const fullPath = await this.resolveFilePath(path, base);
|
||||
const { NativeDatabaseService } = await import('./database/nativeDatabaseService');
|
||||
return NativeDatabaseService.open(`sqlite:${fullPath}`);
|
||||
return NativeDatabaseService.open(`sqlite:${fullPath}`, opts);
|
||||
}
|
||||
|
||||
async migrate20251029() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FileSystem, BaseDir, AppPlatform, ResolvedPath, FileItem } from '@/types/system';
|
||||
import { DatabaseService } from '@/types/database';
|
||||
import { DatabaseOpts, DatabaseService } from '@/types/database';
|
||||
import { getOSPlatform, isValidURL } from '@/utils/misc';
|
||||
import { RemoteFile } from '@/utils/file';
|
||||
import { isPWA } from './environment';
|
||||
@@ -349,9 +349,9 @@ export class WebAppService extends BaseAppService {
|
||||
return window.confirm(message);
|
||||
}
|
||||
|
||||
async openDatabase(path: string, base: BaseDir): Promise<DatabaseService> {
|
||||
async openDatabase(path: string, base: BaseDir, opts?: DatabaseOpts): Promise<DatabaseService> {
|
||||
const fullPath = await this.resolveFilePath(path, base);
|
||||
const { WebDatabaseService } = await import('./database/webDatabaseService');
|
||||
return WebDatabaseService.open(fullPath);
|
||||
return WebDatabaseService.open(fullPath, opts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export type { DatabaseOpts } from '@tursodatabase/database-common';
|
||||
|
||||
export interface DatabaseExecResult {
|
||||
rowsAffected: number;
|
||||
lastInsertId: number;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { BookMetadata } from '@/libs/document';
|
||||
import { ProgressHandler } from '@/utils/transfer';
|
||||
import { CustomFont, CustomFontInfo } from '@/styles/fonts';
|
||||
import { CustomTextureInfo } from '@/styles/textures';
|
||||
import { DatabaseService } from './database';
|
||||
import { DatabaseOpts, DatabaseService } from './database';
|
||||
|
||||
export type AppPlatform = 'web' | 'tauri';
|
||||
export type OsPlatform = 'android' | 'ios' | 'macos' | 'windows' | 'linux' | 'unknown';
|
||||
@@ -159,5 +159,5 @@ export interface AppService {
|
||||
generateCoverImageUrl(book: Book): Promise<string>;
|
||||
updateCoverImage(book: Book, imageUrl?: string, imageFile?: string): Promise<void>;
|
||||
ask(message: string): Promise<boolean>;
|
||||
openDatabase(path: string, base: BaseDir): Promise<DatabaseService>;
|
||||
openDatabase(path: string, base: BaseDir, opts?: DatabaseOpts): Promise<DatabaseService>;
|
||||
}
|
||||
|
||||
Generated
+3
@@ -191,6 +191,9 @@ importers:
|
||||
'@tauri-apps/plugin-websocket':
|
||||
specifier: ~2.4.2
|
||||
version: 2.4.2
|
||||
'@tursodatabase/database-common':
|
||||
specifier: ^0.5.0
|
||||
version: 0.5.0
|
||||
'@tursodatabase/database-wasm':
|
||||
specifier: ^0.5.0
|
||||
version: 0.5.0
|
||||
|
||||
Reference in New Issue
Block a user