This commit is contained in:
Corey Johnson 2025-06-18 09:13:42 -07:00
commit a3cf5f6d06
19 changed files with 1523 additions and 0 deletions

View File

@ -0,0 +1,43 @@
## Style
- No semicolons — ever.
- No comments — ever.
- 2space indentation.
- Double quotes for strings.
- Trailing commas where ES5 allows (objects, arrays, imports).
- Keep lines <= 100 characters.
- End every file with a single newline.
## TypeScript
- This project runs on Bun.
- Assume `strict` mode is on (no implicit `any`).
- Prefer `const`; use `let` only when reassignment is required.
- Avoid the `any` type unless unavoidable.
- Use `import type { … }` when importing only types.
- In TypeScript files put npm imports first, then std imports, then library imports.
## Tests
Tests should use the toplevel `test()` and the `assert` library.
Test files should always live in `test/` off the project root and be named `THING.test.ts`
### Example Test
```
import { test } from "bun:test"
import assert from 'assert'
test("1 + 2 = 3", () => {
assert.equal(1 + 2, 3)
assert.ok(true)
})
```
## Assistant behaviour
- Respond in a concise, direct tone.
- Do not ask followup questions unless clarification is essential.
Stay simple, readable, and stick to these rules.

39
packages/query/.gitignore vendored Normal file
View File

@ -0,0 +1,39 @@
# examples
users.db
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
**/.claude/settings.local.json

136
packages/query/README.md Normal file
View File

@ -0,0 +1,136 @@
# ?uery
?uery is a SQL library for Bun that helps glue your database and class
objects together with little setup and minimal fuss.
The API is simple and fully typed.
Don't hide SQL - embrace it.
Currently supports both SQLite and PlanetScale. Best used for prototypes
and all kinds of spikes.
## SQL
You can use SQL, but data mapping (next section) is the best way to use this library.
```typescript
import { openDB } from "query"
const db = await openDB({ sqlite: "users.db" })
await db.run(
"CREATE TABLE IF NOT EXISTS users (ID INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)"
)
await db.run("INSERT INTO users (name) VALUES ($name)", { name: "John" })
const user = await db.queryOne("SELECT * FROM users WHERE name = $name", {
name: "John",
})
console.log("Name:", user["name"])
```
## Data Mapping
It's not an ORM. Think of it more like SQL queries as typed templates, automatically created based on your class objects.
```typescript
import { openDB } from "query"
// Define your model - just needs an id property
class User {
id: number
name: string
}
const db = await openDB({ sqlite: "users.db" }).model(User, "users") // connect your model to a db table <-- this is where the magic happens
await db.run(
"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)"
)
const user = await db.users.create({ name: "John" })
console.log(user.name) // "John"
const john = await db.users.find(1) // or await db.users.one({name: "John"})
const updated = await db.users.update(1, { name: "John Smith" })
const smiths = await db.users.all("name LIKE $name", { name: "Smith%" })
await db.users.delete(1)
// SQL is encouraged
const results = await db.query<User>(
"SELECT * FROM users WHERE name LIKE $search",
{ search: "J%" }
)
```
## `async` API
Everything needs `await`. Everything.
### Queries
The main way you'll query the DB is with `db.table.one()` and `db.table.all()`.
Each takes either:
1. SQL String + Args
2. Query Builder
```typescript
// SQL Args
const everyoneNamedBob = db.users.all("name = $name LIMIT 2", { name: "Bob" })
// Query Builder
const everyoneNamedJon = db.users.all({ name: "Jon", age: 20, limit: 2 })
```
The Query Builder has three special keys:
- limit
- offset
- order
### Database Operations
- `openDB(config: { sqlite: string } | { planetscale: PlanetScaleConfig })`: Opens a database connection
- `.model(modelClass: Model, tableName: string): DB`: Chain this with `openDB()` to get your `db` objects.
- `db.query(sql: string, args?: {}, map?: (row: T) => U): U[]`: Query multiple rows
- `db.queryOne(sql: string, args?: {}, map?: (row: T) => U): U | null`: Query single row
- `db.run(sql: string, args?: {})`: Execute SQL with no results
- `db.transaction(fn)`: Run multiple SQL statements together, rolling them all back if an error is thrown.
- `db.close()`: Close connection
### Mapper API
- `db.table.find(id: string | number): Model | null`: Find a record by ID
- `db.table.create(data: Partial<Model>): Model`: Create a new record
- `db.table.update(id: string | number, data: Partial<Model>): Model | null`: Update a record
- `db.table.delete(id: string | number): boolean`: Delete a record
- `db.table.one(where?: string, args?: Record<string, any>): Model | null`: Get one record with optional where clause
- `db.table.all(where?: string | Record<string, any>, args?: Record<string, any>): Model[]`: Get all records with optional where clause or object-based query
- `db.table.query(sql: string, args?: {}): Model[]`: Query multiple rows
- `db.table.queryOne(sql: string, args?: {}): Model | null`: Query single row
### Database Operations
```typescript
// Basic CRUD
const user = await db.users.find(1)
const users = await db.users.all()
const filtered = await db.users.all("age > $age", { age: 18 })
const byName = await db.users.all({ name: "John" }) // Object-based query
const oneUser = await db.users.one("email = $email", {
email: "test@example.com",
})
// Transactions
await db.transaction(async (tx) => {
const user = await db.users.create({ name: "Alice" })
const post = await db.posts.create({ userId: user.id, title: "First Post" })
})
```

33
packages/query/bun.lock Normal file
View File

