forked from akai/readest
feat(database): add database service abstraction with libsql/turso backend (#3472)
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { DatabaseService, DatabaseExecResult, DatabaseRow } from '@/types/database';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock: NativeDatabaseService
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock('tauri-plugin-libsql', () => {
|
||||
const rows = new Map<string, DatabaseRow[]>();
|
||||
|
||||
const mockDb = {
|
||||
execute: vi.fn(async (sql: string, params: unknown[] = []) => {
|
||||
const insertTable = sql.match(/INTO\s+(\w+)/i)?.[1];
|
||||
const fromTable = sql.match(/FROM\s+(\w+)/i)?.[1];
|
||||
const table = insertTable ?? fromTable ?? '_default';
|
||||
if (/^INSERT/i.test(sql.trim())) {
|
||||
const existing = rows.get(table) ?? [];
|
||||
const id = existing.length + 1;
|
||||
existing.push({ id, value: params[0] ?? null });
|
||||
rows.set(table, existing);
|
||||
return { rowsAffected: 1, lastInsertId: id };
|
||||
}
|
||||
if (/^DELETE/i.test(sql.trim())) {
|
||||
const existing = rows.get(table) ?? [];
|
||||
rows.set(table, []);
|
||||
return { rowsAffected: existing.length, lastInsertId: 0 };
|
||||
}
|
||||
return { rowsAffected: 0, lastInsertId: 0 };
|
||||
}),
|
||||
select: vi.fn(async (sql: string) => {
|
||||
const table = sql.match(/FROM\s+(\w+)/i)?.[1] ?? '_default';
|
||||
return rows.get(table) ?? [];
|
||||
}),
|
||||
batch: vi.fn(async () => {}),
|
||||
close: vi.fn(async () => {
|
||||
rows.clear();
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
Database: {
|
||||
load: vi.fn(async () => mockDb),
|
||||
},
|
||||
__mockDb: mockDb,
|
||||
__rows: rows,
|
||||
};
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock: @tursodatabase/database-wasm
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock('@tursodatabase/database-wasm', () => {
|
||||
const rows = new Map<string, DatabaseRow[]>();
|
||||
|
||||
const mockDb = {
|
||||
prepare: vi.fn((sql: string) => ({
|
||||
run: vi.fn((...params: unknown[]) => {
|
||||
const table = sql.match(/INTO\s+(\w+)/i)?.[1] ?? '_default';
|
||||
if (/^INSERT/i.test(sql.trim())) {
|
||||
const existing = rows.get(table) ?? [];
|
||||
const id = existing.length + 1;
|
||||
existing.push({ id, value: params[0] ?? null });
|
||||
rows.set(table, existing);
|
||||
return { changes: 1, lastInsertRowid: id };
|
||||
}
|
||||
if (/^DELETE/i.test(sql.trim())) {
|
||||
const existing = rows.get(table) ?? [];
|
||||
rows.set(table, []);
|
||||
return { changes: existing.length, lastInsertRowid: 0 };
|
||||
}
|
||||
return { changes: 0, lastInsertRowid: 0 };
|
||||
}),
|
||||
all: vi.fn(() => {
|
||||
const table = sql.match(/FROM\s+(\w+)/i)?.[1] ?? '_default';
|
||||
return rows.get(table) ?? [];
|
||||
}),
|
||||
})),
|
||||
exec: vi.fn(),
|
||||
close: vi.fn(() => {
|
||||
rows.clear();
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
connect: vi.fn(() => mockDb),
|
||||
__mockDb: mockDb,
|
||||
__rows: rows,
|
||||
};
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: NativeDatabaseService
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('NativeDatabaseService', () => {
|
||||
let db: DatabaseService;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const mod = await import('tauri-plugin-libsql');
|
||||
(mod as unknown as { __rows: Map<string, DatabaseRow[]> }).__rows.clear();
|
||||
|
||||
const { NativeDatabaseService } = await import('@/services/database/nativeDatabaseService');
|
||||
db = await NativeDatabaseService.open('sqlite:test.db');
|
||||
});
|
||||
|
||||
it('execute() returns DatabaseExecResult for INSERT', async () => {
|
||||
const result: DatabaseExecResult = await db.execute('INSERT INTO items (value) VALUES (?)', [
|
||||
'hello',
|
||||
]);
|
||||
expect(result.rowsAffected).toBe(1);
|
||||
expect(result.lastInsertId).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('execute() returns DatabaseExecResult for DELETE', async () => {
|
||||
await db.execute('INSERT INTO items (value) VALUES (?)', ['a']);
|
||||
const result = await db.execute('DELETE FROM items');
|
||||
expect(result.rowsAffected).toBe(1);
|
||||
});
|
||||
|
||||
it('select() returns typed row arrays', async () => {
|
||||
await db.execute('INSERT INTO items (value) VALUES (?)', ['alpha']);
|
||||
await db.execute('INSERT INTO items (value) VALUES (?)', ['beta']);
|
||||
|
||||
const rows = await db.select<{ id: number; value: string }>('SELECT * FROM items');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]!.id).toBe(1);
|
||||
expect(rows[0]!.value).toBe('alpha');
|
||||
expect(rows[1]!.id).toBe(2);
|
||||
expect(rows[1]!.value).toBe('beta');
|
||||
});
|
||||
|
||||
it('select() returns empty array when no rows', async () => {
|
||||
const rows = await db.select('SELECT * FROM empty_table');
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('batch() delegates to underlying db.batch()', async () => {
|
||||
await db.batch(['CREATE TABLE t (id INTEGER)', 'INSERT INTO t VALUES (1)']);
|
||||
const mod = await import('tauri-plugin-libsql');
|
||||
const mockDb = (mod as unknown as { __mockDb: { batch: ReturnType<typeof vi.fn> } }).__mockDb;
|
||||
expect(mockDb.batch).toHaveBeenCalledWith([
|
||||
'CREATE TABLE t (id INTEGER)',
|
||||
'INSERT INTO t VALUES (1)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('close() delegates to underlying db.close()', async () => {
|
||||
await db.close();
|
||||
const mod = await import('tauri-plugin-libsql');
|
||||
const mockDb = (mod as unknown as { __mockDb: { close: ReturnType<typeof vi.fn> } }).__mockDb;
|
||||
expect(mockDb.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: WebDatabaseService
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('WebDatabaseService', () => {
|
||||
let db: DatabaseService;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const mod = await import('@tursodatabase/database-wasm');
|
||||
(mod as unknown as { __rows: Map<string, DatabaseRow[]> }).__rows.clear();
|
||||
|
||||
const { WebDatabaseService } = await import('@/services/database/webDatabaseService');
|
||||
db = await WebDatabaseService.open('test.db');
|
||||
});
|
||||
|
||||
it('execute() maps changes/lastInsertRowid to DatabaseExecResult', async () => {
|
||||
const result: DatabaseExecResult = await db.execute('INSERT INTO items (value) VALUES (?)', [
|
||||
'hello',
|
||||
]);
|
||||
expect(result.rowsAffected).toBe(1);
|
||||
expect(result.lastInsertId).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('select() returns row objects from prepare().all()', async () => {
|
||||
await db.execute('INSERT INTO items (value) VALUES (?)', ['alpha']);
|
||||
await db.execute('INSERT INTO items (value) VALUES (?)', ['beta']);
|
||||
|
||||
const rows = await db.select<{ id: number; value: string }>('SELECT * FROM items');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]!.id).toBe(1);
|
||||
expect(rows[0]!.value).toBe('alpha');
|
||||
});
|
||||
|
||||
it('select() returns empty array when no rows', async () => {
|
||||
const rows = await db.select('SELECT * FROM empty_table');
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('batch() wraps in BEGIN/COMMIT transaction', async () => {
|
||||
await db.batch(['CREATE TABLE t (id INTEGER)', 'INSERT INTO t VALUES (1)']);
|
||||
const mod = await import('@tursodatabase/database-wasm');
|
||||
const mockDb = (mod as unknown as { __mockDb: { exec: ReturnType<typeof vi.fn> } }).__mockDb;
|
||||
expect(mockDb.exec).toHaveBeenCalledWith('BEGIN');
|
||||
expect(mockDb.exec).toHaveBeenCalledWith('CREATE TABLE t (id INTEGER)');
|
||||
expect(mockDb.exec).toHaveBeenCalledWith('INSERT INTO t VALUES (1)');
|
||||
expect(mockDb.exec).toHaveBeenCalledWith('COMMIT');
|
||||
});
|
||||
|
||||
it('batch() rolls back on error', async () => {
|
||||
const mod = await import('@tursodatabase/database-wasm');
|
||||
const mockDb = (mod as unknown as { __mockDb: { exec: ReturnType<typeof vi.fn> } }).__mockDb;
|
||||
let callCount = 0;
|
||||
mockDb.exec.mockImplementation((_sql: string) => {
|
||||
callCount++;
|
||||
// Fail on the second exec (first real statement after BEGIN)
|
||||
if (callCount === 2) throw new Error('SQL error');
|
||||
});
|
||||
|
||||
await expect(db.batch(['BAD SQL', 'GOOD SQL'])).rejects.toThrow('SQL error');
|
||||
expect(mockDb.exec).toHaveBeenCalledWith('ROLLBACK');
|
||||
});
|
||||
|
||||
it('close() delegates to underlying db.close()', async () => {
|
||||
await db.close();
|
||||
const mod = await import('@tursodatabase/database-wasm');
|
||||
const mockDb = (mod as unknown as { __mockDb: { close: ReturnType<typeof vi.fn> } }).__mockDb;
|
||||
expect(mockDb.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: DatabaseExecResult shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('DatabaseExecResult type contract', () => {
|
||||
it('has rowsAffected and lastInsertId properties', () => {
|
||||
const result: DatabaseExecResult = {
|
||||
rowsAffected: 5,
|
||||
lastInsertId: 42,
|
||||
};
|
||||
expect(result.rowsAffected).toBe(5);
|
||||
expect(result.lastInsertId).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: Unified API consistency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('API consistency between Native and Web implementations', () => {
|
||||
it('both implementations satisfy the DatabaseService interface', async () => {
|
||||
const { NativeDatabaseService } = await import('@/services/database/nativeDatabaseService');
|
||||
const nativeDb: DatabaseService = await NativeDatabaseService.open('sqlite:test.db');
|
||||
expect(nativeDb.execute).toBeDefined();
|
||||
expect(nativeDb.select).toBeDefined();
|
||||
expect(nativeDb.batch).toBeDefined();
|
||||
expect(nativeDb.close).toBeDefined();
|
||||
|
||||
const { WebDatabaseService } = await import('@/services/database/webDatabaseService');
|
||||
const webDb: DatabaseService = await WebDatabaseService.open('test.db');
|
||||
expect(webDb.execute).toBeDefined();
|
||||
expect(webDb.select).toBeDefined();
|
||||
expect(webDb.batch).toBeDefined();
|
||||
expect(webDb.close).toBeDefined();
|
||||
});
|
||||
|
||||
it('both return same shape from execute()', async () => {
|
||||
const { NativeDatabaseService } = await import('@/services/database/nativeDatabaseService');
|
||||
const nativeDb = await NativeDatabaseService.open('sqlite:test.db');
|
||||
const nativeResult = await nativeDb.execute('INSERT INTO t (value) VALUES (?)', ['x']);
|
||||
|
||||
const { WebDatabaseService } = await import('@/services/database/webDatabaseService');
|
||||
const webDb = await WebDatabaseService.open('test.db');
|
||||
const webResult = await webDb.execute('INSERT INTO t (value) VALUES (?)', ['x']);
|
||||
|
||||
expect(Object.keys(nativeResult).sort()).toEqual(['lastInsertId', 'rowsAffected']);
|
||||
expect(Object.keys(webResult).sort()).toEqual(['lastInsertId', 'rowsAffected']);
|
||||
expect(typeof nativeResult.rowsAffected).toBe('number');
|
||||
expect(typeof webResult.rowsAffected).toBe('number');
|
||||
expect(typeof nativeResult.lastInsertId).toBe('number');
|
||||
expect(typeof webResult.lastInsertId).toBe('number');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { NodeDatabaseService } from '@/services/database/nodeDatabaseService';
|
||||
import { DatabaseService, DatabaseExecResult } from '@/types/database';
|
||||
|
||||
/**
|
||||
* Integration tests using a real in-memory SQLite database via @tursodatabase/database.
|
||||
* These complement the mock-based tests in databaseService.test.ts by exercising
|
||||
* actual SQL execution through the DatabaseService interface using the same
|
||||
* libsql engine that powers the browser-based @tursodatabase/database-wasm.
|
||||
*/
|
||||
describe('NodeDatabaseService (real in-memory SQLite)', () => {
|
||||
let db: DatabaseService;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await NodeDatabaseService.open(':memory:');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Schema & basic operations
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('creates a table and inserts a row', async () => {
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
const result: DatabaseExecResult = await db.execute('INSERT INTO items (name) VALUES (?)', [
|
||||
'apple',
|
||||
]);
|
||||
expect(result.rowsAffected).toBe(1);
|
||||
expect(result.lastInsertId).toBe(1);
|
||||
});
|
||||
|
||||
it('inserts multiple rows with auto-incrementing ids', async () => {
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
const r1 = await db.execute('INSERT INTO items (name) VALUES (?)', ['a']);
|
||||
const r2 = await db.execute('INSERT INTO items (name) VALUES (?)', ['b']);
|
||||
const r3 = await db.execute('INSERT INTO items (name) VALUES (?)', ['c']);
|
||||
expect(r1.lastInsertId).toBe(1);
|
||||
expect(r2.lastInsertId).toBe(2);
|
||||
expect(r3.lastInsertId).toBe(3);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SELECT queries
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('select() returns typed rows', async () => {
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT, qty INTEGER)');
|
||||
await db.execute('INSERT INTO items (name, qty) VALUES (?, ?)', ['apple', 10]);
|
||||
await db.execute('INSERT INTO items (name, qty) VALUES (?, ?)', ['banana', 20]);
|
||||
|
||||
const rows = await db.select<{ id: number; name: string; qty: number }>(
|
||||
'SELECT * FROM items ORDER BY id',
|
||||
);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]!.name).toBe('apple');
|
||||
expect(rows[0]!.qty).toBe(10);
|
||||
expect(rows[1]!.name).toBe('banana');
|
||||
expect(rows[1]!.qty).toBe(20);
|
||||
});
|
||||
|
||||
it('select() with WHERE and params', async () => {
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['apple']);
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['banana']);
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['cherry']);
|
||||
|
||||
const rows = await db.select<{ id: number; name: string }>(
|
||||
'SELECT * FROM items WHERE name = ?',
|
||||
['banana'],
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.name).toBe('banana');
|
||||
});
|
||||
|
||||
it('select() returns empty array for no matching rows', async () => {
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
const rows = await db.select('SELECT * FROM items');
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// UPDATE & DELETE
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('execute() UPDATE returns rowsAffected', async () => {
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['old']);
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['old']);
|
||||
|
||||
const result = await db.execute('UPDATE items SET name = ? WHERE name = ?', ['new', 'old']);
|
||||
expect(result.rowsAffected).toBe(2);
|
||||
});
|
||||
|
||||
it('execute() DELETE returns rowsAffected', async () => {
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['a']);
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['b']);
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['c']);
|
||||
|
||||
const result = await db.execute('DELETE FROM items WHERE name IN (?, ?)', ['a', 'c']);
|
||||
expect(result.rowsAffected).toBe(2);
|
||||
|
||||
const remaining = await db.select<{ name: string }>('SELECT name FROM items');
|
||||
expect(remaining).toHaveLength(1);
|
||||
expect(remaining[0]!.name).toBe('b');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Batch execution
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('batch() executes multiple statements atomically', async () => {
|
||||
await db.batch([
|
||||
'CREATE TABLE t1 (id INTEGER PRIMARY KEY, val TEXT)',
|
||||
"INSERT INTO t1 (val) VALUES ('one')",
|
||||
"INSERT INTO t1 (val) VALUES ('two')",
|
||||
"INSERT INTO t1 (val) VALUES ('three')",
|
||||
]);
|
||||
|
||||
const rows = await db.select<{ val: string }>('SELECT val FROM t1 ORDER BY id');
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows.map((r) => r.val)).toEqual(['one', 'two', 'three']);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Data types
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('handles NULL values correctly', async () => {
|
||||
await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)');
|
||||
await db.execute('INSERT INTO t (val) VALUES (?)', [null]);
|
||||
|
||||
const rows = await db.select<{ id: number; val: string | null }>('SELECT * FROM t');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.val).toBeNull();
|
||||
});
|
||||
|
||||
it('handles integer and real types', async () => {
|
||||
await db.execute('CREATE TABLE nums (i INTEGER, r REAL)');
|
||||
await db.execute('INSERT INTO nums (i, r) VALUES (?, ?)', [42, 3.14]);
|
||||
|
||||
const rows = await db.select<{ i: number; r: number }>('SELECT * FROM nums');
|
||||
expect(rows[0]!.i).toBe(42);
|
||||
expect(rows[0]!.r).toBeCloseTo(3.14);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Error handling
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('throws on invalid SQL', async () => {
|
||||
await expect(db.execute('INVALID SQL STATEMENT')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws on constraint violation', async () => {
|
||||
await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT UNIQUE)');
|
||||
await db.execute('INSERT INTO t (val) VALUES (?)', ['unique_val']);
|
||||
await expect(db.execute('INSERT INTO t (val) VALUES (?)', ['unique_val'])).rejects.toThrow();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DatabaseExecResult contract
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('execute() result always has rowsAffected and lastInsertId as numbers', async () => {
|
||||
await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY)');
|
||||
const result = await db.execute('INSERT INTO t DEFAULT VALUES');
|
||||
expect(typeof result.rowsAffected).toBe('number');
|
||||
expect(typeof result.lastInsertId).toBe('number');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { WebDatabaseService } from '@/services/database/webDatabaseService';
|
||||
import { DatabaseService, DatabaseExecResult } from '@/types/database';
|
||||
|
||||
/**
|
||||
* Browser-based integration tests for WebDatabaseService using @tursodatabase/database-wasm.
|
||||
* These run in real headless Chromium via @vitest/browser + Playwright, providing
|
||||
* Web Workers, SharedArrayBuffer, and OPFS support required by the WASM module.
|
||||
*/
|
||||
describe('WebDatabaseService (browser WASM, in-memory SQLite)', () => {
|
||||
let db: DatabaseService;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await WebDatabaseService.open(':memory:');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Schema & basic operations
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('creates a table and inserts a row', async () => {
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
const result: DatabaseExecResult = await db.execute('INSERT INTO items (name) VALUES (?)', [
|
||||
'apple',
|
||||
]);
|
||||
expect(result.rowsAffected).toBe(1);
|
||||
expect(result.lastInsertId).toBe(1);
|
||||
});
|
||||
|
||||
it('inserts multiple rows with auto-incrementing ids', async () => {
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
const r1 = await db.execute('INSERT INTO items (name) VALUES (?)', ['a']);
|
||||
const r2 = await db.execute('INSERT INTO items (name) VALUES (?)', ['b']);
|
||||
const r3 = await db.execute('INSERT INTO items (name) VALUES (?)', ['c']);
|
||||
expect(r1.lastInsertId).toBe(1);
|
||||
expect(r2.lastInsertId).toBe(2);
|
||||
expect(r3.lastInsertId).toBe(3);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SELECT queries
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('select() returns typed rows', async () => {
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT, qty INTEGER)');
|
||||
await db.execute('INSERT INTO items (name, qty) VALUES (?, ?)', ['apple', 10]);
|
||||
await db.execute('INSERT INTO items (name, qty) VALUES (?, ?)', ['banana', 20]);
|
||||
|
||||
const rows = await db.select<{ id: number; name: string; qty: number }>(
|
||||
'SELECT * FROM items ORDER BY id',
|
||||
);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]!.name).toBe('apple');
|
||||
expect(rows[0]!.qty).toBe(10);
|
||||
expect(rows[1]!.name).toBe('banana');
|
||||
expect(rows[1]!.qty).toBe(20);
|
||||
});
|
||||
|
||||
it('select() with WHERE and params', async () => {
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['apple']);
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['banana']);
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['cherry']);
|
||||
|
||||
const rows = await db.select<{ id: number; name: string }>(
|
||||
'SELECT * FROM items WHERE name = ?',
|
||||
['banana'],
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.name).toBe('banana');
|
||||
});
|
||||
|
||||
it('select() returns empty array for no matching rows', async () => {
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
const rows = await db.select('SELECT * FROM items');
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// UPDATE & DELETE
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('execute() UPDATE returns rowsAffected', async () => {
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['old']);
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['old']);
|
||||
|
||||
const result = await db.execute('UPDATE items SET name = ? WHERE name = ?', ['new', 'old']);
|
||||
expect(result.rowsAffected).toBe(2);
|
||||
});
|
||||
|
||||
it('execute() DELETE returns rowsAffected', async () => {
|
||||
await db.execute('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['a']);
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['b']);
|
||||
await db.execute('INSERT INTO items (name) VALUES (?)', ['c']);
|
||||
|
||||
const result = await db.execute('DELETE FROM items WHERE name IN (?, ?)', ['a', 'c']);
|
||||
expect(result.rowsAffected).toBe(2);
|
||||
|
||||
const remaining = await db.select<{ name: string }>('SELECT name FROM items');
|
||||
expect(remaining).toHaveLength(1);
|
||||
expect(remaining[0]!.name).toBe('b');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Batch execution
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('batch() executes multiple statements atomically', async () => {
|
||||
await db.batch([
|
||||
'CREATE TABLE t1 (id INTEGER PRIMARY KEY, val TEXT)',
|
||||
"INSERT INTO t1 (val) VALUES ('one')",
|
||||
"INSERT INTO t1 (val) VALUES ('two')",
|
||||
"INSERT INTO t1 (val) VALUES ('three')",
|
||||
]);
|
||||
|
||||
const rows = await db.select<{ val: string }>('SELECT val FROM t1 ORDER BY id');
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows.map((r) => r.val)).toEqual(['one', 'two', 'three']);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Data types
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('handles NULL values correctly', async () => {
|
||||
await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)');
|
||||
await db.execute('INSERT INTO t (val) VALUES (?)', [null]);
|
||||
|
||||
const rows = await db.select<{ id: number; val: string | null }>('SELECT * FROM t');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.val).toBeNull();
|
||||
});
|
||||
|
||||
it('handles integer and real types', async () => {
|
||||
await db.execute('CREATE TABLE nums (i INTEGER, r REAL)');
|
||||
await db.execute('INSERT INTO nums (i, r) VALUES (?, ?)', [42, 3.14]);
|
||||
|
||||
const rows = await db.select<{ i: number; r: number }>('SELECT * FROM nums');
|
||||
expect(rows[0]!.i).toBe(42);
|
||||
expect(rows[0]!.r).toBeCloseTo(3.14);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Error handling
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('throws on invalid SQL', async () => {
|
||||
await expect(db.execute('INVALID SQL STATEMENT')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws on constraint violation', async () => {
|
||||
await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT UNIQUE)');
|
||||
await db.execute('INSERT INTO t (val) VALUES (?)', ['unique_val']);
|
||||
await expect(db.execute('INSERT INTO t (val) VALUES (?)', ['unique_val'])).rejects.toThrow();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DatabaseExecResult contract
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('execute() result always has rowsAffected and lastInsertId as numbers', async () => {
|
||||
await db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY)');
|
||||
const result = await db.execute('INSERT INTO t DEFAULT VALUES');
|
||||
expect(typeof result.rowsAffected).toBe('number');
|
||||
expect(typeof result.lastInsertId).toBe('number');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user