add query package
This commit is contained in:
parent
1d6a73676b
commit
895fc864b0
67
bun.lock
67
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=="],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
43
packages/query/.cursorrules
Normal file
43
packages/query/.cursorrules
Normal file
|
|
@ -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.
|
||||
39
packages/query/.gitignore
vendored
Normal file
39
packages/query/.gitignore
vendored
Normal 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
136
packages/query/README.md
Normal 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
33
packages/query/bun.lock
Normal 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=="],
|
||||
}
|
||||
}
|
||||
116
packages/query/examples/basic.ts
Normal file
116
packages/query/examples/basic.ts
Normal 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)
|
||||
22
packages/query/examples/db.ts
Normal file
22
packages/query/examples/db.ts
Normal 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)
|
||||
18
packages/query/package.json
Normal file
18
packages/query/package.json
Normal 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
145
packages/query/src/db.ts
Normal 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")
|
||||
}
|
||||
}
|
||||
26
packages/query/src/index.ts
Normal file
26
packages/query/src/index.ts
Normal 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)
|
||||
151
packages/query/src/mapper.ts
Normal file
151
packages/query/src/mapper.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
122
packages/query/src/schema.ts
Normal file
122
packages/query/src/schema.ts
Normal 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(", ")})`
|
||||
}
|
||||
|
||||
85
packages/query/src/where-builder.ts
Normal file
85
packages/query/src/where-builder.ts
Normal 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(" ")
|
||||
}
|
||||
161
packages/query/test/mapper.test.ts
Normal file
161
packages/query/test/mapper.test.ts
Normal 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")
|
||||
})
|
||||
138
packages/query/test/planetscale.test.ts
Normal file
138
packages/query/test/planetscale.test.ts
Normal 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")
|
||||
})
|
||||
}
|
||||
91
packages/query/test/query.test.ts
Normal file
91
packages/query/test/query.test.ts
Normal 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)
|
||||
})
|
||||
48
packages/query/test/schema.test.ts
Normal file
48
packages/query/test/schema.test.ts
Normal 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" +
|
||||
")"
|
||||
)
|
||||
})
|
||||
54
packages/query/test/validator.test.ts
Normal file
54
packages/query/test/validator.test.ts
Normal 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"] })
|
||||
})
|
||||
79
packages/query/test/where.test.ts
Normal file
79
packages/query/test/where.test.ts
Normal 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")
|
||||
})
|
||||
16
packages/query/tsconfig.json
Normal file
16
packages/query/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user