@ -0,0 +1,33 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "query",
"dependencies": {
"@planetscale/database": "^1.19.0",
"reflect-metadata": "^0.2.2",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@planetscale/database": ["@planetscale/database@1.19.0", "", {}, "sha512-Tv4jcFUFAFjOWrGSio49H6R2ijALv0ZzVBfJKIdm+kl9X046Fh4LLawrF9OMsglVbK6ukqMJsUCeucGAFTBcMA=="],
"@types/bun": ["@types/bun@1.2.12", "", { "dependencies": { "bun-types": "1.2.12" } }, "sha512-lY/GQTXDGsolT/TiH72p1tuyUORuRrdV7VwOTOjDOt8uTBJQOJc5zz3ufwwDl0VBaoxotSk4LdP0hhjLJ6ypIQ=="],
"@types/node": ["@types/node@22.15.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-rlyK0vuU7VLEYQfXuC7QTFxDvkb6tKhDI7wR4r6ZzM0k8BJd44W0jxo6xmUjqSs4AlYmiYfLJU2f0pAG/FtCRw=="],
"bun-types": ["bun-types@1.2.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-tvWMx5vPqbRXgE8WUZI94iS1xAYs8bkqESR9cxBB1Wi+urvfTrF1uzuDgBHFAdO0+d2lmsbG3HmeKMvUyj6pWA=="],
"reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
}
}

View File

