@bunnykit/orm 0.1.4 → 0.1.6
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 +420 -81
- 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 +7 -0
- package/dist/src/index.js +4 -0
- package/dist/src/model/BelongsToMany.js +9 -6
- package/dist/src/model/Model.d.ts +41 -0
- package/dist/src/model/Model.js +217 -18
- package/dist/src/model/ModelNotFoundError.d.ts +5 -0
- package/dist/src/model/ModelNotFoundError.js +13 -0
- package/dist/src/model/MorphRelations.js +10 -10
- package/dist/src/query/Builder.d.ts +85 -7
- package/dist/src/query/Builder.js +489 -68
- 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
|
@@ -18,11 +18,12 @@ An **Eloquent-inspired ORM** built specifically for [Bun](https://bun.sh)'s nati
|
|
|
18
18
|
- 📦 **Multi-database** — SQLite, MySQL, and PostgreSQL support
|
|
19
19
|
- 🔷 **Fully Typed** — Written in TypeScript with generics everywhere
|
|
20
20
|
- 🏗️ **Schema Builder** — Programmatic table creation, indexes, foreign keys
|
|
21
|
-
- 🔍 **Query Builder** — Chainable `where`, `join`, `orderBy`, `groupBy`, etc.
|
|
22
|
-
- 🧬 **Eloquent-style Models** — Property attributes, defaults, casts, dirty tracking, soft deletes, scopes
|
|
21
|
+
- 🔍 **Query Builder** — Chainable `where`, `join`, `orderBy`, `groupBy`, date filters, conditional building, etc.
|
|
22
|
+
- 🧬 **Eloquent-style Models** — Property attributes, defaults, casts, dirty tracking, soft deletes, scopes, find-or-fail, first-or-create
|
|
23
23
|
- 🔗 **Relations** — Standard, many-to-many, polymorphic, through, one-of-many, and relation queries
|
|
24
24
|
- 👁️ **Observers** — Lifecycle hooks (`creating`, `created`, `updating`, `updated`, etc.)
|
|
25
25
|
- 🚀 **Migrations & CLI** — Create, run, and rollback migrations from the command line
|
|
26
|
+
- ⚡ **Streaming** — `chunk`, `cursor`, `each`, and `lazy` for memory-efficient large dataset processing
|
|
26
27
|
|
|
27
28
|
---
|
|
28
29
|
|
|
@@ -53,14 +54,29 @@ export default {
|
|
|
53
54
|
// password: "secret",
|
|
54
55
|
},
|
|
55
56
|
migrationsPath: ["./database/migrations", "./database/tenant-migrations"],
|
|
57
|
+
// Optional grouped migrations for multi-tenant apps
|
|
58
|
+
// migrations: {
|
|
59
|
+
// landlord: "./database/landlord-migrations",
|
|
60
|
+
// tenant: "./database/tenant-migrations",
|
|
61
|
+
// },
|
|
62
|
+
// Optional tenant resolver for dynamic multi-tenant apps.
|
|
63
|
+
// Apps call configureBunny(config) at startup to register this resolver.
|
|
64
|
+
// tenancy: {
|
|
65
|
+
// resolveTenant: async (tenantId) => ({
|
|
66
|
+
// strategy: "database",
|
|
67
|
+
// name: `tenant:${tenantId}`,
|
|
68
|
+
// config: await getTenantConnectionConfig(tenantId),
|
|
69
|
+
// }),
|
|
70
|
+
// listTenants: async () => await getAllTenantIds(),
|
|
71
|
+
// },
|
|
56
72
|
modelsPath: ["./src/models", "./src/admin/models"],
|
|
57
73
|
// Optional legacy type output directory
|
|
58
|
-
typesOutDir: "./src/generated/model-types",
|
|
74
|
+
// typesOutDir: "./src/generated/model-types",
|
|
59
75
|
// Optional typegen overrides
|
|
60
|
-
typeDeclarationImportPrefix: "../models",
|
|
61
|
-
typeDeclarations: {
|
|
62
|
-
|
|
63
|
-
},
|
|
76
|
+
// typeDeclarationImportPrefix: "../models",
|
|
77
|
+
// typeDeclarations: {
|
|
78
|
+
// admin_users: { path: "../AdminAccount", className: "AdminAccount" },
|
|
79
|
+
// },
|
|
64
80
|
};
|
|
65
81
|
```
|
|
66
82
|
|
|
@@ -110,6 +126,136 @@ Model.setConnection(connection);
|
|
|
110
126
|
Schema.setConnection(connection);
|
|
111
127
|
```
|
|
112
128
|
|
|
129
|
+
Or apply the same `bunny.config.ts` used by the CLI in your application bootstrap:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
import config from "../bunny.config";
|
|
133
|
+
import { configureBunny } from "@bunnykit/orm";
|
|
134
|
+
|
|
135
|
+
const { connection } = configureBunny(config);
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Dynamic Tenant Connections
|
|
139
|
+
|
|
140
|
+
Use `ConnectionManager` and `TenantContext` when tenants are discovered at runtime instead of listed in config:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
import { ConnectionManager, TenantContext } from "@bunnykit/orm";
|
|
144
|
+
|
|
145
|
+
ConnectionManager.setTenantResolver(async (tenantId) => {
|
|
146
|
+
const tenant = await lookupTenant(tenantId); // your app owns this lookup
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
strategy: "database",
|
|
150
|
+
name: `tenant:${tenant.id}`,
|
|
151
|
+
config: { url: tenant.databaseUrl },
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await TenantContext.run("acme", async () => {
|
|
156
|
+
const users = await User.all();
|
|
157
|
+
await Invoice.create({ total: 100 });
|
|
158
|
+
});
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
You can also define the resolver in `bunny.config.ts` and reuse the same config in your app and CLI:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
// bunny.config.ts
|
|
165
|
+
export default {
|
|
166
|
+
connection: { url: process.env.LANDLORD_DATABASE_URL! },
|
|
167
|
+
tenancy: {
|
|
168
|
+
resolveTenant: async (tenantId) => ({
|
|
169
|
+
strategy: "database",
|
|
170
|
+
name: `tenant:${tenantId}`,
|
|
171
|
+
config: await getTenantConnectionConfig(tenantId),
|
|
172
|
+
}),
|
|
173
|
+
listTenants: async () => await getAllTenantIds(),
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Then register it in app startup:
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
import config from "../bunny.config";
|
|
182
|
+
import { configureBunny } from "@bunnykit/orm";
|
|
183
|
+
|
|
184
|
+
configureBunny(config);
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`configureBunny(config)` registers `tenancy.resolveTenant` for application code automatically. `listTenants()` is only used by the CLI when running grouped tenant migrations.
|
|
188
|
+
|
|
189
|
+
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.
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
ConnectionManager.setTenantResolver(async (tenantId) => ({
|
|
193
|
+
strategy: "schema",
|
|
194
|
+
name: `tenant:${tenantId}`,
|
|
195
|
+
config: { url: process.env.DATABASE_URL!, schema: `tenant_${tenantId}` },
|
|
196
|
+
schema: `tenant_${tenantId}`,
|
|
197
|
+
}));
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
For PostgreSQL systems that prefer `SET search_path` over table qualification, reuse the existing default connection and opt into transaction-scoped search path switching:
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
ConnectionManager.setTenantResolver(async (tenantId) => ({
|
|
204
|
+
strategy: "schema",
|
|
205
|
+
name: `tenant:${tenantId}`,
|
|
206
|
+
schema: `tenant_${tenantId}`,
|
|
207
|
+
mode: "search_path",
|
|
208
|
+
}));
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
`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.
|
|
212
|
+
|
|
213
|
+
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:
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
ConnectionManager.setTenantResolver(async (tenantId) => ({
|
|
217
|
+
strategy: "rls",
|
|
218
|
+
name: "main",
|
|
219
|
+
tenantId,
|
|
220
|
+
setting: "app.current_tenant_id",
|
|
221
|
+
}));
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Your PostgreSQL policies should read the same setting, for example `current_setting('app.current_tenant_id')`.
|
|
225
|
+
|
|
226
|
+
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.
|
|
227
|
+
|
|
228
|
+
### Landlord and Tenant Migrations
|
|
229
|
+
|
|
230
|
+
For multi-tenant apps, use grouped migrations so landlord tables and tenant tables can be migrated separately:
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
export default {
|
|
234
|
+
connection: { url: process.env.LANDLORD_DATABASE_URL! },
|
|
235
|
+
migrations: {
|
|
236
|
+
landlord: ["./database/landlord-migrations", "./modules/billing/migrations"],
|
|
237
|
+
tenant: ["./database/tenant-migrations", "./modules/tenant-features/migrations"],
|
|
238
|
+
},
|
|
239
|
+
tenancy: {
|
|
240
|
+
resolveTenant: async (tenantId) => ({
|
|
241
|
+
strategy: "database",
|
|
242
|
+
name: `tenant:${tenantId}`,
|
|
243
|
+
config: await getTenantConnectionConfig(tenantId),
|
|
244
|
+
}),
|
|
245
|
+
listTenants: async () => await getAllTenantIds(),
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
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.
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
bun run bunny migrate
|
|
254
|
+
bun run bunny migrate --landlord
|
|
255
|
+
bun run bunny migrate --tenants
|
|
256
|
+
bun run bunny migrate --tenant acme
|
|
257
|
+
```
|
|
258
|
+
|
|
113
259
|
### Create Tables
|
|
114
260
|
|
|
115
261
|
```ts
|
|
@@ -141,9 +287,7 @@ const user = await User.create({ name: "Alice", email: "alice@example.com" });
|
|
|
141
287
|
const found = await User.find(1);
|
|
142
288
|
|
|
143
289
|
// Query
|
|
144
|
-
const adults = await User.where("age", ">=", 18)
|
|
145
|
-
.orderBy("name")
|
|
146
|
-
.get();
|
|
290
|
+
const adults = await User.where("age", ">=", 18).orderBy("name").get();
|
|
147
291
|
|
|
148
292
|
// Update
|
|
149
293
|
user.name = "Alice Smith";
|
|
@@ -186,37 +330,37 @@ await Schema.create("products", (table) => {
|
|
|
186
330
|
|
|
187
331
|
### Available Column Types
|
|
188
332
|
|
|
189
|
-
| Method
|
|
190
|
-
|
|
191
|
-
| `increments(name)`
|
|
192
|
-
| `bigIncrements(name)`
|
|
193
|
-
| `string(name, length=255)` | VARCHAR
|
|
194
|
-
| `text(name)`
|
|
195
|
-
| `integer(name)`
|
|
196
|
-
| `bigInteger(name)`
|
|
197
|
-
| `smallInteger(name)`
|
|
198
|
-
| `tinyInteger(name)`
|
|
199
|
-
| `float(name, p=8, s=2)`
|
|
200
|
-
| `double(name, p=8, s=2)`
|
|
201
|
-
| `decimal(name, p=8, s=2)`
|
|
202
|
-
| `boolean(name)`
|
|
203
|
-
| `date(name)`
|
|
204
|
-
| `dateTime(name)`
|
|
205
|
-
| `time(name)`
|
|
206
|
-
| `timestamp(name)`
|
|
207
|
-
| `json(name)`
|
|
208
|
-
| `jsonb(name)`
|
|
209
|
-
| `binary(name)`
|
|
210
|
-
| `uuid(name)`
|
|
211
|
-
| `enum(name, values)`
|
|
333
|
+
| Method | Description |
|
|
334
|
+
| -------------------------- | ------------------------------------- |
|
|
335
|
+
| `increments(name)` | Auto-incrementing integer primary key |
|
|
336
|
+
| `bigIncrements(name)` | Auto-incrementing big integer |
|
|
337
|
+
| `string(name, length=255)` | VARCHAR |
|
|
338
|
+
| `text(name)` | TEXT |
|
|
339
|
+
| `integer(name)` | INTEGER |
|
|
340
|
+
| `bigInteger(name)` | BIGINT |
|
|
341
|
+
| `smallInteger(name)` | SMALLINT |
|
|
342
|
+
| `tinyInteger(name)` | TINYINT |
|
|
343
|
+
| `float(name, p=8, s=2)` | FLOAT |
|
|
344
|
+
| `double(name, p=8, s=2)` | DOUBLE |
|
|
345
|
+
| `decimal(name, p=8, s=2)` | DECIMAL |
|
|
346
|
+
| `boolean(name)` | BOOLEAN |
|
|
347
|
+
| `date(name)` | DATE |
|
|
348
|
+
| `dateTime(name)` | DATETIME |
|
|
349
|
+
| `time(name)` | TIME |
|
|
350
|
+
| `timestamp(name)` | TIMESTAMP |
|
|
351
|
+
| `json(name)` | JSON |
|
|
352
|
+
| `jsonb(name)` | JSONB (Postgres) |
|
|
353
|
+
| `binary(name)` | BLOB / BYTEA |
|
|
354
|
+
| `uuid(name)` | UUID |
|
|
355
|
+
| `enum(name, values)` | ENUM |
|
|
212
356
|
|
|
213
357
|
### Column Modifiers
|
|
214
358
|
|
|
215
359
|
```ts
|
|
216
|
-
table.string("email").unique();
|
|
217
|
-
table.string("slug").index();
|
|
218
|
-
table.string("name").nullable();
|
|
219
|
-
table.integer("role").default(1);
|
|
360
|
+
table.string("email").unique(); // UNIQUE index
|
|
361
|
+
table.string("slug").index(); // INDEX
|
|
362
|
+
table.string("name").nullable(); // NULLABLE
|
|
363
|
+
table.integer("role").default(1); // DEFAULT value
|
|
220
364
|
table.string("code").comment("SKU code");
|
|
221
365
|
table.integer("user_id").unsigned();
|
|
222
366
|
```
|
|
@@ -261,6 +405,22 @@ User.where({ role: "admin", active: true });
|
|
|
261
405
|
User.whereIn("id", [1, 2, 3]);
|
|
262
406
|
User.whereNull("deleted_at");
|
|
263
407
|
User.whereNotNull("email");
|
|
408
|
+
User.whereNot("status", "banned");
|
|
409
|
+
|
|
410
|
+
// Date filtering (cross-database)
|
|
411
|
+
Event.whereDate("happened_at", "2024-01-01");
|
|
412
|
+
Event.whereYear("created_at", ">=", 2023);
|
|
413
|
+
Event.whereMonth("birthday", 12);
|
|
414
|
+
Event.whereDay("anniversary", 14);
|
|
415
|
+
Event.whereTime("opened_at", "09:00:00");
|
|
416
|
+
|
|
417
|
+
// or* variants
|
|
418
|
+
User.where("role", "admin").orWhereNull("email");
|
|
419
|
+
User.where("status", "active").orWhereIn("role", ["admin", "mod"]);
|
|
420
|
+
User.where("price", ">=", 100).orWhereBetween("price", [10, 50]);
|
|
421
|
+
User.where("name", "Alice").orWhereExists("SELECT 1 FROM orders WHERE orders.user_id = users.id");
|
|
422
|
+
User.where("a", 1).orWhereColumn("updated_at", ">", "created_at");
|
|
423
|
+
User.where("active", true).orWhereRaw("score > 100");
|
|
264
424
|
|
|
265
425
|
// Chaining
|
|
266
426
|
const results = await User
|
|
@@ -271,25 +431,188 @@ const results = await User
|
|
|
271
431
|
.offset(0)
|
|
272
432
|
.get();
|
|
273
433
|
|
|
434
|
+
// Conditional building
|
|
435
|
+
User.when(filters.name, (q) => q.where("name", filters.name))
|
|
436
|
+
.when(filters.age, (q) => q.where("age", ">=", filters.age))
|
|
437
|
+
.unless(showAll, (q) => q.where("active", true))
|
|
438
|
+
.tap((q) => console.log(q.toSql()));
|
|
439
|
+
|
|
440
|
+
// Ordering convenience
|
|
441
|
+
Post.latest().first(); // orderBy created_at desc
|
|
442
|
+
Post.oldest("published_at"); // orderBy published_at asc
|
|
443
|
+
Post.orderByDesc("score"); // shorthand
|
|
444
|
+
Post.orderBy("name").reorder(); // clear orders
|
|
445
|
+
Post.orderBy("name").reorder("id"); // replace with new order
|
|
446
|
+
|
|
274
447
|
// Aggregates
|
|
275
448
|
const count = await User.where("active", true).count();
|
|
276
449
|
const exists = await User.where("email", "test@example.com").exists();
|
|
450
|
+
const doesntExist = await User.where("email", "missing@example.com").doesntExist();
|
|
277
451
|
|
|
278
452
|
// Joins
|
|
279
453
|
const posts = await Post
|
|
280
454
|
.query()
|
|
281
455
|
.select("posts.*", "users.name as author_name")
|
|
282
456
|
.join("users", "posts.user_id", "=", "users.id")
|
|
457
|
+
.leftJoin("comments", "comments.post_id", "=", "posts.id")
|
|
458
|
+
.crossJoin("tags")
|
|
283
459
|
.get();
|
|
284
460
|
|
|
461
|
+
// Group by / Having
|
|
462
|
+
User.select("role").groupBy("role").having("count", ">", 1);
|
|
463
|
+
User.groupBy("role").havingRaw("COUNT(*) > 1").orHavingRaw("SUM(score) > 100");
|
|
464
|
+
|
|
465
|
+
// Union
|
|
466
|
+
const q1 = User.where("active", true);
|
|
467
|
+
const q2 = User.where("role", "admin");
|
|
468
|
+
const results = await q1.union(q2).get();
|
|
469
|
+
const allResults = await q1.unionAll(q2).get();
|
|
470
|
+
|
|
285
471
|
// Pluck
|
|
286
472
|
const emails = await User.pluck("email");
|
|
287
473
|
|
|
288
|
-
// First / Find
|
|
474
|
+
// First / Find / Sole
|
|
289
475
|
const user = await User.where("email", "alice@example.com").first();
|
|
290
476
|
const byId = await User.find(1);
|
|
477
|
+
const name = await User.where("id", 1).value("name"); // single scalar
|
|
478
|
+
const sole = await User.where("email", "alice@example.com").sole(); // exactly one or throw
|
|
479
|
+
|
|
480
|
+
// Find-or-Fail (throws if not found)
|
|
481
|
+
const user = await User.findOrFail(1);
|
|
482
|
+
const first = await User.firstOrFail();
|
|
483
|
+
|
|
484
|
+
// Streaming large datasets
|
|
485
|
+
await User.chunk(100, (users) => { ... });
|
|
486
|
+
await User.each(100, (user) => { ... });
|
|
487
|
+
for await (const user of User.cursor()) { ... }
|
|
488
|
+
for await (const user of User.lazy(500)) { ... }
|
|
489
|
+
|
|
490
|
+
// Raw / Subquery helpers
|
|
491
|
+
User.select("name").selectRaw("price * 2 as doubled");
|
|
492
|
+
User.fromSub(User.where("price", ">", 100), "expensive");
|
|
493
|
+
|
|
494
|
+
// Debug
|
|
495
|
+
User.where("name", "Alice").dump(); // logs SQL, returns builder
|
|
496
|
+
User.where("name", "Alice").dd(); // logs SQL and throws
|
|
291
497
|
```
|
|
292
498
|
|
|
499
|
+
### Query Builder Reference
|
|
500
|
+
|
|
501
|
+
| Method | Description |
|
|
502
|
+
| ---------------------------------------------------------- | ----------------------------------- |
|
|
503
|
+
| `where(col, op, val)` | Basic equality or operator filter |
|
|
504
|
+
| `where(obj)` | Object of column → value pairs |
|
|
505
|
+
| `where(fn)` | Nested where group via closure |
|
|
506
|
+
| `orWhere(...)` | OR variant of `where` |
|
|
507
|
+
| `whereNot(col, val)` | `!=` filter |
|
|
508
|
+
| `orWhereNot(...)` | OR `!=` |
|
|
509
|
+
| `whereIn(col, vals)` | `IN` set |
|
|
510
|
+
| `orWhereIn(...)` | OR `IN` |
|
|
511
|
+
| `whereNotIn(col, vals)` | `NOT IN` |
|
|
512
|
+
| `orWhereNotIn(...)` | OR `NOT IN` |
|
|
513
|
+
| `whereNull(col)` | `IS NULL` |
|
|
514
|
+
| `orWhereNull(...)` | OR `IS NULL` |
|
|
515
|
+
| `whereNotNull(col)` | `IS NOT NULL` |
|
|
516
|
+
| `orWhereNotNull(...)` | OR `IS NOT NULL` |
|
|
517
|
+
| `whereBetween(col, [a, b])` | `BETWEEN` |
|
|
518
|
+
| `orWhereBetween(...)` | OR `BETWEEN` |
|
|
519
|
+
| `whereNotBetween(col, [a, b])` | `NOT BETWEEN` |
|
|
520
|
+
| `orWhereNotBetween(...)` | OR `NOT BETWEEN` |
|
|
521
|
+
| `whereExists(sql)` | `EXISTS (subquery)` |
|
|
522
|
+
| `orWhereExists(...)` | OR `EXISTS` |
|
|
523
|
+
| `whereNotExists(sql)` | `NOT EXISTS` |
|
|
524
|
+
| `orWhereNotExists(...)` | OR `NOT EXISTS` |
|
|
525
|
+
| `whereColumn(a, op, b)` | Compare two columns |
|
|
526
|
+
| `orWhereColumn(...)` | OR column compare |
|
|
527
|
+
| `whereRaw(sql)` | Raw SQL where clause |
|
|
528
|
+
| `orWhereRaw(...)` | OR raw SQL |
|
|
529
|
+
| `whereDate(col, op, val)` | Cross-database date filter |
|
|
530
|
+
| `whereDay / whereMonth / whereYear / whereTime` | Date part filters |
|
|
531
|
+
| `whereJsonContains(col, val)` | JSON membership (cross-db) |
|
|
532
|
+
| `whereJsonLength(col, op, val)` | JSON array length |
|
|
533
|
+
| `whereLike(col, pattern)` | `LIKE` pattern |
|
|
534
|
+
| `whereNotLike(...)` | `NOT LIKE` |
|
|
535
|
+
| `whereRegexp(col, pattern)` | Regular expression match |
|
|
536
|
+
| `whereFullText(cols, query)` | Full-text search (cross-db) |
|
|
537
|
+
| `whereAll(cols, op, val)` | Multi-column `AND` |
|
|
538
|
+
| `whereAny(cols, op, val)` | Multi-column `OR` |
|
|
539
|
+
| `orderBy(col, dir)` | Sort ascending or descending |
|
|
540
|
+
| `orderByDesc(col)` | Sort descending shorthand |
|
|
541
|
+
| `latest(col?)` | `orderBy(created_at, desc)` |
|
|
542
|
+
| `oldest(col?)` | `orderBy(created_at, asc)` |
|
|
543
|
+
| `inRandomOrder()` | `ORDER BY RANDOM()` / `RAND()` |
|
|
544
|
+
| `reorder(col?, dir?)` | Clear and optionally replace orders |
|
|
545
|
+
| `groupBy(...cols)` | `GROUP BY` |
|
|
546
|
+
| `having(col, op, val)` | `HAVING` filter |
|
|
547
|
+
| `orHaving(...)` | OR `HAVING` |
|
|
548
|
+
| `havingRaw(sql)` | Raw `HAVING` |
|
|
549
|
+
| `orHavingRaw(...)` | OR raw `HAVING` |
|
|
550
|
+
| `join(tbl, a, op, b)` | `INNER JOIN` |
|
|
551
|
+
| `leftJoin(...)` | `LEFT JOIN` |
|
|
552
|
+
| `rightJoin(...)` | `RIGHT JOIN` |
|
|
553
|
+
| `crossJoin(tbl)` | `CROSS JOIN` |
|
|
554
|
+
| `union(query, all?)` | `UNION` another query |
|
|
555
|
+
| `unionAll(query)` | `UNION ALL` |
|
|
556
|
+
| `select(...cols)` | Choose columns |
|
|
557
|
+
| `addSelect(...cols)` | Append columns |
|
|
558
|
+
| `selectRaw(sql)` | Raw SELECT expression |
|
|
559
|
+
| `fromSub(query, alias)` | Derived table from subquery |
|
|
560
|
+
| `distinct()` | `SELECT DISTINCT` |
|
|
561
|
+
| `limit(n)` | Row limit |
|
|
562
|
+
| `offset(n)` | Row offset |
|
|
563
|
+
| `take(n)` | Alias for `limit` |
|
|
564
|
+
| `skip(n)` | Alias for `offset` |
|
|
565
|
+
| `forPage(page, perPage)` | Pagination offset/limit |
|
|
566
|
+
| `lockForUpdate()` | `FOR UPDATE` (MySQL/Postgres) |
|
|
567
|
+
| `sharedLock()` | `LOCK IN SHARE MODE` / `FOR SHARE` |
|
|
568
|
+
| `skipLocked()` | Append `SKIP LOCKED` |
|
|
569
|
+
| `noWait()` | Append `NOWAIT` |
|
|
570
|
+
| `get()` | Fetch all rows |
|
|
571
|
+
| `first()` | Fetch first row |
|
|
572
|
+
| `find(id, col?)` | Find by ID |
|
|
573
|
+
| `findOrFail(id, col?)` | Find or throw |
|
|
574
|
+
| `firstOrFail()` | First or throw |
|
|
575
|
+
| `sole()` | Exactly one row or throw |
|
|
576
|
+
| `value(col)` | Single scalar from first row |
|
|
577
|
+
| `pluck(col)` | Array of column values |
|
|
578
|
+
| `count(col?)` | `COUNT` aggregate |
|
|
579
|
+
| `sum(col)` | `SUM` |
|
|
580
|
+
| `avg(col)` | `AVG` |
|
|
581
|
+
| `min(col)` | `MIN` |
|
|
582
|
+
| `max(col)` | `MAX` |
|
|
583
|
+
| `exists()` | Check any rows exist |
|
|
584
|
+
| `doesntExist()` | Check no rows exist |
|
|
585
|
+
| `paginate(perPage?, page?)` | Paginated result set |
|
|
586
|
+
| `chunk(n, fn)` | Batch iterate |
|
|
587
|
+
| `each(n, fn)` | Per-item iterate |
|
|
588
|
+
| `cursor()` | Lazy async generator |
|
|
589
|
+
| `lazy(n?)` | Chunked lazy generator |
|
|
590
|
+
| `insert(data)` | Insert row(s) |
|
|
591
|
+
| `insertGetId(data, col?)` | Insert and return ID |
|
|
592
|
+
| `insertOrIgnore(data)` | Insert, ignore conflicts |
|
|
593
|
+
| `upsert(data, uniqueBy, updateCols?)` | Insert or update on conflict |
|
|
594
|
+
| `update(data)` | Update matched rows |
|
|
595
|
+
| `updateFrom(tbl, a, op, b)` | Update with JOIN |
|
|
596
|
+
| `delete()` | Delete matched rows |
|
|
597
|
+
| `increment(col, amt?, extra?)` | Add to column |
|
|
598
|
+
| `decrement(col, amt?, extra?)` | Subtract from column |
|
|
599
|
+
| `restore()` | Restore soft-deleted rows |
|
|
600
|
+
| `with(...rels)` | Eager load relations |
|
|
601
|
+
| `has(rel)` / `orHas(rel)` | Relation existence |
|
|
602
|
+
| `whereHas(rel, fn?)` / `orWhereHas(...)` | Filtered relation existence |
|
|
603
|
+
| `doesntHave(rel)` / `whereDoesntHave(...)` | Relation absence |
|
|
604
|
+
| `withCount(rel)` / `withSum / withAvg / withMin / withMax` | Relation aggregates |
|
|
605
|
+
| `scope(name, ...args)` | Apply local scope |
|
|
606
|
+
| `withoutGlobalScope(name)` / `withoutGlobalScopes()` | Remove scopes |
|
|
607
|
+
| `withTrashed()` / `onlyTrashed()` | Soft delete visibility |
|
|
608
|
+
| `when(cond, fn, elseFn?)` / `unless(...)` | Conditional building |
|
|
609
|
+
| `tap(fn)` | Mutate and return |
|
|
610
|
+
| `clone()` | Copy builder state |
|
|
611
|
+
| `toSql()` | Compile to SQL string |
|
|
612
|
+
| `dump()` | Log SQL, return builder |
|
|
613
|
+
| `dd()` | Log SQL and halt |
|
|
614
|
+
| `explain()` | Return query plan |
|
|
615
|
+
|
|
293
616
|
---
|
|
294
617
|
|
|
295
618
|
## Models
|
|
@@ -306,10 +629,10 @@ const byId = await User.find(1);
|
|
|
306
629
|
|
|
307
630
|
```ts
|
|
308
631
|
class Product extends Model {
|
|
309
|
-
static table = "products";
|
|
310
|
-
static primaryKey = "sku";
|
|
311
|
-
static timestamps = false;
|
|
312
|
-
static softDeletes = true;
|
|
632
|
+
static table = "products"; // override table name
|
|
633
|
+
static primaryKey = "sku"; // override primary key
|
|
634
|
+
static timestamps = false; // disable timestamps
|
|
635
|
+
static softDeletes = true; // use deleted_at instead of hard deletes
|
|
313
636
|
|
|
314
637
|
static attributes = {
|
|
315
638
|
active: true,
|
|
@@ -336,16 +659,33 @@ const builder = User.where("active", true);
|
|
|
336
659
|
|
|
337
660
|
// Instance
|
|
338
661
|
user.fill({ name: "Bob", email: "bob@example.com" });
|
|
339
|
-
user.name;
|
|
340
|
-
user.name = "Charlie";
|
|
662
|
+
user.name; // property access
|
|
663
|
+
user.name = "Charlie"; // property assignment
|
|
341
664
|
user.getAttribute("name"); // explicit access still works
|
|
342
665
|
user.setAttribute("name", "Dana");
|
|
343
|
-
user.isDirty();
|
|
344
|
-
user.getDirty();
|
|
666
|
+
user.isDirty(); // true if attributes changed
|
|
667
|
+
user.getDirty(); // { name: "Charlie" }
|
|
345
668
|
await user.save();
|
|
346
669
|
await user.delete();
|
|
347
670
|
await user.refresh();
|
|
348
|
-
user.
|
|
671
|
+
await user.touch(); // update only timestamps
|
|
672
|
+
await user.load("posts"); // lazy eager loading
|
|
673
|
+
user.toJSON(); // plain object
|
|
674
|
+
|
|
675
|
+
// Increment / Decrement
|
|
676
|
+
await user.increment("login_count");
|
|
677
|
+
await user.increment("login_count", 5, { last_login_at: new Date() });
|
|
678
|
+
await user.decrement("stock", 10);
|
|
679
|
+
|
|
680
|
+
// First-or-Create / Update-or-Create
|
|
681
|
+
const user = await User.firstOrCreate(
|
|
682
|
+
{ email: "alice@example.com" },
|
|
683
|
+
{ name: "Alice" },
|
|
684
|
+
);
|
|
685
|
+
const user = await User.updateOrCreate(
|
|
686
|
+
{ email: "alice@example.com" },
|
|
687
|
+
{ name: "Alice Smith" },
|
|
688
|
+
);
|
|
349
689
|
```
|
|
350
690
|
|
|
351
691
|
### Default Attributes
|
|
@@ -362,7 +702,7 @@ class User extends Model {
|
|
|
362
702
|
|
|
363
703
|
const user = new User({ name: "Ada" });
|
|
364
704
|
user.active; // true
|
|
365
|
-
user.role;
|
|
705
|
+
user.role; // "member"
|
|
366
706
|
```
|
|
367
707
|
|
|
368
708
|
These are model defaults, not database defaults. Values provided by the caller override them.
|
|
@@ -387,23 +727,23 @@ const user = new User({
|
|
|
387
727
|
settings: { theme: "dark" },
|
|
388
728
|
});
|
|
389
729
|
|
|
390
|
-
user.$attributes.active;
|
|
391
|
-
user.active;
|
|
392
|
-
user.settings.theme;
|
|
730
|
+
user.$attributes.active; // 1
|
|
731
|
+
user.active; // true
|
|
732
|
+
user.settings.theme; // "dark"
|
|
393
733
|
```
|
|
394
734
|
|
|
395
735
|
Supported built-in casts:
|
|
396
736
|
|
|
397
|
-
| Cast
|
|
398
|
-
|
|
399
|
-
| `boolean`, `bool`
|
|
400
|
-
| `number`, `integer`, `int`, `float`, `double` | Reads/writes numbers
|
|
401
|
-
| `decimal:2`
|
|
402
|
-
| `string`
|
|
403
|
-
| `date`, `datetime`
|
|
404
|
-
| `json`, `array`, `object`
|
|
405
|
-
| `enum`
|
|
406
|
-
| `encrypted`
|
|
737
|
+
| Cast | Behavior |
|
|
738
|
+
| --------------------------------------------- | --------------------------------------------------- |
|
|
739
|
+
| `boolean`, `bool` | Stores `1` / `0`, reads boolean |
|
|
740
|
+
| `number`, `integer`, `int`, `float`, `double` | Reads/writes numbers |
|
|
741
|
+
| `decimal:2` | Stores fixed precision string |
|
|
742
|
+
| `string` | Reads/writes string |
|
|
743
|
+
| `date`, `datetime` | Reads as `Date`, stores ISO string for `Date` input |
|
|
744
|
+
| `json`, `array`, `object` | Stores JSON string, reads parsed value |
|
|
745
|
+
| `enum` | Stores enum `.value` when present |
|
|
746
|
+
| `encrypted` | Base64 encodes on write and decodes on read |
|
|
407
747
|
|
|
408
748
|
Custom casts can implement `CastsAttributes`:
|
|
409
749
|
|
|
@@ -442,13 +782,13 @@ class User extends Model {
|
|
|
442
782
|
static softDeletes = true;
|
|
443
783
|
}
|
|
444
784
|
|
|
445
|
-
await user.delete();
|
|
446
|
-
await user.restore();
|
|
447
|
-
await user.forceDelete();
|
|
785
|
+
await user.delete(); // sets deleted_at
|
|
786
|
+
await user.restore(); // clears deleted_at
|
|
787
|
+
await user.forceDelete(); // permanently deletes
|
|
448
788
|
|
|
449
|
-
await User.all();
|
|
450
|
-
await User.withTrashed().get();
|
|
451
|
-
await User.onlyTrashed().get();
|
|
789
|
+
await User.all(); // excludes trashed rows
|
|
790
|
+
await User.withTrashed().get(); // includes trashed rows
|
|
791
|
+
await User.onlyTrashed().get(); // only trashed rows
|
|
452
792
|
await User.onlyTrashed().restore();
|
|
453
793
|
```
|
|
454
794
|
|
|
@@ -486,17 +826,17 @@ await User.withoutGlobalScopes().get();
|
|
|
486
826
|
```ts
|
|
487
827
|
class User extends Model {
|
|
488
828
|
posts() {
|
|
489
|
-
return this.hasMany(Post);
|
|
829
|
+
return this.hasMany(Post); // foreignKey: user_id, localKey: id
|
|
490
830
|
}
|
|
491
831
|
|
|
492
832
|
profile() {
|
|
493
|
-
return this.hasOne(Profile);
|
|
833
|
+
return this.hasOne(Profile); // foreignKey: user_id, localKey: id
|
|
494
834
|
}
|
|
495
835
|
}
|
|
496
836
|
|
|
497
837
|
class Post extends Model {
|
|
498
838
|
author() {
|
|
499
|
-
return this.belongsTo(User);
|
|
839
|
+
return this.belongsTo(User); // foreignKey: user_id, ownerKey: id
|
|
500
840
|
}
|
|
501
841
|
}
|
|
502
842
|
```
|
|
@@ -592,8 +932,7 @@ const usersWithoutPosts = await User.doesntHave("posts").get();
|
|
|
592
932
|
Add relation aggregate columns:
|
|
593
933
|
|
|
594
934
|
```ts
|
|
595
|
-
const users = await User
|
|
596
|
-
.withCount("posts")
|
|
935
|
+
const users = await User.withCount("posts")
|
|
597
936
|
.withSum("posts", "views")
|
|
598
937
|
.withAvg("posts", "score")
|
|
599
938
|
.withMin("posts", "created_at")
|
|
@@ -615,7 +954,7 @@ MorphMap.register("Video", Video);
|
|
|
615
954
|
|
|
616
955
|
class Comment extends Model {
|
|
617
956
|
commentable() {
|
|
618
|
-
return this.morphTo("commentable");
|
|
957
|
+
return this.morphTo("commentable"); // reads commentable_type / commentable_id
|
|
619
958
|
}
|
|
620
959
|
}
|
|
621
960
|
|
|
@@ -658,7 +997,7 @@ Pivot table: `taggables(tag_id, taggable_id, taggable_type)`.
|
|
|
658
997
|
|
|
659
998
|
```ts
|
|
660
999
|
class Post extends Model {
|
|
661
|
-
static morphName = "post";
|
|
1000
|
+
static morphName = "post"; // stored in {name}_type column
|
|
662
1001
|
}
|
|
663
1002
|
```
|
|
664
1003
|
|
|
@@ -819,8 +1158,8 @@ Bunny can introspect your database schema and generate TypeScript declaration fi
|
|
|
819
1158
|
|
|
820
1159
|
```ts
|
|
821
1160
|
const user = await User.first();
|
|
822
|
-
user.name;
|
|
823
|
-
user.email = "a@example.com";
|
|
1161
|
+
user.name; // ✅ autocomplete + type-checking
|
|
1162
|
+
user.email = "a@example.com"; // ✅ typed setter
|
|
824
1163
|
```
|
|
825
1164
|
|
|
826
1165
|
### Generate Types
|
|
@@ -856,9 +1195,9 @@ export default {
|
|
|
856
1195
|
|
|
857
1196
|
With `modelsPath`, Bunny conventionally maps tables to singular PascalCase model modules and writes the generated declarations into `types/` beside each model root:
|
|
858
1197
|
|
|
859
|
-
| Table
|
|
860
|
-
|
|
861
|
-
| `users`
|
|
1198
|
+
| Table | Generated augmentation |
|
|
1199
|
+
| ------------ | -------------------------- |
|
|
1200
|
+
| `users` | `../User` / `User` |
|
|
862
1201
|
| `blog_posts` | `../BlogPost` / `BlogPost` |
|
|
863
1202
|
| `categories` | `../Category` / `Category` |
|
|
864
1203
|
|
|
@@ -919,7 +1258,7 @@ class User extends Model<UserAttributes> {
|
|
|
919
1258
|
// $attributes and getAttribute are now typed
|
|
920
1259
|
const user = await User.first();
|
|
921
1260
|
user.getAttribute("name"); // string
|
|
922
|
-
user.$attributes.email;
|
|
1261
|
+
user.$attributes.email; // string
|
|
923
1262
|
```
|
|
924
1263
|
|
|
925
1264
|
---
|
|
@@ -932,7 +1271,7 @@ Bunny includes a full test suite built with `bun:test`.
|
|
|
932
1271
|
bun test
|
|
933
1272
|
```
|
|
934
1273
|
|
|
935
|
-
|
|
1274
|
+
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.
|
|
936
1275
|
|
|
937
1276
|
---
|
|
938
1277
|
|