@bunnykit/orm 0.1.5 â 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +385 -84
- package/dist/bin/bunny.js +133 -19
- package/dist/src/config/BunnyConfig.d.ts +28 -0
- package/dist/src/config/BunnyConfig.js +14 -0
- package/dist/src/connection/Connection.d.ts +20 -1
- package/dist/src/connection/Connection.js +80 -2
- package/dist/src/connection/ConnectionManager.d.ts +40 -0
- package/dist/src/connection/ConnectionManager.js +104 -0
- package/dist/src/connection/TenantContext.d.ts +15 -0
- package/dist/src/connection/TenantContext.js +22 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.js +3 -0
- package/dist/src/model/BelongsToMany.js +9 -6
- package/dist/src/model/Model.d.ts +5 -0
- package/dist/src/model/Model.js +57 -20
- package/dist/src/model/MorphRelations.js +10 -10
- package/dist/src/query/Builder.d.ts +44 -5
- package/dist/src/query/Builder.js +252 -113
- package/dist/src/query/grammars/Grammar.d.ts +19 -0
- package/dist/src/query/grammars/Grammar.js +47 -0
- package/dist/src/query/grammars/MySqlGrammar.d.ts +13 -0
- package/dist/src/query/grammars/MySqlGrammar.js +59 -0
- package/dist/src/query/grammars/PostgresGrammar.d.ts +13 -0
- package/dist/src/query/grammars/PostgresGrammar.js +62 -0
- package/dist/src/query/grammars/SQLiteGrammar.d.ts +14 -0
- package/dist/src/query/grammars/SQLiteGrammar.js +63 -0
- package/dist/src/schema/Schema.js +44 -26
- package/dist/src/typegen/TypeGenerator.js +4 -2
- package/dist/src/types/index.d.ts +10 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,13 +8,14 @@
|
|
|
8
8
|
>
|
|
9
9
|
> npm, yarn, pnpm, and Node.js runtime usage are not supported.
|
|
10
10
|
|
|
11
|
-
An **Eloquent-inspired ORM** built specifically for [Bun](https://bun.sh)'s native `bun:sql` client.
|
|
11
|
+
An **Eloquent-inspired ORM** built specifically for [Bun](https://bun.sh)'s native `bun:sql` client. It ships with **zero runtime dependencies** and supports **SQLite**, **MySQL**, and **PostgreSQL** with full TypeScript typing, a chainable query builder, schema migrations, model observers, polymorphic relations, and an interactive REPL.
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
## Features
|
|
16
16
|
|
|
17
17
|
- ðĨ **Bun-native** â Built on top of `bun:sql` for maximum performance
|
|
18
|
+
- ðŠķ **Zero runtime dependencies** â No package lock-in beyond Bun itself
|
|
18
19
|
- ðĶ **Multi-database** â SQLite, MySQL, and PostgreSQL support
|
|
19
20
|
- ð· **Fully Typed** â Written in TypeScript with generics everywhere
|
|
20
21
|
- ðïļ **Schema Builder** â Programmatic table creation, indexes, foreign keys
|
|
@@ -23,6 +24,7 @@ An **Eloquent-inspired ORM** built specifically for [Bun](https://bun.sh)'s nati
|
|
|
23
24
|
- ð **Relations** â Standard, many-to-many, polymorphic, through, one-of-many, and relation queries
|
|
24
25
|
- ðïļ **Observers** â Lifecycle hooks (`creating`, `created`, `updating`, `updated`, etc.)
|
|
25
26
|
- ð **Migrations & CLI** â Create, run, and rollback migrations from the command line
|
|
27
|
+
- ðŽ **REPL** â Inspect models and run queries interactively with `bunny repl`
|
|
26
28
|
- ⥠**Streaming** â `chunk`, `cursor`, `each`, and `lazy` for memory-efficient large dataset processing
|
|
27
29
|
|
|
28
30
|
---
|
|
@@ -54,14 +56,29 @@ export default {
|
|
|
54
56
|
// password: "secret",
|
|
55
57
|
},
|
|
56
58
|
migrationsPath: ["./database/migrations", "./database/tenant-migrations"],
|
|
59
|
+
// Optional grouped migrations for multi-tenant apps
|
|
60
|
+
// migrations: {
|
|
61
|
+
// landlord: "./database/landlord-migrations",
|
|
62
|
+
// tenant: "./database/tenant-migrations",
|
|
63
|
+
// },
|
|
64
|
+
// Optional tenant resolver for dynamic multi-tenant apps.
|
|
65
|
+
// Apps call configureBunny(config) at startup to register this resolver.
|
|
66
|
+
// tenancy: {
|
|
67
|
+
// resolveTenant: async (tenantId) => ({
|
|
68
|
+
// strategy: "database",
|
|
69
|
+
// name: `tenant:${tenantId}`,
|
|
70
|
+
// config: await getTenantConnectionConfig(tenantId),
|
|
71
|
+
// }),
|
|
72
|
+
// listTenants: async () => await getAllTenantIds(),
|
|
73
|
+
// },
|
|
57
74
|
modelsPath: ["./src/models", "./src/admin/models"],
|
|
58
75
|
// Optional legacy type output directory
|
|
59
|
-
typesOutDir: "./src/generated/model-types",
|
|
76
|
+
// typesOutDir: "./src/generated/model-types",
|
|
60
77
|
// Optional typegen overrides
|
|
61
|
-
typeDeclarationImportPrefix: "../models",
|
|
62
|
-
typeDeclarations: {
|
|
63
|
-
|
|
64
|
-
},
|
|
78
|
+
// typeDeclarationImportPrefix: "../models",
|
|
79
|
+
// typeDeclarations: {
|
|
80
|
+
// admin_users: { path: "../AdminAccount", className: "AdminAccount" },
|
|
81
|
+
// },
|
|
65
82
|
};
|
|
66
83
|
```
|
|
67
84
|
|
|
@@ -111,6 +128,136 @@ Model.setConnection(connection);
|
|
|
111
128
|
Schema.setConnection(connection);
|
|
112
129
|
```
|
|
113
130
|
|
|
131
|
+
Or apply the same `bunny.config.ts` used by the CLI in your application bootstrap:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import config from "../bunny.config";
|
|
135
|
+
import { configureBunny } from "@bunnykit/orm";
|
|
136
|
+
|
|
137
|
+
const { connection } = configureBunny(config);
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Dynamic Tenant Connections
|
|
141
|
+
|
|
142
|
+
Use `ConnectionManager` and `TenantContext` when tenants are discovered at runtime instead of listed in config:
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import { ConnectionManager, TenantContext } from "@bunnykit/orm";
|
|
146
|
+
|
|
147
|
+
ConnectionManager.setTenantResolver(async (tenantId) => {
|
|
148
|
+
const tenant = await lookupTenant(tenantId); // your app owns this lookup
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
strategy: "database",
|
|
152
|
+
name: `tenant:${tenant.id}`,
|
|
153
|
+
config: { url: tenant.databaseUrl },
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await TenantContext.run("acme", async () => {
|
|
158
|
+
const users = await User.all();
|
|
159
|
+
await Invoice.create({ total: 100 });
|
|
160
|
+
});
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
You can also define the resolver in `bunny.config.ts` and reuse the same config in your app and CLI:
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
// bunny.config.ts
|
|
167
|
+
export default {
|
|
168
|
+
connection: { url: process.env.LANDLORD_DATABASE_URL! },
|
|
169
|
+
tenancy: {
|
|
170
|
+
resolveTenant: async (tenantId) => ({
|
|
171
|
+
strategy: "database",
|
|
172
|
+
name: `tenant:${tenantId}`,
|
|
173
|
+
config: await getTenantConnectionConfig(tenantId),
|
|
174
|
+
}),
|
|
175
|
+
listTenants: async () => await getAllTenantIds(),
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Then register it in app startup:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
import config from "../bunny.config";
|
|
184
|
+
import { configureBunny } from "@bunnykit/orm";
|
|
185
|
+
|
|
186
|
+
configureBunny(config);
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
`configureBunny(config)` registers `tenancy.resolveTenant` for application code automatically. `listTenants()` is only used by the CLI when running grouped tenant migrations.
|
|
190
|
+
|
|
191
|
+
For PostgreSQL schema-per-tenant systems, return a shared database config plus a schema. PostgreSQL is the only supported driver for ORM-level schema switching; for MySQL and SQLite, use dynamic `strategy: "database"` instead.
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
ConnectionManager.setTenantResolver(async (tenantId) => ({
|
|
195
|
+
strategy: "schema",
|
|
196
|
+
name: `tenant:${tenantId}`,
|
|
197
|
+
config: { url: process.env.DATABASE_URL!, schema: `tenant_${tenantId}` },
|
|
198
|
+
schema: `tenant_${tenantId}`,
|
|
199
|
+
}));
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
For PostgreSQL systems that prefer `SET search_path` over table qualification, reuse the existing default connection and opt into transaction-scoped search path switching:
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
ConnectionManager.setTenantResolver(async (tenantId) => ({
|
|
206
|
+
strategy: "schema",
|
|
207
|
+
name: `tenant:${tenantId}`,
|
|
208
|
+
schema: `tenant_${tenantId}`,
|
|
209
|
+
mode: "search_path",
|
|
210
|
+
}));
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
`mode: "search_path"` runs the tenant callback inside a PostgreSQL transaction and applies `SET LOCAL search_path`, so the schema switch stays bound to the same database session and is reset when the transaction ends.
|
|
214
|
+
|
|
215
|
+
For PostgreSQL RLS, return `strategy: "rls"`. The ORM sets a transaction-local tenant variable before running the tenant callback. The setting defaults to `app.tenant_id`, but can be customized:
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
ConnectionManager.setTenantResolver(async (tenantId) => ({
|
|
219
|
+
strategy: "rls",
|
|
220
|
+
name: "main",
|
|
221
|
+
tenantId,
|
|
222
|
+
setting: "app.current_tenant_id",
|
|
223
|
+
}));
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Your PostgreSQL policies should read the same setting, for example `current_setting('app.current_tenant_id')`.
|
|
227
|
+
|
|
228
|
+
Resolved tenants are cached. Use `await ConnectionManager.resolveTenant("acme")` to preload a tenant, `User.forTenant("acme")` for an already resolved tenant, and `ConnectionManager.purgeTenant("acme")` when tenant connection metadata changes.
|
|
229
|
+
|
|
230
|
+
### Landlord and Tenant Migrations
|
|
231
|
+
|
|
232
|
+
For multi-tenant apps, use grouped migrations so landlord tables and tenant tables can be migrated separately:
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
export default {
|
|
236
|
+
connection: { url: process.env.LANDLORD_DATABASE_URL! },
|
|
237
|
+
migrations: {
|
|
238
|
+
landlord: ["./database/landlord-migrations", "./modules/billing/migrations"],
|
|
239
|
+
tenant: ["./database/tenant-migrations", "./modules/tenant-features/migrations"],
|
|
240
|
+
},
|
|
241
|
+
tenancy: {
|
|
242
|
+
resolveTenant: async (tenantId) => ({
|
|
243
|
+
strategy: "database",
|
|
244
|
+
name: `tenant:${tenantId}`,
|
|
245
|
+
config: await getTenantConnectionConfig(tenantId),
|
|
246
|
+
}),
|
|
247
|
+
listTenants: async () => await getAllTenantIds(),
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
With grouped migrations, `bun run bunny migrate` runs landlord migrations first, then tenant migrations for every tenant returned by `listTenants()`. Rollback runs in reverse order: tenants first, then landlord.
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
bun run bunny migrate
|
|
256
|
+
bun run bunny migrate --landlord
|
|
257
|
+
bun run bunny migrate --tenants
|
|
258
|
+
bun run bunny migrate --tenant acme
|
|
259
|
+
```
|
|
260
|
+
|
|
114
261
|
### Create Tables
|
|
115
262
|
|
|
116
263
|
```ts
|
|
@@ -142,9 +289,7 @@ const user = await User.create({ name: "Alice", email: "alice@example.com" });
|
|
|
142
289
|
const found = await User.find(1);
|
|
143
290
|
|
|
144
291
|
// Query
|
|
145
|
-
const adults = await User.where("age", ">=", 18)
|
|
146
|
-
.orderBy("name")
|
|
147
|
-
.get();
|
|
292
|
+
const adults = await User.where("age", ">=", 18).orderBy("name").get();
|
|
148
293
|
|
|
149
294
|
// Update
|
|
150
295
|
user.name = "Alice Smith";
|
|
@@ -162,7 +307,7 @@ Start an interactive Bunny session with the ORM already loaded:
|
|
|
162
307
|
bunny repl
|
|
163
308
|
```
|
|
164
309
|
|
|
165
|
-
The REPL exposes `Model`, `Schema`, `Connection`, `db`, and a `Models` map. Any model files under `modelsPath` are loaded automatically and also registered by class name on the global scope. If no project config is present, it starts against an in-memory SQLite database so you can still experiment immediately.
|
|
310
|
+
The REPL exposes `Model`, `Schema`, `Connection`, `db`, and a `Models` map. Any model files under `modelsPath` are loaded automatically and also registered by class name on the global scope. If no project config is present, it starts against an in-memory SQLite database so you can still experiment immediately. This makes it useful for quick inspection, ad hoc queries, and schema experiments without adding any dependencies to your app.
|
|
166
311
|
|
|
167
312
|
---
|
|
168
313
|
|
|
@@ -187,37 +332,37 @@ await Schema.create("products", (table) => {
|
|
|
187
332
|
|
|
188
333
|
### Available Column Types
|
|
189
334
|
|
|
190
|
-
| Method
|
|
191
|
-
|
|
192
|
-
| `increments(name)`
|
|
193
|
-
| `bigIncrements(name)`
|
|
194
|
-
| `string(name, length=255)` | VARCHAR
|
|
195
|
-
| `text(name)`
|
|
196
|
-
| `integer(name)`
|
|
197
|
-
| `bigInteger(name)`
|
|
198
|
-
| `smallInteger(name)`
|
|
199
|
-
| `tinyInteger(name)`
|
|
200
|
-
| `float(name, p=8, s=2)`
|
|
201
|
-
| `double(name, p=8, s=2)`
|
|
202
|
-
| `decimal(name, p=8, s=2)`
|
|
203
|
-
| `boolean(name)`
|
|
204
|
-
| `date(name)`
|
|
205
|
-
| `dateTime(name)`
|
|
206
|
-
| `time(name)`
|
|
207
|
-
| `timestamp(name)`
|
|
208
|
-
| `json(name)`
|
|
209
|
-
| `jsonb(name)`
|
|
210
|
-
| `binary(name)`
|
|
211
|
-
| `uuid(name)`
|
|
212
|
-
| `enum(name, values)`
|
|
335
|
+
| Method | Description |
|
|
336
|
+
| -------------------------- | ------------------------------------- |
|
|
337
|
+
| `increments(name)` | Auto-incrementing integer primary key |
|
|
338
|
+
| `bigIncrements(name)` | Auto-incrementing big integer |
|
|
339
|
+
| `string(name, length=255)` | VARCHAR |
|
|
340
|
+
| `text(name)` | TEXT |
|
|
341
|
+
| `integer(name)` | INTEGER |
|
|
342
|
+
| `bigInteger(name)` | BIGINT |
|
|
343
|
+
| `smallInteger(name)` | SMALLINT |
|
|
344
|
+
| `tinyInteger(name)` | TINYINT |
|
|
345
|
+
| `float(name, p=8, s=2)` | FLOAT |
|
|
346
|
+
| `double(name, p=8, s=2)` | DOUBLE |
|
|
347
|
+
| `decimal(name, p=8, s=2)` | DECIMAL |
|
|
348
|
+
| `boolean(name)` | BOOLEAN |
|
|
349
|
+
| `date(name)` | DATE |
|
|
350
|
+
| `dateTime(name)` | DATETIME |
|
|
351
|
+
| `time(name)` | TIME |
|
|
352
|
+
| `timestamp(name)` | TIMESTAMP |
|
|
353
|
+
| `json(name)` | JSON |
|
|
354
|
+
| `jsonb(name)` | JSONB (Postgres) |
|
|
355
|
+
| `binary(name)` | BLOB / BYTEA |
|
|
356
|
+
| `uuid(name)` | UUID |
|
|
357
|
+
| `enum(name, values)` | ENUM |
|
|
213
358
|
|
|
214
359
|
### Column Modifiers
|
|
215
360
|
|
|
216
361
|
```ts
|
|
217
|
-
table.string("email").unique();
|
|
218
|
-
table.string("slug").index();
|
|
219
|
-
table.string("name").nullable();
|
|
220
|
-
table.integer("role").default(1);
|
|
362
|
+
table.string("email").unique(); // UNIQUE index
|
|
363
|
+
table.string("slug").index(); // INDEX
|
|
364
|
+
table.string("name").nullable(); // NULLABLE
|
|
365
|
+
table.integer("role").default(1); // DEFAULT value
|
|
221
366
|
table.string("code").comment("SKU code");
|
|
222
367
|
table.integer("user_id").unsigned();
|
|
223
368
|
```
|
|
@@ -271,6 +416,14 @@ Event.whereMonth("birthday", 12);
|
|
|
271
416
|
Event.whereDay("anniversary", 14);
|
|
272
417
|
Event.whereTime("opened_at", "09:00:00");
|
|
273
418
|
|
|
419
|
+
// or* variants
|
|
420
|
+
User.where("role", "admin").orWhereNull("email");
|
|
421
|
+
User.where("status", "active").orWhereIn("role", ["admin", "mod"]);
|
|
422
|
+
User.where("price", ">=", 100).orWhereBetween("price", [10, 50]);
|
|
423
|
+
User.where("name", "Alice").orWhereExists("SELECT 1 FROM orders WHERE orders.user_id = users.id");
|
|
424
|
+
User.where("a", 1).orWhereColumn("updated_at", ">", "created_at");
|
|
425
|
+
User.where("active", true).orWhereRaw("score > 100");
|
|
426
|
+
|
|
274
427
|
// Chaining
|
|
275
428
|
const results = await User
|
|
276
429
|
.where("age", ">=", 18)
|
|
@@ -289,24 +442,42 @@ User.when(filters.name, (q) => q.where("name", filters.name))
|
|
|
289
442
|
// Ordering convenience
|
|
290
443
|
Post.latest().first(); // orderBy created_at desc
|
|
291
444
|
Post.oldest("published_at"); // orderBy published_at asc
|
|
445
|
+
Post.orderByDesc("score"); // shorthand
|
|
446
|
+
Post.orderBy("name").reorder(); // clear orders
|
|
447
|
+
Post.orderBy("name").reorder("id"); // replace with new order
|
|
292
448
|
|
|
293
449
|
// Aggregates
|
|
294
450
|
const count = await User.where("active", true).count();
|
|
295
451
|
const exists = await User.where("email", "test@example.com").exists();
|
|
452
|
+
const doesntExist = await User.where("email", "missing@example.com").doesntExist();
|
|
296
453
|
|
|
297
454
|
// Joins
|
|
298
455
|
const posts = await Post
|
|
299
456
|
.query()
|
|
300
457
|
.select("posts.*", "users.name as author_name")
|
|
301
458
|
.join("users", "posts.user_id", "=", "users.id")
|
|
459
|
+
.leftJoin("comments", "comments.post_id", "=", "posts.id")
|
|
460
|
+
.crossJoin("tags")
|
|
302
461
|
.get();
|
|
303
462
|
|
|
463
|
+
// Group by / Having
|
|
464
|
+
User.select("role").groupBy("role").having("count", ">", 1);
|
|
465
|
+
User.groupBy("role").havingRaw("COUNT(*) > 1").orHavingRaw("SUM(score) > 100");
|
|
466
|
+
|
|
467
|
+
// Union
|
|
468
|
+
const q1 = User.where("active", true);
|
|
469
|
+
const q2 = User.where("role", "admin");
|
|
470
|
+
const results = await q1.union(q2).get();
|
|
471
|
+
const allResults = await q1.unionAll(q2).get();
|
|
472
|
+
|
|
304
473
|
// Pluck
|
|
305
474
|
const emails = await User.pluck("email");
|
|
306
475
|
|
|
307
|
-
// First / Find
|
|
476
|
+
// First / Find / Sole
|
|
308
477
|
const user = await User.where("email", "alice@example.com").first();
|
|
309
478
|
const byId = await User.find(1);
|
|
479
|
+
const name = await User.where("id", 1).value("name"); // single scalar
|
|
480
|
+
const sole = await User.where("email", "alice@example.com").sole(); // exactly one or throw
|
|
310
481
|
|
|
311
482
|
// Find-or-Fail (throws if not found)
|
|
312
483
|
const user = await User.findOrFail(1);
|
|
@@ -317,8 +488,133 @@ await User.chunk(100, (users) => { ... });
|
|
|
317
488
|
await User.each(100, (user) => { ... });
|
|
318
489
|
for await (const user of User.cursor()) { ... }
|
|
319
490
|
for await (const user of User.lazy(500)) { ... }
|
|
491
|
+
|
|
492
|
+
// Raw / Subquery helpers
|
|
493
|
+
User.select("name").selectRaw("price * 2 as doubled");
|
|
494
|
+
User.fromSub(User.where("price", ">", 100), "expensive");
|
|
495
|
+
|
|
496
|
+
// Debug
|
|
497
|
+
User.where("name", "Alice").dump(); // logs SQL, returns builder
|
|
498
|
+
User.where("name", "Alice").dd(); // logs SQL and throws
|
|
320
499
|
```
|
|
321
500
|
|
|
501
|
+
### Query Builder Reference
|
|
502
|
+
|
|
503
|
+
| Method | Description |
|
|
504
|
+
| ---------------------------------------------------------- | ----------------------------------- |
|
|
505
|
+
| `where(col, op, val)` | Basic equality or operator filter |
|
|
506
|
+
| `where(obj)` | Object of column â value pairs |
|
|
507
|
+
| `where(fn)` | Nested where group via closure |
|
|
508
|
+
| `orWhere(...)` | OR variant of `where` |
|
|
509
|
+
| `whereNot(col, val)` | `!=` filter |
|
|
510
|
+
| `orWhereNot(...)` | OR `!=` |
|
|
511
|
+
| `whereIn(col, vals)` | `IN` set |
|
|
512
|
+
| `orWhereIn(...)` | OR `IN` |
|
|
513
|
+
| `whereNotIn(col, vals)` | `NOT IN` |
|
|
514
|
+
| `orWhereNotIn(...)` | OR `NOT IN` |
|
|
515
|
+
| `whereNull(col)` | `IS NULL` |
|
|
516
|
+
| `orWhereNull(...)` | OR `IS NULL` |
|
|
517
|
+
| `whereNotNull(col)` | `IS NOT NULL` |
|
|
518
|
+
| `orWhereNotNull(...)` | OR `IS NOT NULL` |
|
|
519
|
+
| `whereBetween(col, [a, b])` | `BETWEEN` |
|
|
520
|
+
| `orWhereBetween(...)` | OR `BETWEEN` |
|
|
521
|
+
| `whereNotBetween(col, [a, b])` | `NOT BETWEEN` |
|
|
522
|
+
| `orWhereNotBetween(...)` | OR `NOT BETWEEN` |
|
|
523
|
+
| `whereExists(sql)` | `EXISTS (subquery)` |
|
|
524
|
+
| `orWhereExists(...)` | OR `EXISTS` |
|
|
525
|
+
| `whereNotExists(sql)` | `NOT EXISTS` |
|
|
526
|
+
| `orWhereNotExists(...)` | OR `NOT EXISTS` |
|
|
527
|
+
| `whereColumn(a, op, b)` | Compare two columns |
|
|
528
|
+
| `orWhereColumn(...)` | OR column compare |
|
|
529
|
+
| `whereRaw(sql)` | Raw SQL where clause |
|
|
530
|
+
| `orWhereRaw(...)` | OR raw SQL |
|
|
531
|
+
| `whereDate(col, op, val)` | Cross-database date filter |
|
|
532
|
+
| `whereDay / whereMonth / whereYear / whereTime` | Date part filters |
|
|
533
|
+
| `whereJsonContains(col, val)` | JSON membership (cross-db) |
|
|
534
|
+
| `whereJsonLength(col, op, val)` | JSON array length |
|
|
535
|
+
| `whereLike(col, pattern)` | `LIKE` pattern |
|
|
536
|
+
| `whereNotLike(...)` | `NOT LIKE` |
|
|
537
|
+
| `whereRegexp(col, pattern)` | Regular expression match |
|
|
538
|
+
| `whereFullText(cols, query)` | Full-text search (cross-db) |
|
|
539
|
+
| `whereAll(cols, op, val)` | Multi-column `AND` |
|
|
540
|
+
| `whereAny(cols, op, val)` | Multi-column `OR` |
|
|
541
|
+
| `orderBy(col, dir)` | Sort ascending or descending |
|
|
542
|
+
| `orderByDesc(col)` | Sort descending shorthand |
|
|
543
|
+
| `latest(col?)` | `orderBy(created_at, desc)` |
|
|
544
|
+
| `oldest(col?)` | `orderBy(created_at, asc)` |
|
|
545
|
+
| `inRandomOrder()` | `ORDER BY RANDOM()` / `RAND()` |
|
|
546
|
+
| `reorder(col?, dir?)` | Clear and optionally replace orders |
|
|
547
|
+
| `groupBy(...cols)` | `GROUP BY` |
|
|
548
|
+
| `having(col, op, val)` | `HAVING` filter |
|
|
549
|
+
| `orHaving(...)` | OR `HAVING` |
|
|
550
|
+
| `havingRaw(sql)` | Raw `HAVING` |
|
|
551
|
+
| `orHavingRaw(...)` | OR raw `HAVING` |
|
|
552
|
+
| `join(tbl, a, op, b)` | `INNER JOIN` |
|
|
553
|
+
| `leftJoin(...)` | `LEFT JOIN` |
|
|
554
|
+
| `rightJoin(...)` | `RIGHT JOIN` |
|
|
555
|
+
| `crossJoin(tbl)` | `CROSS JOIN` |
|
|
556
|
+
| `union(query, all?)` | `UNION` another query |
|
|
557
|
+
| `unionAll(query)` | `UNION ALL` |
|
|
558
|
+
| `select(...cols)` | Choose columns |
|
|
559
|
+
| `addSelect(...cols)` | Append columns |
|
|
560
|
+
| `selectRaw(sql)` | Raw SELECT expression |
|
|
561
|
+
| `fromSub(query, alias)` | Derived table from subquery |
|
|
562
|
+
| `distinct()` | `SELECT DISTINCT` |
|
|
563
|
+
| `limit(n)` | Row limit |
|
|
564
|
+
| `offset(n)` | Row offset |
|
|
565
|
+
| `take(n)` | Alias for `limit` |
|
|
566
|
+
| `skip(n)` | Alias for `offset` |
|
|
567
|
+
| `forPage(page, perPage)` | Pagination offset/limit |
|
|
568
|
+
| `lockForUpdate()` | `FOR UPDATE` (MySQL/Postgres) |
|
|
569
|
+
| `sharedLock()` | `LOCK IN SHARE MODE` / `FOR SHARE` |
|
|
570
|
+
| `skipLocked()` | Append `SKIP LOCKED` |
|
|
571
|
+
| `noWait()` | Append `NOWAIT` |
|
|
572
|
+
| `get()` | Fetch all rows |
|
|
573
|
+
| `first()` | Fetch first row |
|
|
574
|
+
| `find(id, col?)` | Find by ID |
|
|
575
|
+
| `findOrFail(id, col?)` | Find or throw |
|
|
576
|
+
| `firstOrFail()` | First or throw |
|
|
577
|
+
| `sole()` | Exactly one row or throw |
|
|
578
|
+
| `value(col)` | Single scalar from first row |
|
|
579
|
+
| `pluck(col)` | Array of column values |
|
|
580
|
+
| `count(col?)` | `COUNT` aggregate |
|
|
581
|
+
| `sum(col)` | `SUM` |
|
|
582
|
+
| `avg(col)` | `AVG` |
|
|
583
|
+
| `min(col)` | `MIN` |
|
|
584
|
+
| `max(col)` | `MAX` |
|
|
585
|
+
| `exists()` | Check any rows exist |
|
|
586
|
+
| `doesntExist()` | Check no rows exist |
|
|
587
|
+
| `paginate(perPage?, page?)` | Paginated result set |
|
|
588
|
+
| `chunk(n, fn)` | Batch iterate |
|
|
589
|
+
| `each(n, fn)` | Per-item iterate |
|
|
590
|
+
| `cursor()` | Lazy async generator |
|
|
591
|
+
| `lazy(n?)` | Chunked lazy generator |
|
|
592
|
+
| `insert(data)` | Insert row(s) |
|
|
593
|
+
| `insertGetId(data, col?)` | Insert and return ID |
|
|
594
|
+
| `insertOrIgnore(data)` | Insert, ignore conflicts |
|
|
595
|
+
| `upsert(data, uniqueBy, updateCols?)` | Insert or update on conflict |
|
|
596
|
+
| `update(data)` | Update matched rows |
|
|
597
|
+
| `updateFrom(tbl, a, op, b)` | Update with JOIN |
|
|
598
|
+
| `delete()` | Delete matched rows |
|
|
599
|
+
| `increment(col, amt?, extra?)` | Add to column |
|
|
600
|
+
| `decrement(col, amt?, extra?)` | Subtract from column |
|
|
601
|
+
| `restore()` | Restore soft-deleted rows |
|
|
602
|
+
| `with(...rels)` | Eager load relations |
|
|
603
|
+
| `has(rel)` / `orHas(rel)` | Relation existence |
|
|
604
|
+
| `whereHas(rel, fn?)` / `orWhereHas(...)` | Filtered relation existence |
|
|
605
|
+
| `doesntHave(rel)` / `whereDoesntHave(...)` | Relation absence |
|
|
606
|
+
| `withCount(rel)` / `withSum / withAvg / withMin / withMax` | Relation aggregates |
|
|
607
|
+
| `scope(name, ...args)` | Apply local scope |
|
|
608
|
+
| `withoutGlobalScope(name)` / `withoutGlobalScopes()` | Remove scopes |
|
|
609
|
+
| `withTrashed()` / `onlyTrashed()` | Soft delete visibility |
|
|
610
|
+
| `when(cond, fn, elseFn?)` / `unless(...)` | Conditional building |
|
|
611
|
+
| `tap(fn)` | Mutate and return |
|
|
612
|
+
| `clone()` | Copy builder state |
|
|
613
|
+
| `toSql()` | Compile to SQL string |
|
|
614
|
+
| `dump()` | Log SQL, return builder |
|
|
615
|
+
| `dd()` | Log SQL and halt |
|
|
616
|
+
| `explain()` | Return query plan |
|
|
617
|
+
|
|
322
618
|
---
|
|
323
619
|
|
|
324
620
|
## Models
|
|
@@ -335,10 +631,10 @@ for await (const user of User.lazy(500)) { ... }
|
|
|
335
631
|
|
|
336
632
|
```ts
|
|
337
633
|
class Product extends Model {
|
|
338
|
-
static table = "products";
|
|
339
|
-
static primaryKey = "sku";
|
|
340
|
-
static timestamps = false;
|
|
341
|
-
static softDeletes = true;
|
|
634
|
+
static table = "products"; // override table name
|
|
635
|
+
static primaryKey = "sku"; // override primary key
|
|
636
|
+
static timestamps = false; // disable timestamps
|
|
637
|
+
static softDeletes = true; // use deleted_at instead of hard deletes
|
|
342
638
|
|
|
343
639
|
static attributes = {
|
|
344
640
|
active: true,
|
|
@@ -365,18 +661,18 @@ const builder = User.where("active", true);
|
|
|
365
661
|
|
|
366
662
|
// Instance
|
|
367
663
|
user.fill({ name: "Bob", email: "bob@example.com" });
|
|
368
|
-
user.name;
|
|
369
|
-
user.name = "Charlie";
|
|
664
|
+
user.name; // property access
|
|
665
|
+
user.name = "Charlie"; // property assignment
|
|
370
666
|
user.getAttribute("name"); // explicit access still works
|
|
371
667
|
user.setAttribute("name", "Dana");
|
|
372
|
-
user.isDirty();
|
|
373
|
-
user.getDirty();
|
|
668
|
+
user.isDirty(); // true if attributes changed
|
|
669
|
+
user.getDirty(); // { name: "Charlie" }
|
|
374
670
|
await user.save();
|
|
375
671
|
await user.delete();
|
|
376
672
|
await user.refresh();
|
|
377
|
-
await user.touch();
|
|
673
|
+
await user.touch(); // update only timestamps
|
|
378
674
|
await user.load("posts"); // lazy eager loading
|
|
379
|
-
user.toJSON();
|
|
675
|
+
user.toJSON(); // plain object
|
|
380
676
|
|
|
381
677
|
// Increment / Decrement
|
|
382
678
|
await user.increment("login_count");
|
|
@@ -384,8 +680,14 @@ await user.increment("login_count", 5, { last_login_at: new Date() });
|
|
|
384
680
|
await user.decrement("stock", 10);
|
|
385
681
|
|
|
386
682
|
// First-or-Create / Update-or-Create
|
|
387
|
-
const user = await User.firstOrCreate(
|
|
388
|
-
|
|
683
|
+
const user = await User.firstOrCreate(
|
|
684
|
+
{ email: "alice@example.com" },
|
|
685
|
+
{ name: "Alice" },
|
|
686
|
+
);
|
|
687
|
+
const user = await User.updateOrCreate(
|
|
688
|
+
{ email: "alice@example.com" },
|
|
689
|
+
{ name: "Alice Smith" },
|
|
690
|
+
);
|
|
389
691
|
```
|
|
390
692
|
|
|
391
693
|
### Default Attributes
|
|
@@ -402,7 +704,7 @@ class User extends Model {
|
|
|
402
704
|
|
|
403
705
|
const user = new User({ name: "Ada" });
|
|
404
706
|
user.active; // true
|
|
405
|
-
user.role;
|
|
707
|
+
user.role; // "member"
|
|
406
708
|
```
|
|
407
709
|
|
|
408
710
|
These are model defaults, not database defaults. Values provided by the caller override them.
|
|
@@ -427,23 +729,23 @@ const user = new User({
|
|
|
427
729
|
settings: { theme: "dark" },
|
|
428
730
|
});
|
|
429
731
|
|
|
430
|
-
user.$attributes.active;
|
|
431
|
-
user.active;
|
|
432
|
-
user.settings.theme;
|
|
732
|
+
user.$attributes.active; // 1
|
|
733
|
+
user.active; // true
|
|
734
|
+
user.settings.theme; // "dark"
|
|
433
735
|
```
|
|
434
736
|
|
|
435
737
|
Supported built-in casts:
|
|
436
738
|
|
|
437
|
-
| Cast
|
|
438
|
-
|
|
439
|
-
| `boolean`, `bool`
|
|
440
|
-
| `number`, `integer`, `int`, `float`, `double` | Reads/writes numbers
|
|
441
|
-
| `decimal:2`
|
|
442
|
-
| `string`
|
|
443
|
-
| `date`, `datetime`
|
|
444
|
-
| `json`, `array`, `object`
|
|
445
|
-
| `enum`
|
|
446
|
-
| `encrypted`
|
|
739
|
+
| Cast | Behavior |
|
|
740
|
+
| --------------------------------------------- | --------------------------------------------------- |
|
|
741
|
+
| `boolean`, `bool` | Stores `1` / `0`, reads boolean |
|
|
742
|
+
| `number`, `integer`, `int`, `float`, `double` | Reads/writes numbers |
|
|
743
|
+
| `decimal:2` | Stores fixed precision string |
|
|
744
|
+
| `string` | Reads/writes string |
|
|
745
|
+
| `date`, `datetime` | Reads as `Date`, stores ISO string for `Date` input |
|
|
746
|
+
| `json`, `array`, `object` | Stores JSON string, reads parsed value |
|
|
747
|
+
| `enum` | Stores enum `.value` when present |
|
|
748
|
+
| `encrypted` | Base64 encodes on write and decodes on read |
|
|
447
749
|
|
|
448
750
|
Custom casts can implement `CastsAttributes`:
|
|
449
751
|
|
|
@@ -482,13 +784,13 @@ class User extends Model {
|
|
|
482
784
|
static softDeletes = true;
|
|
483
785
|
}
|
|
484
786
|
|
|
485
|
-
await user.delete();
|
|
486
|
-
await user.restore();
|
|
487
|
-
await user.forceDelete();
|
|
787
|
+
await user.delete(); // sets deleted_at
|
|
788
|
+
await user.restore(); // clears deleted_at
|
|
789
|
+
await user.forceDelete(); // permanently deletes
|
|
488
790
|
|
|
489
|
-
await User.all();
|
|
490
|
-
await User.withTrashed().get();
|
|
491
|
-
await User.onlyTrashed().get();
|
|
791
|
+
await User.all(); // excludes trashed rows
|
|
792
|
+
await User.withTrashed().get(); // includes trashed rows
|
|
793
|
+
await User.onlyTrashed().get(); // only trashed rows
|
|
492
794
|
await User.onlyTrashed().restore();
|
|
493
795
|
```
|
|
494
796
|
|
|
@@ -526,17 +828,17 @@ await User.withoutGlobalScopes().get();
|
|
|
526
828
|
```ts
|
|
527
829
|
class User extends Model {
|
|
528
830
|
posts() {
|
|
529
|
-
return this.hasMany(Post);
|
|
831
|
+
return this.hasMany(Post); // foreignKey: user_id, localKey: id
|
|
530
832
|
}
|
|
531
833
|
|
|
532
834
|
profile() {
|
|
533
|
-
return this.hasOne(Profile);
|
|
835
|
+
return this.hasOne(Profile); // foreignKey: user_id, localKey: id
|
|
534
836
|
}
|
|
535
837
|
}
|
|
536
838
|
|
|
537
839
|
class Post extends Model {
|
|
538
840
|
author() {
|
|
539
|
-
return this.belongsTo(User);
|
|
841
|
+
return this.belongsTo(User); // foreignKey: user_id, ownerKey: id
|
|
540
842
|
}
|
|
541
843
|
}
|
|
542
844
|
```
|
|
@@ -632,8 +934,7 @@ const usersWithoutPosts = await User.doesntHave("posts").get();
|
|
|
632
934
|
Add relation aggregate columns:
|
|
633
935
|
|
|
634
936
|
```ts
|
|
635
|
-
const users = await User
|
|
636
|
-
.withCount("posts")
|
|
937
|
+
const users = await User.withCount("posts")
|
|
637
938
|
.withSum("posts", "views")
|
|
638
939
|
.withAvg("posts", "score")
|
|
639
940
|
.withMin("posts", "created_at")
|
|
@@ -655,7 +956,7 @@ MorphMap.register("Video", Video);
|
|
|
655
956
|
|
|
656
957
|
class Comment extends Model {
|
|
657
958
|
commentable() {
|
|
658
|
-
return this.morphTo("commentable");
|
|
959
|
+
return this.morphTo("commentable"); // reads commentable_type / commentable_id
|
|
659
960
|
}
|
|
660
961
|
}
|
|
661
962
|
|
|
@@ -698,7 +999,7 @@ Pivot table: `taggables(tag_id, taggable_id, taggable_type)`.
|
|
|
698
999
|
|
|
699
1000
|
```ts
|
|
700
1001
|
class Post extends Model {
|
|
701
|
-
static morphName = "post";
|
|
1002
|
+
static morphName = "post"; // stored in {name}_type column
|
|
702
1003
|
}
|
|
703
1004
|
```
|
|
704
1005
|
|
|
@@ -859,8 +1160,8 @@ Bunny can introspect your database schema and generate TypeScript declaration fi
|
|
|
859
1160
|
|
|
860
1161
|
```ts
|
|
861
1162
|
const user = await User.first();
|
|
862
|
-
user.name;
|
|
863
|
-
user.email = "a@example.com";
|
|
1163
|
+
user.name; // â
autocomplete + type-checking
|
|
1164
|
+
user.email = "a@example.com"; // â
typed setter
|
|
864
1165
|
```
|
|
865
1166
|
|
|
866
1167
|
### Generate Types
|
|
@@ -896,9 +1197,9 @@ export default {
|
|
|
896
1197
|
|
|
897
1198
|
With `modelsPath`, Bunny conventionally maps tables to singular PascalCase model modules and writes the generated declarations into `types/` beside each model root:
|
|
898
1199
|
|
|
899
|
-
| Table
|
|
900
|
-
|
|
901
|
-
| `users`
|
|
1200
|
+
| Table | Generated augmentation |
|
|
1201
|
+
| ------------ | -------------------------- |
|
|
1202
|
+
| `users` | `../User` / `User` |
|
|
902
1203
|
| `blog_posts` | `../BlogPost` / `BlogPost` |
|
|
903
1204
|
| `categories` | `../Category` / `Category` |
|
|
904
1205
|
|
|
@@ -959,7 +1260,7 @@ class User extends Model<UserAttributes> {
|
|
|
959
1260
|
// $attributes and getAttribute are now typed
|
|
960
1261
|
const user = await User.first();
|
|
961
1262
|
user.getAttribute("name"); // string
|
|
962
|
-
user.$attributes.email;
|
|
1263
|
+
user.$attributes.email; // string
|
|
963
1264
|
```
|
|
964
1265
|
|
|
965
1266
|
---
|
|
@@ -972,7 +1273,7 @@ Bunny includes a full test suite built with `bun:test`.
|
|
|
972
1273
|
bun test
|
|
973
1274
|
```
|
|
974
1275
|
|
|
975
|
-
|
|
1276
|
+
195 tests covering connection management, schema grammars, query builder, model CRUD, casts, scopes, soft deletes, relations, observers, migrations, type generation, lazy eager loading, find-or-fail, first-or-create, increment/decrement, touch, chunk/cursor/lazy streaming, date where clauses, conditional query building, whereNot, latest/oldest, or\* where variants, having/orHaving, orderByDesc/reorder, crossJoin, union, insertOrIgnore, upsert, delete with limit, skipLocked/noWait, JSON where clauses, like/regexp/fulltext, whereAll/whereAny, sole/value, selectRaw/fromSub, updateFrom, dump/dd, and explain.
|
|
976
1277
|
|
|
977
1278
|
---
|
|
978
1279
|
|