@ -0,0 +1,116 @@
import { openDB, field, validate, type QueryOpts } from '../src/index'
enum UserState {
ACTIVE = 'active',
INACTIVE = 'inactive',
BANNED = 'banned',
}
class User {
@field('primary key')
id!: number
@field('required', 'unique', 'email')
@form('email')
email!: string
@field('required', 'password')
@form('password')
password!: string
@field('required')
@form('text')
name!: string
@field()
@form('checkbox')
admin = false
@field()
@form('select')
state: UserState = UserState.ACTIVE
}
class UserForm extends User {
@form('file picker')
profile!: string
}
const action = async (user: User) => {
const [ok, errors] = db.users.validate(user)
if (!ok) {
return { errors }
}
return { success: true }
}
async function main() {
// Open database with type information
const db = (await openDB({ sqlite: "users.db" }))
.model(User, 'users')
// Create users table
await db.run(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL
)
`)
const john = await db.users.create({ name: "John" })
console.log("Created:", john.id, john.name)
const search = (find: string, opts?: QueryOpts) =>
db.users.all(`name LIKE $name`, { ...opts, name: `%${find}%` })
console.log("Search:", await search("John", { limit: 2 }))
const errors = validate(john)
console.log("Errors:", john, errors)
const jane = await db.users.create({ name: "Jane" })
console.log("Created:", jane.name)
const found = await db.users.find(john.id)
console.log("Found:", found?.name)
const updated = await db.users.update(john.id, { name: "John Smith" })
console.log("Updated:", updated?.name)
const smiths = await db.users.all("name LIKE $name", { name: "Smith%" })
console.log("Smiths:", smiths.map(u => u.name))
const firstJane = await db.users.one({ name: "Jane" })
console.log("First Jane:", firstJane?.name)
const topSmiths = await db.users.all("name LIKE $name", {
name: "%Smith%",
order: "id DESC",
limit: 3,
})
console.log("Top Smiths:", topSmiths.map(u => u.name))
const recent = await db.users.all({ order: "id DESC", limit: 5 })
console.log("Recent:", recent.map(u => u.name))
const secondJane = await db.users.one({ name: "Jane", order: "id ASC", offset: 1 })
console.log("Second Jane:", secondJane?.name)
const latestJ = await db.users.one("name LIKE $name ORDER BY id DESC", { name: "J%" })
console.log("Latest J:", latestJ?.name)
// Get all users
const allUsers = await db.users.all()
console.log("All users:", allUsers.map(u => u.name))
// Delete
await db.users.delete(john.id)
console.log("Deleted John")
await db.close()
}
main().catch(console.error)

View File

@ -0,0 +1,22 @@
import { openDB, field, validate, type QueryOpts } from '../src/index'
class User {
@field('primary key')
id!: number
@field('required')
name!: string
@field('email')
email!: string
@field()
age?: number
}
async function main() {
const db = await openDB({ sqlite: ":memory:" })
db.model(User, 'users')
}
main().catch(console.error)

View File

@ -0,0 +1,18 @@
{
"name": "@workshop/query",
"module": "src/index.ts",
"main": "src/index.ts",
"type": "module",
"private": true,
"entrypoint": "src/index.ts",
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"@planetscale/database": "^1.19.0",
"reflect-metadata": "^0.2.2"
}
}

145
packages/query/src/db.ts Normal file
View File

@ -0,0 +1,145 @@
import { Database } from "bun:sqlite"
import { connect, Connection, type Config as PlanetScaleConfig, type ExecutedQuery } from '@planetscale/database'
import { BaseMapper, type Mapper } from './mapper'
import type { HasID, SQLArgs, Atom } from "./index"
type DBConfig = {
sqlite?: string
planetscale?: PlanetScaleConfig
}
type ModelConfig = {
table?: string
id?: string
}
let db: Database | null = null
// open the db connection
// const db = await openDB({ sqlite: "my-db-name.sqlite")
// const user = await db.queryOne<User>("SELECT * FROM users WHERE id = $id", { id: 1 })
// console.log("It's", user.name,"!")
export async function openDB(config: DBConfig): Promise<DB> {
if (config.planetscale) {
const conn = connect(config.planetscale)
return new PlanetScaleDB(conn)
} else if (config.sqlite) {
return new SQLiteDB(new Database(config.sqlite, { strict: true }))
} else {
throw new Error("openDB() requires a config for either sqlite or planetscale")
}
}
// Base DB interface
export abstract class DB {
private mappers: Map<string, Mapper<any>> = new Map()
abstract query<T = unknown, U = T>(sql: string, args?: SQLArgs, map?: (row: T) => U): Promise<U[]>
async close(): Promise<void> { }
async queryOne<T = unknown, U = T>(sql: string, args?: SQLArgs, map?: (row: T) => U): Promise<U | null> {
return (await this.query<T, U>(sql, args, map))[0] ?? null
}
async run(sql: string, args?: SQLArgs): Promise<void> {
await this.query(sql, args)
}
abstract transaction<T>(fn: (tx: DB) => Promise<T>): Promise<T>
model<Self extends DB, T extends HasID, Name extends string>(
this: Self,
modelClass: new (...args: any[]) => T,
propName: Name,
cfg?: ModelConfig
): Self & { [K in Name]: Mapper<T> }
// Implementation
// mapping: .model(ModelClass, 'propName'[, { table: 'table_name', id: 'user_id' }])
model(this: DB, modelClass: new (...args: any[]) => HasID, propName: string, cfg?: ModelConfig): DB {
const tableName = cfg?.table ?? propName
const id = cfg?.id ?? 'id'
const mapper = new BaseMapper(this, tableName, modelClass, id)
this.mappers.set(propName, mapper)
; (this as any)[propName] = mapper
return this as any
}
}
// SQLite implementation
class SQLiteDB extends DB {
constructor(private __db: Database) {
super()
}
async close(): Promise<void> {
await this.__db.close()
}
async query<T = unknown, U = T>(sql: string, args?: SQLArgs, map?: (row: T) => U): Promise<U[]> {
// console.log("Query:", sql, args)
const rows = this.__db.prepare(sql).all(args || {}) as T[]
return map ? rows.map(map) : (rows as unknown as U[])
}
async transaction<T>(fn: (tx: DB) => Promise<T>): Promise<T> {
await this.run('BEGIN')
try {
const result = await fn(this)
await this.run('COMMIT')
return result
} catch (e) {
await this.run('ROLLBACK')
throw e
}
}
}
// PlanetScale implementation
class PlanetScaleDB extends DB {
constructor(private __conn: { execute: (sql: string, args?: Record<string, Atom>) => Promise<ExecutedQuery> }) {
super()
}
private convertArgs(args?: SQLArgs): Record<string, Atom> | undefined {
if (!args) return undefined
// Convert SQLite $param style to PlanetScale :param style
const converted: Record<string, Atom> = {}
for (const [key, value] of Object.entries(args)) {
// Remove the $ prefix if it exists and add : prefix
const newKey = key.startsWith('$') ? key.slice(1) : key
converted[newKey] = value as Atom
}
return converted
}
private convertQuery(sql: string): string {
// Convert $param to :param in the query
return sql.replace(/\$(\w+)/g, ':$1')
}
private async executeQuery<T>(sql: string, args?: Record<string, Atom>): Promise<T[]> {
const result = await this.__conn.execute(sql, args)
return result.rows as T[]
}
async query<T = unknown, U = T>(sql: string, args?: SQLArgs, map?: (row: T) => U): Promise<U[]> {
const convertedSql = this.convertQuery(sql)
const convertedArgs = this.convertArgs(args)
const rows = await this.executeQuery<T>(convertedSql, convertedArgs)
return map ? rows.map(map) : (rows as unknown as U[])
}
async transaction<T>(fn: (tx: DB) => Promise<T>): Promise<T> {
if ('transaction' in this.__conn) {
return await (this.__conn as Connection).transaction(async (tx) => {
const transactionDB = new PlanetScaleDB(tx)
return await fn(transactionDB)
})
}
throw new Error("Nested transactions are not supported")
}
}

View File

@ -0,0 +1,26 @@
import 'reflect-metadata' // docs say this has to be imported first in the entrypoint
import { type SQLQueryBindings } from "bun:sqlite"
import { openDB, DB } from "./db"
import { field, validate, schema } from './schema'
export { openDB, DB }
export { field, validate, schema }
export interface HasID {
id: string | number
}
export type QueryOpts = {
limit?: number
offset?: number
order?: string
}
// types that can be used in sql
export type Atom = string | number | boolean | null
// query("SELECT * FROM users WHERE id = $id", { id: 1 })
export type SQLArgs = SQLQueryBindings
// What all() and one() accept
export type Where<T> = Partial<T> | QueryOpts | (Partial<T> & QueryOpts)

View File

@ -0,0 +1,151 @@
import type { HasID, Where, DB, SQLArgs } from "./index"
import { schema, } from "./schema"
import { buildWhere, type WhereClause } from "./where-builder"
export interface Mapper<T extends HasID> {
find(id: string | number): Promise<T | null>
create(data: Partial<T>): Promise<T>
update(id: string | number, data: Partial<T>): Promise<T | null>
delete(id: string | number): Promise<boolean>
all(where?: string | Where<T>, args?: Where<T>): Promise<T[]>
one(where?: string | Where<T>, args?: Where<T>): Promise<T | null>
query(sql: string, args?: SQLArgs): Promise<T[]>
queryOne(sql: string, args?: SQLArgs): Promise<T | null>
schema(): string
}
export class BaseMapper<T extends HasID> implements Mapper<T> {
constructor(
protected db: DB,
protected table: string,
protected construct: new (data: any) => T,
protected id: string,
) { }
protected initializeModel(data: any): T {
// Create instance
const model = new this.construct(data)
// Use Object.assign for property assignment
Object.assign(model, data)
return model
}
async find(id: string | number): Promise<T | null> {
const sql = `SELECT * FROM ${this.table} WHERE ${this.id} = $id`
const args = { id }
return await this.db.queryOne<any, T>(
sql,
args,
(row) => this.initializeModel(row),
)
}
async create(data: Partial<T>): Promise<T> {
const columns = Object.keys(data)
const placeholders = columns.map(c => `$${c}`).join(", ")
const args = columns.reduce((acc, col) => {
acc[col] = (data as any)[col]
return acc
}, {} as Record<string, any>)
const result = await this.db.queryOne<any, T>(
`INSERT INTO ${this.table}(${columns.join(", ")})
VALUES(${placeholders})
RETURNING * `,
args,
(row) => this.initializeModel(row),
)
if (!result) {
throw new Error("Failed to create record")
}
return result
}
async update(id: string | number, data: Partial<T>): Promise<T | null> {
const updates = Object.keys(data)
.map((key) => `${key} = $${key}`)
.join(", ")
const args = {
id,
...Object.keys(data).reduce((acc, key) => {
acc[key] = (data as any)[key]
return acc
}, {} as Record<string, any>),
}
return await this.db.queryOne<any, T>(
`UPDATE ${this.table}
SET ${updates}
WHERE ${this.id} = $id
RETURNING * `,
args,
(row) => this.initializeModel(row),
)
}
async delete(id: string | number): Promise<boolean> {
await this.db.run(
`DELETE FROM ${this.table} WHERE ${this.id} = $id`,
{ id },
)
return true
}
async one(where?: string | Where<T>, args?: Where<T>): Promise<T | null> {
return (await this.all(where, args ? { ...args, limit: 1 } : { limit: 1 }))[0] ?? null
}
async all(where?: string | Where<T>, args?: Where<T>): Promise<T[]> {
let query = `SELECT * FROM ${this.table}`
if (where) {
if (typeof where === "string") {
const whereClause = buildWhere(where, args as WhereClause)
if (!whereClause.startsWith("LIMIT") && !whereClause.startsWith("OFFSET") && !whereClause.startsWith("ORDER")) {
query += ` ${whereClause}`
} else {
query += ` ${whereClause}`
}
return await this.db.query<any, T>(
query,
args as Record<string, any>,
(row) => this.initializeModel(row),
)
} else {
const whereClause = buildWhere(where as WhereClause)
if (whereClause) query += ` ${whereClause}`
return await this.db.query<any, T>(
query,
where as Record<string, any>,
(row) => this.initializeModel(row),
)
}
}
return await this.db.query<any, T>(
query,
args as Record<string, any>,
(row) => this.initializeModel(row),
)
}
async query(sql: string, args?: SQLArgs): Promise<T[]> {
return await this.db.query<any, T>(sql, args, (row) => this.initializeModel(row))
}
async queryOne(sql: string, args?: SQLArgs): Promise<T | null> {
return await this.db.queryOne<any, T>(sql, args, (row) => this.initializeModel(row))
}
schema(): string {
return schema(this.construct, this.table)
}
}

View File

@ -0,0 +1,122 @@
import 'reflect-metadata'
import type { HasID } from "./index"
export type Rule = 'required' | 'primary key' | 'unique' | 'email'
export type ValidResult = [boolean, string]
//
// rules
const RULES: Record<string, (value: any) => ValidResult> = {
required,
'primary key': primaryKey,
unique,
email,
}
function required(value: any): ValidResult {
return [!!value, "is required"]
}
function unique(value: any): ValidResult {
// TODO are we checking uniqueness here? might not be worth a db call.
return [true, "must be unique"]
}
function primaryKey(value: any): ValidResult {
const errorMsg = "must be an integer"
try {
return [required(value)[0] && parseInt(value) > 0, errorMsg]
} catch (e) {
return [false, errorMsg]
}
}
function email(value: any): ValidResult {
return [/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value), "must be a valid email address"]
}
//
// decorator
// rule = arg passed to the decorator
// target = the class constructor
// property = the property name, ex 'id' or 'repoId' or 'name'
export const field = (...rules: Rule[]): PropertyDecorator => {
return (target: any, property: string | symbol) => {
if (typeof property !== "string") return
const type = Reflect.getMetadata('design:type', target, property)
const ctor = typeof target === "function" ? target : target.constructor
ctor.__vtypes ??= {}
ctor.__vtypes[property] ??= type.name
ctor.__vrules ??= {}
ctor.__vrules[property] ??= []
ctor.__vrules[property].push(...rules)
}
}
//
// validator
// boolean: true if valid, false if invalid
// Record<string, string[]>: key is the property name, value is an array of error messages
export function validate<T extends Record<string, any>>(obj: T): [ok: boolean, errors: Record<string, string[]>] {
const ctor = obj.constructor as any
const rules = ctor.__vrules ?? {}
const errors: Record<string, string[]> = {}
for (const [prop, list] of Object.entries(rules)) {
const value = obj[prop as keyof T]
for (const rule of list as string[]) {
const [valid, error] = RULES[rule](value)
if (!valid) {
errors[prop] ??= []
errors[prop].push(error)
}
}
}
return [Object.keys(errors).length == 0, errors]
}
//
// schema generator
const SQLITE_TYPES: Record<string, string> = {
Number: 'INTEGER',
String: 'TEXT',
Boolean: 'BOOLEAN',
}
const SQLITE_RULES: Record<string, string> = {
'required': 'NOT NULL',
'primary key': 'PRIMARY KEY AUTOINCREMENT',
'unique': 'UNIQUE',
}
// Produces a string that can be used to create a SQL table.
export function schema<T extends HasID>(ctor: new (...args: any[]) => T, table: string): string {
return sqliteSchema(ctor, table)
}
export function sqliteSchema<T extends HasID>(ctor: new (...args: any[]) => T, table: string): string {
const types: string[] = (ctor as any).__vtypes
const rules = (ctor as any).__vrules
const columns: string[] = []
for (const [prop, type] of Object.entries(types)) {
let column = `${prop} ${SQLITE_TYPES[type] || 'BLOB'}`
const propRules = rules[prop]
if (propRules)
for (const rule of propRules)
if (SQLITE_RULES[rule])
column += ` ${SQLITE_RULES[rule]}`
columns.push(column)
}
return `CREATE TABLE IF NOT EXISTS ${table} (${columns.join(", ")})`
}

View File

@ -0,0 +1,85 @@
import type { Atom } from "./db"
export type WhereClause = Atom | Record<string, Atom>
type QueryExtras = {
limit?: number
offset?: number
order?: string
}
// `query` types:
// - string: WHERE clause, ex "name = 'bob'"
// - number: id, ex 502
// - object: key-value pairs, ex { name: "bob", age: 20 }
//
// special object keys:
// - limit: number
// - offset: number
// - order: string
//
// examples:
// buildWhere("id = 502") // "WHERE id = 502"
// buildWhere("id = $id", { id: 502 }) // "WHERE id = 502"
// buildWhere({ id: 502 }) // "WHERE id = $id"
// buildWhere({ name: "bob" }) // "WHERE name = $name"
// buildWhere({ name: "bob", age: 20 }) // "WHERE name = $name AND age = $age"
// buildWhere({ name: "bob", age: 20, limit: 10 }) // "WHERE name = $name AND age = $age LIMIT 10"
// buildWhere({ name: "bob", age: 20, limit: 10, offset: 5 }) // "WHERE name = $name AND age = $age LIMIT 10 OFFSET 5"
// buildWhere("id = $id", { id: 502, limit: 10, offset: 5 }) // "WHERE id = $id LIMIT 10 OFFSET 5"
export function buildWhere(query: WhereClause, args?: WhereClause): string {
if (!query) throw new Error("Invalid query")
if (typeof query === "object" && Array.isArray(query)) {
throw new Error("Invalid query")
}
let after: string[] = []
if (typeof query === "string" && args && typeof args === "object" && !Array.isArray(args)) {
const conditions: string[] = []
Object.keys(args).forEach((key) => {
if (key === "limit") {
after.push(`LIMIT ${(args as QueryExtras).limit}`)
} else if (key === "offset") {
after.push(`OFFSET ${(args as QueryExtras).offset}`)
} else if (key === "order") {
after.push(`ORDER BY ${(args as QueryExtras).order}`)
} else if (!query.includes(`$${key}`)) {
conditions.push(`${key} = $${key}`)
}
})
const whereClause = [query, ...conditions].filter(Boolean).join(" AND ")
return whereClause ? `WHERE ${whereClause} ${after.join(" ")}`.trim() : after.join(" ")
}
switch (typeof query) {
case "string":
return `WHERE ${query}`
case "object":
return buildObjectWhere(query as Record<string, any>, after)
default:
throw new Error("Invalid query")
}
}
function buildObjectWhere(query: Record<string, any>, after: string[]): string {
const conditions: string[] = []
Object.keys(query).forEach((key) => {
if (key === "limit") {
after.push(`LIMIT ${query[key]}`)
} else if (key === "offset") {
after.push(`OFFSET ${query[key]}`)
} else if (key === "order") {
after.push(`ORDER BY ${query[key]}`)
} else {
conditions.push(`${key} = $${key}`)
}
})
const whereClause = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""
const afterClause = after.join(" ")
return [whereClause, afterClause].filter(Boolean).join(" ")
}

View File

@ -0,0 +1,161 @@
import { test, beforeAll, afterAll } from "bun:test"
import assert from 'assert'
import { rm } from "fs/promises"
import { openDB } from "../src/db.ts"
function xtest(...args: any) { }
class Item {
id!: number
name!: string
value?: number
}
interface Person {
id: number
name: string
age: number
}
class PersonModel {
get id() {
return this.person.id
}
constructor(private person: Person) { }
displayName() {
return `${this.person.name} (${this.person.age})`
}
}
const TEST_DBNAME = "test.datamapper.db"
const newDB = async () =>
(await openDB({ sqlite: TEST_DBNAME }))
.model(Item, 'items', { table: 'not_items' })
.model(PersonModel, 'people')
let db = await newDB()
beforeAll(async () => {
await rm(TEST_DBNAME).catch(() => { }) // Clean up any existing db
db = await newDB()
await db.run("CREATE TABLE IF NOT EXISTS not_items (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, value INTEGER)")
})
afterAll(async () => {
await db.run("DROP TABLE IF EXISTS not_items") // Drop table
await rm(TEST_DBNAME)
})
test("DataMapper setup and basic operations", async () => {
await db.run("DELETE FROM not_items") // Clear table before tests
// Create
const item = await db.items.create({ name: "Test Item 1", value: 100 })
assert.ok(item.id, "Created model should have an ID")
assert.equal(item.name, "Test Item 1")
assert.equal(item.value, 100)
// Find by ID
const found = await db.items.find(item.id)
assert.ok(found, "Should find the created model by ID")
assert.equal(found?.id, item.id)
assert.equal(found?.name, "Test Item 1")
assert.equal(found?.value, 100)
// Update
const updated = await db.items.update(item.id, { name: "Updated Test Item 1", value: 101 })
assert.ok(updated, "Update operation should return the updated model")
assert.equal(updated?.name, "Updated Test Item 1")
assert.equal(updated?.value, 101)
const verifiedUpdate = await db.items.find(item.id)
assert.equal(verifiedUpdate?.name, "Updated Test Item 1", "Model name should be updated in DB")
assert.equal(verifiedUpdate?.value, 101, "Model value should be updated in DB")
// Create another item for 'all' and 'one' tests
await db.items.create({ name: "Test Item 2", value: 200 })
// All
const all = await db.items.all()
assert.equal(all.length, 2, "Should retrieve all models")
assert.ok(all.find(m => m.name === "Updated Test Item 1"), "All models should include the updated first item")
assert.ok(all.find(m => m.name === "Test Item 2"), "All models should include the second item")
// One by specific criteria
const oneByName = await db.items.one("name = $name", { name: "Test Item 2" })
assert.ok(oneByName, "Should find one model by name")
assert.equal(oneByName?.name, "Test Item 2")
assert.equal(oneByName?.value, 200)
// One with no criteria (should pick first or use LIMIT 1)
const first = await db.items.one()
assert.ok(first, "Should retrieve one model with no specific criteria")
// The order isn't guaranteed without ORDER BY, so we check if it's one of the existing models
const firstExists = all.some(m => m.id === first.id && m.name === first.name)
assert.ok(firstExists, "The model retrieved by one() should be one of the existing models")
// Delete
const toDelete = item.id
const deleteResult = await db.items.delete(toDelete)
assert.ok(deleteResult, "Delete operation should be successful")
const deleted = await db.items.find(toDelete)
assert.equal(deleted, null, "Deleted model should not be found")
const remaining = await db.items.all()
assert.equal(remaining.length, 1, "Should have one model remaining after deletion")
assert.equal(remaining[0]?.name, "Test Item 2")
})
test("DataMapper one with object query", async () => {
await db.run("DELETE FROM not_items") // Clear table before tests
// Create test items
const item1 = await db.items.create({ name: "Jon", value: 30 })
const item2 = await db.items.create({ name: "Jon", value: 40 })
const item3 = await db.items.create({ name: "Jane", value: 30 })
// Find by single property
const jonItems = await db.items.one({ name: "Jon" })
assert.ok(jonItems, "Should find at least one item with name Jon")
assert.equal(jonItems?.name, "Jon", "Should find an item with the name Jon")
// Find by single property
const jons = await db.items.all({ name: "Jon" })
assert.equal(jons.length, 2, "Should find both items with the name Jon")
// Find by multiple properties (narrowing down)
const jon30 = await db.items.one({ name: "Jon", value: 30 })
assert.ok(jon30, "Should find the specific item with name Jon and value 30")
assert.equal(jon30?.id, item1.id, "Should match the first item created")
assert.equal(jon30?.name, "Jon")
assert.equal(jon30?.value, 30)
// Find with property that doesn't exist
const nonExistent = await db.items.one({ name: "Nobody" })
assert.equal(nonExistent, null, "Should return null when no item matches the query")
const all = await db.items.all(`name LIKE $name`, { name: `%Jon%`, limit: 1 })
assert.equal(all.length, 1, "Should find 1 item with the name Jon")
})
test("DataMapper with query and queryOne", async () => {
await db.run("DELETE FROM not_items") // Clear table before tests
// Create test items
const item1 = await db.items.create({ name: "Jon", value: 30 })
const item2 = await db.items.create({ name: "Ron", value: 40 })
const all = await db.items.query("SELECT * FROM not_items")
assert.equal(all.length, 2, "Should find 2 items")
assert.equal(all[0].name, "Jon")
assert.equal(all[1].name, "Ron")
const one = await db.items.queryOne("SELECT * FROM not_items WHERE name = $name", { name: "Jon" })
assert.equal(one?.name, "Jon")
})

View File

@ -0,0 +1,138 @@
import { test, expect, beforeAll, afterAll } from "bun:test"
import assert from "assert"
import { openDB } from "../src/db"
// Get PlanetScale credentials from environment
const DATABASE_HOST = process.env.DATABASE_HOST
const DATABASE_USERNAME = process.env.DATABASE_USERNAME
const DATABASE_PASSWORD = process.env.DATABASE_PASSWORD
const DATABASE_NAME = process.env.DATABASE_NAME
let HAS_PLANETSCALE = DATABASE_HOST && DATABASE_USERNAME && DATABASE_PASSWORD && DATABASE_NAME
if (HAS_PLANETSCALE) {
await runTests()
} else {
console.error("PlanetScale credentials not found in environment variables.\nPlease set DATABASE_HOST, DATABASE_USERNAME, DATABASE_PASSWORD, and DATABASE_NAME")
}
async function runTests() {
let db: Awaited<ReturnType<typeof openDB>>
beforeAll(async () => {
// Connect to PlanetScale test database
db = await openDB({
planetscale: {
host: DATABASE_HOST,
username: DATABASE_USERNAME,
password: DATABASE_PASSWORD,
}
})
// Set up test tables
await db.run(`
CREATE TABLE IF NOT EXISTS users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL
)
`)
})
afterAll(async () => {
// Clean up test data
await db.run("DROP TABLE IF EXISTS users")
})
type User = {
id: number
name: string
}
test("basic queries", async () => {
// Insert test data
await db.run(
"INSERT INTO users (name) VALUES ($name)",
{ name: "John" }
)
// Test single row query
const user = await db.queryOne<User>("SELECT * FROM users WHERE name = $name", { name: "John" })
assert(user !== null, "User should be defined")
assert.equal(user.name, "John")
// Test multiple rows
await db.run("INSERT INTO users (name) VALUES ($name)", { name: "Jane" })
const users = await db.query<User>("SELECT * FROM users ORDER BY name")
assert.equal(users.length, 2)
assert.equal(users[0].name, "Jane")
assert.equal(users[1].name, "John")
})
test("parameter conversion", async () => {
// Test multiple parameters
const users = await db.query<User>(
"SELECT * FROM users WHERE name LIKE $search AND id > $minId",
{ search: "J%", minId: 0 }
)
assert(users.length > 0, "Should find users matching pattern")
users.forEach(user => {
assert(user.name.startsWith("J"), "Each user name should start with J")
})
})
test("row mapping", async () => {
class UserModel {
constructor(private data: { id: number, name: string }) { }
displayName() {
return `User ${this.data.name}`
}
}
const user = await db.queryOne<User, UserModel>(
"SELECT * FROM users WHERE name = $name",
{ name: "John" },
row => new UserModel(row)
)
assert(user !== null, "User should be defined")
assert.equal(user.displayName(), "User John")
})
test("no results handling", async () => {
const nonExistentUser = await db.queryOne<User>(
"SELECT * FROM users WHERE name = $name",
{ name: "NonExistent" }
)
assert.equal(nonExistentUser, null)
const noUsers = await db.query<User>(
"SELECT * FROM users WHERE name = $name",
{ name: "NonExistent" }
)
assert.equal(noUsers.length, 0)
})
test("transactions", async () => {
await db.run("CREATE TABLE IF NOT EXISTS messages (message TEXT)")
await db.run("DROP TABLE IF EXISTS messages")
await db.run("CREATE TABLE IF NOT EXISTS messages (message TEXT)")
try {
await db.transaction(async (tx) => {
await tx.run("INSERT INTO messages (message) VALUES ($message)", { message: 'hey there' })
const result = await tx.queryOne<{ message: string }>("SELECT message FROM messages")
assert.equal(result?.message, "hey there")
throw new Error("Kill the transaction")
})
} catch (e) {
// console.error(e)
}
const result = await db.queryOne<{ message: string }>("SELECT message FROM messages")
assert.equal(result, null)
// Clean up
await db.run("DROP TABLE IF EXISTS messages")
})
}

View File

@ -0,0 +1,91 @@
import { test, beforeAll, afterAll } from "bun:test"
import assert from 'assert'
import { rm } from "fs/promises"
import { openDB, DB } from "../src/db.ts"
type Message = { message: string }
class User {
id: number
name: string
constructor({ id, name }: { id: number, name: string }) {
this.id = id
this.name = name
}
displayName() {
return "Mx. " + this.name
}
}
const TEST_DBNAME = "test-db1.db"
let db = await openDB({ sqlite: TEST_DBNAME })
beforeAll(async () => {
await rm(TEST_DBNAME).catch(() => { }) // Clean up any existing db
db = await openDB({ sqlite: TEST_DBNAME })
await db.run("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)")
})
afterAll(async () => {
await rm(TEST_DBNAME)
})
test("db.queryOne", async () => {
const result = await db.queryOne<Message>("SELECT 'hey there' as message")
assert.equal(result?.message, "hey there")
})
test("db.query", async () => {
const result = await db.query<Message>("SELECT 'hey there' as message")
assert.equal(result[0]?.message, "hey there")
})
test("db.run", async () => {
await db.run("CREATE TABLE IF NOT EXISTS messages (message TEXT)")
await db.run("INSERT INTO messages (message) VALUES ('hey there')")
const result = await db.queryOne<Message>("SELECT message FROM messages")
assert.equal(result?.message, "hey there")
})
test("User model", async () => {
await db.run("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)")
await db.run("INSERT INTO users (name) VALUES ('John')")
await db.run("INSERT INTO users (name) VALUES ('Jane')")
await db.run("INSERT INTO users (name) VALUES ('Jimbo')")
await db.run("INSERT INTO users (name) VALUES ('Jones')")
await db.run("INSERT INTO users (name) VALUES ('Rogers')")
const users = await db.query<User>("SELECT * FROM users WHERE name LIKE $search", { search: "J%" })
assert.equal(users.length, 4)
assert.equal(users[0]?.name, "John")
assert.equal(users[1]?.name, "Jane")
assert.equal(users[2]?.name, "Jimbo")
assert.equal(users[3]?.name, "Jones")
const user = await db.queryOne<User>("SELECT * FROM users WHERE name LIKE $search", { search: "J%" }, row => new User(row))
assert.equal(user?.name, "John")
assert.equal(user?.displayName(), "Mx. John")
})
test("transactions", async () => {
await db.run("DROP TABLE IF EXISTS messages")
await db.run("CREATE TABLE IF NOT EXISTS messages (message TEXT)")
try {
await db.transaction(async (tx: DB) => {
await tx.run("INSERT INTO messages (message) VALUES ('hey there')")
const result = await tx.queryOne<Message>("SELECT message FROM messages")
assert.equal(result?.message, "hey there")
throw new Error("Kill the transaction")
})
} catch (e) {
// console.error(e)
}
const result = await db.queryOne<Message>("SELECT message FROM messages")
assert.equal(result, null)
})

View File

@ -0,0 +1,48 @@
import { test } from "bun:test"
import assert from "assert"
import { field } from "../src/schema"
import { openDB } from "../src/db"
class Person {
@field('primary key')
id!: number
@field('required')
name!: string
}
class User {
@field('primary key')
id!: number
@field('required', 'unique', 'email')
email!: string
@field('required')
name!: string
@field()
age?: number
}
const db = (await openDB({ sqlite: ":memory:" }))
.model(Person, 'people')
.model(User, 'users')
test("basic sqlite schema generation", () => {
assert.equal(db.people.schema(),
"CREATE TABLE IF NOT EXISTS people (" +
"id INTEGER PRIMARY KEY AUTOINCREMENT, " +
"name TEXT NOT NULL" +
")"
)
assert.equal(db.users.schema(),
"CREATE TABLE IF NOT EXISTS users (" +
"id INTEGER PRIMARY KEY AUTOINCREMENT, " +
"email TEXT NOT NULL UNIQUE, " +
"name TEXT NOT NULL, " +
"age INTEGER" +
")"
)
})

View File

@ -0,0 +1,54 @@
import { test } from "bun:test"
import assert from "assert"
import { field, validate } from "../src/schema"
class Person {
@field('required')
name!: string
}
class User {
@field('primary key')
id!: number
@field('required', 'email')
email!: string
@field('required')
name!: string
age?: number
}
test("required decorator validates presence", () => {
const p = new Person()
const [ok, errors] = validate(p)
assert.equal(ok, false)
assert.deepEqual(errors, { name: ["is required"] })
})
test("validate returns null when no errors", () => {
const p = new Person()
p.name = "Alice"
const [ok, errors] = validate(p)
assert.equal(ok, true)
assert.deepEqual(errors, {})
})
test("validate returns errors when invalid", () => {
const u = new User()
u.email = "not an email"
const [ok, errors] = validate(u)
assert.equal(ok, false)
assert.deepEqual(errors, { id: ["must be an integer"], name: ["is required"], email: ["must be a valid email address"] })
})
test("rules can stack", () => {
const u = new User()
u.email = ""
u.name = ""
const [ok, errors] = validate(u)
assert.equal(ok, false)
assert.deepEqual(errors, { id: ["must be an integer"], email: ["is required", "must be a valid email address"], name: ["is required"] })
})

View File

@ -0,0 +1,79 @@
import { test } from "bun:test"
import assert from "assert"
import { buildWhere } from "../src/where-builder"
test("buildWhere with number ID", () => {
assert.throws(() => buildWhere(502), /Invalid query/)
})
test("buildWhere with string ID", () => {
assert.equal(buildWhere("name = 'bob'"), "WHERE name = 'bob'")
})
test("buildWhere with simple object", () => {
assert.equal(buildWhere({ name: "bob" }), "WHERE name = $name")
assert.equal(buildWhere({ id: 502 }), "WHERE id = $id")
})
test("buildWhere with multiple conditions", () => {
assert.equal(buildWhere({ name: "bob", age: 20 }), "WHERE name = $name AND age = $age")
})
test("buildWhere with special parameters", () => {
// Test limit
assert.equal(buildWhere({ name: "bob", limit: 10 }), "WHERE name = $name LIMIT 10")
// Test offset
assert.equal(buildWhere({ name: "bob", offset: 5 }), "WHERE name = $name OFFSET 5")
// Test order
assert.equal(buildWhere({ name: "bob", order: "age DESC" }), "WHERE name = $name ORDER BY age DESC")
})
test("buildWhere with combined special parameters", () => {
assert.equal(
buildWhere({
name: "bob",
age: 20,
limit: 10,
offset: 5,
order: "name ASC",
}),
"WHERE name = $name AND age = $age LIMIT 10 OFFSET 5 ORDER BY name ASC",
)
})
test("buildWhere with empty or invalid input", () => {
assert.throws(() => buildWhere(null as any), /Invalid query/)
assert.throws(() => buildWhere(undefined as any), /Invalid query/)
assert.throws(() => buildWhere(true as any), /Invalid query/)
assert.throws(() => buildWhere([] as any), /Invalid query/)
})
test("buildWhere with only special parameters", () => {
assert.equal(buildWhere({ limit: 10 }), "LIMIT 10")
assert.equal(buildWhere({ offset: 5 }), "OFFSET 5")
assert.equal(buildWhere({ order: "id DESC" }), "ORDER BY id DESC")
assert.equal(buildWhere({ limit: 10, offset: 5 }), "LIMIT 10 OFFSET 5")
})
// test("buildWhere with array", () => {
// assert.throws(() => buildWhere([53, { name: "bob" }]), /Invalid query/)
// assert.throws(() => buildWhere([55]), /Invalid query/)
// assert.throws(() => buildWhere(["id = 100", { limit: 50 }]), /Invalid query/)
// assert.throws(() => buildWhere([53, { name: "bob" }, { age: 20 }, { offset: 20 }]), /Invalid query/)
// })
test("buildWhere with sql string and args", () => {
assert.equal(buildWhere("id = $id", { id: 502 }), "WHERE id = $id")
assert.equal(buildWhere("502"), "WHERE 502")
assert.equal(buildWhere("id = 502"), "WHERE id = 502")
assert.equal(buildWhere({ id: 502 }), "WHERE id = $id")
assert.equal(buildWhere({ name: "bob" }), "WHERE name = $name")
assert.equal(buildWhere({ name: "bob", limit: 10 }), "WHERE name = $name LIMIT 10")
assert.equal(buildWhere({ name: "bob", age: 20 }), "WHERE name = $name AND age = $age")
assert.equal(buildWhere({ name: "bob", age: 20, limit: 10 }), "WHERE name = $name AND age = $age LIMIT 10")
assert.equal(buildWhere({ name: "bob", age: 20, limit: 10, offset: 5 }), "WHERE name = $name AND age = $age LIMIT 10 OFFSET 5")
assert.equal(buildWhere("id = $id", { id: 502, limit: 10, offset: 5 }), "WHERE id = $id LIMIT 10 OFFSET 5")
assert.equal(buildWhere("id = $id", { id: 502, name: "bob", limit: 10, offset: 5 }), "WHERE id = $id AND name = $name LIMIT 10 OFFSET 5")
})

View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es2022",
"module": "esnext",
"strict": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"esModuleInterop": true
},
"exclude": [
"node_modules"
]
}