diff --git a/bun.lock b/bun.lock index dd31f12..36bbc7d 100644 --- a/bun.lock +++ b/bun.lock @@ -18,19 +18,13 @@ "typescript": "^5", }, }, - "packages/mything": { - "name": "bun-react-template", - "version": "0.1.0", - "dependencies": { - "bun-plugin-tailwind": "^0.0.14", - "react": "^19", - "react-dom": "^19", - "tailwindcss": "^4.0.6", - }, + "packages/iago": { + "name": "@workshop/iago", "devDependencies": { "@types/bun": "latest", - "@types/react": "^19", - "@types/react-dom": "^19", + }, + "peerDependencies": { + "typescript": "^5", }, }, "packages/nano-remix": { @@ -45,6 +39,19 @@ "typescript": "^5", }, }, + "packages/query": { + "name": "@workshop/query", + "dependencies": { + "@planetscale/database": "^1.19.0", + "reflect-metadata": "^0.2.2", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, "packages/shared": { "name": "@workshop/shared", "devDependencies": { @@ -84,42 +91,38 @@ "@discordjs/ws": ["@discordjs/ws@1.2.2", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.0", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-dyfq7yn0wO0IYeYOs3z79I6/HumhmKISzFL0Z+007zQJMtAFGtt3AEoq1nuLXtcunUE5YYYQqgKvybXukAK8/w=="], + "@planetscale/database": ["@planetscale/database@1.19.0", "", {}, "sha512-Tv4jcFUFAFjOWrGSio49H6R2ijALv0ZzVBfJKIdm+kl9X046Fh4LLawrF9OMsglVbK6ukqMJsUCeucGAFTBcMA=="], + "@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="], "@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="], "@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="], - "@workshop/http": ["@workshop/http@workspace:packages/http"], - - "@workshop/nano-remix": ["@workshop/nano-remix@workspace:packages/nano-remix"], - - "@workshop/shared": ["@workshop/shared@workspace:packages/shared"], - - "@workshop/spike": ["@workshop/spike@workspace:packages/spike"], - "@types/bun": ["@types/bun@1.2.16", "", { "dependencies": { "bun-types": "1.2.16" } }, "sha512-1aCZJ/6nSiViw339RsaNhkNoEloLaPzZhxMOYEa7OzRzO41IGg5n/7I43/ZIAW/c+Q6cT12Vf7fOZOoVIzb5BQ=="], "@types/luxon": ["@types/luxon@3.6.2", "", {}, "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw=="], "@types/node": ["@types/node@24.0.1", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-MX4Zioh39chHlDJbKmEgydJDS3tspMP/lnQC67G3SWsTnb9NeYVWOjkxpOSy4oMfPs4StcWHwBrvUb4ybfnuaw=="], - "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], - - "@types/react-dom": ["@types/react-dom@19.1.6", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw=="], - "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.6", "", {}, "sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA=="], - "bun-plugin-tailwind": ["bun-plugin-tailwind@0.0.14", "", { "dependencies": { "tailwindcss": "4.0.0-beta.9" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-Ge8M8DQsRDErCzH/uI8pYjx5vZWXxQvnwM/xMQMElxQqHieGbAopfYo/q/kllkPkRbFHiwhnHwTpRMAMJZCjug=="], + "@workshop/http": ["@workshop/http@workspace:packages/http"], - "bun-react-template": ["bun-react-template@workspace:packages/mything"], + "@workshop/iago": ["@workshop/iago@workspace:packages/iago"], + + "@workshop/nano-remix": ["@workshop/nano-remix@workspace:packages/nano-remix"], + + "@workshop/query": ["@workshop/query@workspace:packages/query"], + + "@workshop/shared": ["@workshop/shared@workspace:packages/shared"], + + "@workshop/spike": ["@workshop/spike@workspace:packages/spike"], "bun-types": ["bun-types@1.2.16", "", { "dependencies": { "@types/node": "*" } }, "sha512-ciXLrHV4PXax9vHvUrkvun9VPVGOVwbbbBF/Ev1cXz12lyEZMoJpIJABOfPcN9gDJRaiKF9MVbSygLg4NXu3/A=="], - "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], - "discord-api-types": ["discord-api-types@0.38.11", "", {}, "sha512-XN0qhcQpetkyb/49hcDHuoeUPsQqOkb17wbV/t48gUkoEDi4ajhsxqugGcxvcN17BBtI9FPPWEgzv6IhQmCwyw=="], "discord.js": ["discord.js@14.19.3", "", { "dependencies": { "@discordjs/builders": "^1.11.2", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.1", "@discordjs/rest": "^2.5.0", "@discordjs/util": "^1.1.1", "@discordjs/ws": "^1.2.2", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.1", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.1" } }, "sha512-lncTRk0k+8Q5D3nThnODBR8fR8x2fM798o8Vsr40Krx0DjPwpZCuxxTcFMrXMQVOqM1QB9wqWgaXPg3TbmlHqA=="], @@ -138,13 +141,7 @@ "openai": ["openai@5.3.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-VIKmoF7y4oJCDOwP/oHXGzM69+x0dpGFmN9QmYO+uPbLFOmmnwO+x1GbsgUtI+6oraxomGZ566Y421oYVu191w=="], - "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], - - "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], - - "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], - - "tailwindcss": ["tailwindcss@4.1.10", "", {}, "sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA=="], + "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], "ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="], @@ -163,7 +160,5 @@ "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], "@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], - - "bun-plugin-tailwind/tailwindcss": ["tailwindcss@4.0.0-beta.9", "", {}, "sha512-96KpsfQi+/sFIOfyFnGzyy5pobuzf1iMBD9NVtelerPM/lPI2XUS4Kikw9yuKRniXXw77ov1sl7gCSKLsn6CJA=="], } } diff --git a/packages/query/.cursorrules b/packages/query/.cursorrules new file mode 100644 index 0000000..35bb9fa --- /dev/null +++ b/packages/query/.cursorrules @@ -0,0 +1,43 @@ +## Style + +- No semicolons — ever. +- No comments — ever. +- 2‑space 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 follow‑up questions unless clarification is essential. + +Stay simple, readable, and stick to these rules. diff --git a/packages/query/.gitignore b/packages/query/.gitignore new file mode 100644 index 0000000..f4912e3 --- /dev/null +++ b/packages/query/.gitignore @@ -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 diff --git a/packages/query/README.md b/packages/query/README.md new file mode 100644 index 0000000..7b75951 --- /dev/null +++ b/packages/query/README.md @@ -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( + "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`: Create a new record +- `db.table.update(id: string | number, data: Partial): Model | null`: Update a record +- `db.table.delete(id: string | number): boolean`: Delete a record +- `db.table.one(where?: string, args?: Record): Model | null`: Get one record with optional where clause +- `db.table.all(where?: string | Record, args?: Record): 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" }) +}) +``` diff --git a/packages/query/bun.lock b/packages/query/bun.lock new file mode 100644 index 0000000..63a64b2 --- /dev/null +++ b/packages/query/bun.lock @@ -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=="], + } +} diff --git a/packages/query/examples/basic.ts b/packages/query/examples/basic.ts new file mode 100644 index 0000000..5c0ce5c --- /dev/null +++ b/packages/query/examples/basic.ts @@ -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) \ No newline at end of file diff --git a/packages/query/examples/db.ts b/packages/query/examples/db.ts new file mode 100644 index 0000000..1b7ae25 --- /dev/null +++ b/packages/query/examples/db.ts @@ -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) \ No newline at end of file diff --git a/packages/query/package.json b/packages/query/package.json new file mode 100644 index 0000000..7fc1cd3 --- /dev/null +++ b/packages/query/package.json @@ -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" + } +} diff --git a/packages/query/src/db.ts b/packages/query/src/db.ts new file mode 100644 index 0000000..125c4bf --- /dev/null +++ b/packages/query/src/db.ts @@ -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("SELECT * FROM users WHERE id = $id", { id: 1 }) +// console.log("It's", user.name,"!") +export async function openDB(config: DBConfig): Promise { + 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> = new Map() + + abstract query(sql: string, args?: SQLArgs, map?: (row: T) => U): Promise + + async close(): Promise { } + + async queryOne(sql: string, args?: SQLArgs, map?: (row: T) => U): Promise { + return (await this.query(sql, args, map))[0] ?? null + } + + async run(sql: string, args?: SQLArgs): Promise { + await this.query(sql, args) + } + + abstract transaction(fn: (tx: DB) => Promise): Promise + + + model( + this: Self, + modelClass: new (...args: any[]) => T, + propName: Name, + cfg?: ModelConfig + ): Self & { [K in Name]: Mapper } + + // 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 { + await this.__db.close() + } + + async query(sql: string, args?: SQLArgs, map?: (row: T) => U): Promise { + // 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(fn: (tx: DB) => Promise): Promise { + 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) => Promise }) { + super() + } + + private convertArgs(args?: SQLArgs): Record | undefined { + if (!args) return undefined + + // Convert SQLite $param style to PlanetScale :param style + const converted: Record = {} + 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(sql: string, args?: Record): Promise { + const result = await this.__conn.execute(sql, args) + return result.rows as T[] + } + + async query(sql: string, args?: SQLArgs, map?: (row: T) => U): Promise { + const convertedSql = this.convertQuery(sql) + const convertedArgs = this.convertArgs(args) + const rows = await this.executeQuery(convertedSql, convertedArgs) + return map ? rows.map(map) : (rows as unknown as U[]) + } + + async transaction(fn: (tx: DB) => Promise): Promise { + 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") + } +} diff --git a/packages/query/src/index.ts b/packages/query/src/index.ts new file mode 100644 index 0000000..5865f4b --- /dev/null +++ b/packages/query/src/index.ts @@ -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 = Partial | QueryOpts | (Partial & QueryOpts) \ No newline at end of file diff --git a/packages/query/src/mapper.ts b/packages/query/src/mapper.ts new file mode 100644 index 0000000..16a3e46 --- /dev/null +++ b/packages/query/src/mapper.ts @@ -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 { + find(id: string | number): Promise + create(data: Partial): Promise + update(id: string | number, data: Partial): Promise + delete(id: string | number): Promise + all(where?: string | Where, args?: Where): Promise + one(where?: string | Where, args?: Where): Promise + query(sql: string, args?: SQLArgs): Promise + queryOne(sql: string, args?: SQLArgs): Promise + schema(): string +} + +export class BaseMapper implements Mapper { + 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 { + const sql = `SELECT * FROM ${this.table} WHERE ${this.id} = $id` + const args = { id } + return await this.db.queryOne( + sql, + args, + (row) => this.initializeModel(row), + ) + } + + async create(data: Partial): Promise { + 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) + + const result = await this.db.queryOne( + `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): Promise { + 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), + } + + return await this.db.queryOne( + `UPDATE ${this.table} + SET ${updates} + WHERE ${this.id} = $id + RETURNING * `, + args, + (row) => this.initializeModel(row), + ) + } + + async delete(id: string | number): Promise { + await this.db.run( + `DELETE FROM ${this.table} WHERE ${this.id} = $id`, + { id }, + ) + return true + } + + async one(where?: string | Where, args?: Where): Promise { + return (await this.all(where, args ? { ...args, limit: 1 } : { limit: 1 }))[0] ?? null + } + + async all(where?: string | Where, args?: Where): Promise { + 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( + query, + args as Record, + (row) => this.initializeModel(row), + ) + } else { + const whereClause = buildWhere(where as WhereClause) + if (whereClause) query += ` ${whereClause}` + + return await this.db.query( + query, + where as Record, + (row) => this.initializeModel(row), + ) + } + } + + return await this.db.query( + query, + args as Record, + (row) => this.initializeModel(row), + ) + } + + async query(sql: string, args?: SQLArgs): Promise { + return await this.db.query(sql, args, (row) => this.initializeModel(row)) + } + + async queryOne(sql: string, args?: SQLArgs): Promise { + return await this.db.queryOne(sql, args, (row) => this.initializeModel(row)) + } + + schema(): string { + return schema(this.construct, this.table) + } +} + diff --git a/packages/query/src/schema.ts b/packages/query/src/schema.ts new file mode 100644 index 0000000..d59bdb2 --- /dev/null +++ b/packages/query/src/schema.ts @@ -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 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: key is the property name, value is an array of error messages +export function validate>(obj: T): [ok: boolean, errors: Record] { + const ctor = obj.constructor as any + const rules = ctor.__vrules ?? {} + const errors: Record = {} + + 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 = { + Number: 'INTEGER', + String: 'TEXT', + Boolean: 'BOOLEAN', +} + +const SQLITE_RULES: Record = { + '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(ctor: new (...args: any[]) => T, table: string): string { + return sqliteSchema(ctor, table) +} + +export function sqliteSchema(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(", ")})` +} + diff --git a/packages/query/src/where-builder.ts b/packages/query/src/where-builder.ts new file mode 100644 index 0000000..056a0aa --- /dev/null +++ b/packages/query/src/where-builder.ts @@ -0,0 +1,85 @@ +import type { Atom } from "./db" + +export type WhereClause = Atom | Record + +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, after) + default: + throw new Error("Invalid query") + } +} + +function buildObjectWhere(query: Record, 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(" ") +} diff --git a/packages/query/test/mapper.test.ts b/packages/query/test/mapper.test.ts new file mode 100644 index 0000000..d338ced --- /dev/null +++ b/packages/query/test/mapper.test.ts @@ -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") +}) \ No newline at end of file diff --git a/packages/query/test/planetscale.test.ts b/packages/query/test/planetscale.test.ts new file mode 100644 index 0000000..3cc7699 --- /dev/null +++ b/packages/query/test/planetscale.test.ts @@ -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> + + 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("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("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( + "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( + "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( + "SELECT * FROM users WHERE name = $name", + { name: "NonExistent" } + ) + assert.equal(nonExistentUser, null) + + const noUsers = await db.query( + "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") + }) +} \ No newline at end of file diff --git a/packages/query/test/query.test.ts b/packages/query/test/query.test.ts new file mode 100644 index 0000000..8f5f11f --- /dev/null +++ b/packages/query/test/query.test.ts @@ -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("SELECT 'hey there' as message") + assert.equal(result?.message, "hey there") +}) + +test("db.query", async () => { + const result = await db.query("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("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("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("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("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("SELECT message FROM messages") + assert.equal(result, null) +}) diff --git a/packages/query/test/schema.test.ts b/packages/query/test/schema.test.ts new file mode 100644 index 0000000..81d1774 --- /dev/null +++ b/packages/query/test/schema.test.ts @@ -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" + + ")" + ) +}) \ No newline at end of file diff --git a/packages/query/test/validator.test.ts b/packages/query/test/validator.test.ts new file mode 100644 index 0000000..c6e2b91 --- /dev/null +++ b/packages/query/test/validator.test.ts @@ -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"] }) +}) \ No newline at end of file diff --git a/packages/query/test/where.test.ts b/packages/query/test/where.test.ts new file mode 100644 index 0000000..cb613ac --- /dev/null +++ b/packages/query/test/where.test.ts @@ -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") +}) diff --git a/packages/query/tsconfig.json b/packages/query/tsconfig.json new file mode 100644 index 0000000..c52e0a2 --- /dev/null +++ b/packages/query/tsconfig.json @@ -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" + ] +} \ No newline at end of file