11 KiB
11 KiB
SQLite CRUD Patterns
Complete examples for database operations with bun:sqlite.
Basic Repository
import { Database } from 'bun:sqlite';
type User = {
id: string;
email: string;
name: string;
createdAt: Date;
};
type UserRow = {
id: string;
email: string;
name: string;
created_at: string;
};
class UserRepository {
private stmt: {
findById: ReturnType<Database['prepare']>;
findByEmail: ReturnType<Database['prepare']>;
findAll: ReturnType<Database['prepare']>;
create: ReturnType<Database['prepare']>;
update: ReturnType<Database['prepare']>;
delete: ReturnType<Database['prepare']>;
};
constructor(private db: Database) {
// Initialize schema
this.db.run(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`);
// Prepare statements once
this.stmt = {
findById: this.db.prepare('SELECT * FROM users WHERE id = ?'),
findByEmail: this.db.prepare('SELECT * FROM users WHERE email = ?'),
findAll: this.db.prepare('SELECT * FROM users ORDER BY created_at DESC LIMIT ?'),
create: this.db.prepare('INSERT INTO users (id, email, name) VALUES (?, ?, ?) RETURNING *'),
update: this.db.prepare('UPDATE users SET email = ?, name = ? WHERE id = ? RETURNING *'),
delete: this.db.prepare('DELETE FROM users WHERE id = ? RETURNING *')
};
}
findById(id: string): User | null {
const row = this.stmt.findById.get(id) as UserRow | null;
return row ? this.mapRow(row) : null;
}
findByEmail(email: string): User | null {
const row = this.stmt.findByEmail.get(email) as UserRow | null;
return row ? this.mapRow(row) : null;
}
findAll(limit = 100): User[] {
const rows = this.stmt.findAll.all(limit) as UserRow[];
return rows.map(row => this.mapRow(row));
}
create(data: { email: string; name: string }): User {
const id = crypto.randomUUID();
const row = this.stmt.create.get(id, data.email, data.name) as UserRow;
return this.mapRow(row);
}
update(id: string, data: { email?: string; name?: string }): User | null {
const existing = this.findById(id);
if (!existing) return null;
const row = this.stmt.update.get(
data.email ?? existing.email,
data.name ?? existing.name,
id
) as UserRow;
return this.mapRow(row);
}
delete(id: string): boolean {
const row = this.stmt.delete.get(id);
return row !== null;
}
private mapRow(row: UserRow): User {
return {
id: row.id,
email: row.email,
name: row.name,
createdAt: new Date(row.created_at)
};
}
}
Usage
const db = new Database('app.db');
const users = new UserRepository(db);
// Create
const user = users.create({
email: 'alice@example.com',
name: 'Alice'
});
console.log('Created:', user.id);
// Read
const found = users.findById(user.id);
console.log('Found:', found?.email);
// Update
const updated = users.update(user.id, { name: 'Alice Smith' });
console.log('Updated:', updated?.name);
// Delete
const deleted = users.delete(user.id);
console.log('Deleted:', deleted);
// List
const allUsers = users.findAll(10);
console.log('All users:', allUsers.length);
db.close();
With Transactions
class AccountRepository {
constructor(private db: Database) {
this.db.run(`
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
balance INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
}
transfer = this.db.transaction((fromId: string, toId: string, amount: number) => {
// Check balance
const from = this.db.prepare('SELECT balance FROM accounts WHERE id = ?').get(fromId) as { balance: number } | null;
if (!from) {
throw new Error('Source account not found');
}
if (from.balance < amount) {
throw new Error('Insufficient funds');
}
// Debit
this.db.prepare('UPDATE accounts SET balance = balance - ? WHERE id = ?').run(amount, fromId);
// Credit
this.db.prepare('UPDATE accounts SET balance = balance + ? WHERE id = ?').run(amount, toId);
return { fromId, toId, amount };
});
bulkCreate = this.db.transaction((accounts: Array<{ userId: string; balance: number }>) => {
const stmt = this.db.prepare('INSERT INTO accounts (id, user_id, balance) VALUES (?, ?, ?)');
const created = [];
for (const account of accounts) {
const id = crypto.randomUUID();
stmt.run(id, account.userId, account.balance);
created.push(id);
}
return created;
});
}
Pagination
type PaginatedResult<T> = {
items: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
};
class UserRepository {
// ... other methods
findPaginated(page: number, pageSize: number): PaginatedResult<User> {
const offset = (page - 1) * pageSize;
const countResult = this.db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number };
const total = countResult.count;
const rows = this.db.prepare(`
SELECT * FROM users
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`).all(pageSize, offset) as UserRow[];
return {
items: rows.map(row => this.mapRow(row)),
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize)
};
}
}
// Usage
const result = users.findPaginated(1, 20);
console.log(`Page ${result.page} of ${result.totalPages}`);
console.log(`Showing ${result.items.length} of ${result.total} users`);
Search with Full-Text
class PostRepository {
constructor(private db: Database) {
// Create main table
this.db.run(`
CREATE TABLE IF NOT EXISTS posts (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
user_id TEXT NOT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`);
// Create FTS index
this.db.run(`
CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
title,
content,
content='posts',
content_rowid='rowid'
)
`);
// Triggers to keep FTS in sync
this.db.run(`
CREATE TRIGGER IF NOT EXISTS posts_ai AFTER INSERT ON posts BEGIN
INSERT INTO posts_fts(rowid, title, content)
VALUES (new.rowid, new.title, new.content);
END
`);
this.db.run(`
CREATE TRIGGER IF NOT EXISTS posts_ad AFTER DELETE ON posts BEGIN
INSERT INTO posts_fts(posts_fts, rowid, title, content)
VALUES ('delete', old.rowid, old.title, old.content);
END
`);
this.db.run(`
CREATE TRIGGER IF NOT EXISTS posts_au AFTER UPDATE ON posts BEGIN
INSERT INTO posts_fts(posts_fts, rowid, title, content)
VALUES ('delete', old.rowid, old.title, old.content);
INSERT INTO posts_fts(rowid, title, content)
VALUES (new.rowid, new.title, new.content);
END
`);
}
search(query: string, limit = 20) {
return this.db.prepare(`
SELECT posts.*, bm25(posts_fts) as rank
FROM posts
JOIN posts_fts ON posts.rowid = posts_fts.rowid
WHERE posts_fts MATCH ?
ORDER BY rank
LIMIT ?
`).all(query, limit);
}
}
// Usage
const posts = new PostRepository(db);
const results = posts.search('typescript tutorial');
JSON Storage
type Settings = {
theme: 'light' | 'dark';
notifications: boolean;
language: string;
};
class SettingsRepository {
constructor(private db: Database) {
this.db.run(`
CREATE TABLE IF NOT EXISTS settings (
user_id TEXT PRIMARY KEY,
data TEXT NOT NULL
)
`);
}
get(userId: string): Settings | null {
const row = this.db.prepare('SELECT data FROM settings WHERE user_id = ?').get(userId) as { data: string } | null;
if (!row) return null;
return JSON.parse(row.data);
}
set(userId: string, settings: Settings): void {
this.db.prepare(`
INSERT INTO settings (user_id, data) VALUES (?, ?)
ON CONFLICT (user_id) DO UPDATE SET data = excluded.data
`).run(userId, JSON.stringify(settings));
}
update(userId: string, partial: Partial<Settings>): Settings | null {
const existing = this.get(userId);
if (!existing) return null;
const updated = { ...existing, ...partial };
this.set(userId, updated);
return updated;
}
// Query JSON fields directly
findByTheme(theme: 'light' | 'dark') {
return this.db.prepare(`
SELECT user_id FROM settings
WHERE json_extract(data, '$.theme') = ?
`).all(theme);
}
}
With Hono API
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { Database } from 'bun:sqlite';
import { createFactory } from 'hono/factory';
import { HTTPException } from 'hono/http-exception';
type Env = {
Variables: {
db: Database;
users: UserRepository;
};
};
const factory = createFactory<Env>();
const dbMiddleware = factory.createMiddleware(async (c, next) => {
const db = new Database('app.db');
const users = new UserRepository(db);
c.set('db', db);
c.set('users', users);
try {
await next();
} finally {
db.close();
}
});
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100)
});
const UpdateUserSchema = z.object({
email: z.string().email().optional(),
name: z.string().min(1).max(100).optional()
});
const app = factory.createApp()
.use('*', dbMiddleware)
.get('/users', (c) => {
const users = c.get('users');
const limit = Number(c.req.query('limit')) || 20;
return c.json({ users: users.findAll(limit) });
})
.get('/users/:id', (c) => {
const users = c.get('users');
const user = users.findById(c.req.param('id'));
if (!user) {
throw new HTTPException(404, { message: 'User not found' });
}
return c.json({ user });
})
.post('/users', zValidator('json', CreateUserSchema), (c) => {
const users = c.get('users');
const data = c.req.valid('json');
const existing = users.findByEmail(data.email);
if (existing) {
throw new HTTPException(409, { message: 'Email already registered' });
}
const user = users.create(data);
return c.json({ user }, 201);
})
.patch('/users/:id', zValidator('json', UpdateUserSchema), (c) => {
const users = c.get('users');
const data = c.req.valid('json');
const user = users.update(c.req.param('id'), data);
if (!user) {
throw new HTTPException(404, { message: 'User not found' });
}
return c.json({ user });
})
.delete('/users/:id', (c) => {
const users = c.get('users');
const deleted = users.delete(c.req.param('id'));
if (!deleted) {
throw new HTTPException(404, { message: 'User not found' });
}
return c.json({ deleted: true });
});
export default app;