447 lines
11 KiB
Markdown
447 lines
11 KiB
Markdown
# SQLite CRUD Patterns
|
|
|
|
Complete examples for database operations with bun:sqlite.
|
|
|
|
## Basic Repository
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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;
|
|
```
|