@bunnykit/orm 0.1.5 → 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 +381 -82
- 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
|
@@ -54,14 +54,29 @@ export default {
|
|
|
54
54
|
// password: "secret",
|
|
55
55
|
},
|
|
56
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
|
+
// },
|
|
57
72
|
modelsPath: ["./src/models", "./src/admin/models"],
|
|
58
73
|
// Optional legacy type output directory
|
|
59
|
-
typesOutDir: "./src/generated/model-types",
|
|
74
|
+
// typesOutDir: "./src/generated/model-types",
|
|
60
75
|
// Optional typegen overrides
|
|
61
|
-
typeDeclarationImportPrefix: "../models",
|
|
62
|
-
typeDeclarations: {
|
|
63
|
-
|
|
64
|
-
},
|
|
76
|
+
// typeDeclarationImportPrefix: "../models",
|
|
77
|
+
// typeDeclarations: {
|
|
78
|
+
// admin_users: { path: "../AdminAccount", className: "AdminAccount" },
|
|
79
|
+
// },
|
|
65
80
|
};
|
|
66
81
|
```
|
|
67
82
|
|
|
@@ -111,6 +126,136 @@ Model.setConnection(connection);
|
|
|
111
126
|
Schema.setConnection(connection);
|
|
112
127
|
```
|
|
113
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
|
+
|
|
114
259
|
### Create Tables
|
|
115
260
|
|
|
116
261
|
```ts
|
|
@@ -142,9 +287,7 @@ const user = await User.create({ name: "Alice", email: "alice@example.com" });
|
|
|
142
287
|
const found = await User.find(1);
|
|
143
288
|
|
|
144
289
|
// Query
|
|
145
|
-
const adults = await User.where("age", ">=", 18)
|
|
146
|
-
.orderBy("name")
|
|
147
|
-
.get();
|
|
290
|
+
const adults = await User.where("age", ">=", 18).orderBy("name").get();
|
|
148
291
|
|
|
149
292
|
// Update
|
|
150
293
|
user.name = "Alice Smith";
|
|
@@ -187,37 +330,37 @@ await Schema.create("products", (table) => {
|
|
|
187
330
|
|
|
188
331
|
### Available Column Types
|
|
189
332
|
|
|
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)`
|
|
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 |
|
|
213
356
|
|
|
214
357
|
### Column Modifiers
|
|
215
358
|
|
|
216
359
|
```ts
|
|
217
|
-
table.string("email").unique();
|
|
218
|
-
table.string("slug").index();
|
|
219
|
-
table.string("name").nullable();
|
|
220
|
-
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
|
|
221
364
|
table.string("code").comment("SKU code");
|
|
222
365
|
table.integer("user_id").unsigned();
|
|
223
366
|
```
|
|
@@ -271,6 +414,14 @@ Event.whereMonth("birthday", 12);
|
|
|
271
414
|
Event.whereDay("anniversary", 14);
|
|
272
415
|
Event.whereTime("opened_at", "09:00:00");
|
|
273
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");
|
|
424
|
+
|
|
274
425
|
// Chaining
|
|
275
426
|
const results = await User
|
|
276
427
|
.where("age", ">=", 18)
|
|
@@ -289,24 +440,42 @@ User.when(filters.name, (q) => q.where("name", filters.name))
|
|
|
289
440
|
// Ordering convenience
|
|
290
441
|
Post.latest().first(); // orderBy created_at desc
|
|
291
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
|
|
292
446
|
|
|
293
447
|
// Aggregates
|
|
294
448
|
const count = await User.where("active", true).count();
|
|
295
449
|
const exists = await User.where("email", "test@example.com").exists();
|
|
450
|
+
const doesntExist = await User.where("email", "missing@example.com").doesntExist();
|
|
296
451
|
|
|
297
452
|
// Joins
|
|
298
453
|
const posts = await Post
|
|
299
454
|
.query()
|
|
300
455
|
.select("posts.*", "users.name as author_name")
|
|
301
456
|
.join("users", "posts.user_id", "=", "users.id")
|
|
457
|
+
.leftJoin("comments", "comments.post_id", "=", "posts.id")
|
|
458
|
+
.crossJoin("tags")
|
|
302
459
|
.get();
|
|
303
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
|
+
|
|
304
471
|
// Pluck
|
|
305
472
|
const emails = await User.pluck("email");
|
|
306
473
|
|
|
307
|
-
// First / Find
|
|
474
|
+
// First / Find / Sole
|
|
308
475
|
const user = await User.where("email", "alice@example.com").first();
|
|
309
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
|
|
310
479
|
|
|
311
480
|
// Find-or-Fail (throws if not found)
|
|
312
481
|
const user = await User.findOrFail(1);
|
|
@@ -317,8 +486,133 @@ await User.chunk(100, (users) => { ... });
|
|
|
317
486
|
await User.each(100, (user) => { ... });
|
|
318
487
|
for await (const user of User.cursor()) { ... }
|
|
319
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
|
|
320
497
|
```
|
|
321
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
|
+
|
|
322
616
|
---
|
|
323
617
|
|
|
324
618
|
## Models
|
|
@@ -335,10 +629,10 @@ for await (const user of User.lazy(500)) { ... }
|
|
|
335
629
|
|
|
336
630
|
```ts
|
|
337
631
|
class Product extends Model {
|
|
338
|
-
static table = "products";
|
|
339
|
-
static primaryKey = "sku";
|
|
340
|
-
static timestamps = false;
|
|
341
|
-
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
|
|
342
636
|
|
|
343
637
|
static attributes = {
|
|
344
638
|
active: true,
|
|
@@ -365,18 +659,18 @@ const builder = User.where("active", true);
|
|
|
365
659
|
|
|
366
660
|
// Instance
|
|
367
661
|
user.fill({ name: "Bob", email: "bob@example.com" });
|
|
368
|
-
user.name;
|
|
369
|
-
user.name = "Charlie";
|
|
662
|
+
user.name; // property access
|
|
663
|
+
user.name = "Charlie"; // property assignment
|
|
370
664
|
user.getAttribute("name"); // explicit access still works
|
|
371
665
|
user.setAttribute("name", "Dana");
|
|
372
|
-
user.isDirty();
|
|
373
|
-
user.getDirty();
|
|
666
|
+
user.isDirty(); // true if attributes changed
|
|
667
|
+
user.getDirty(); // { name: "Charlie" }
|
|
374
668
|
await user.save();
|
|
375
669
|
await user.delete();
|
|
376
670
|
await user.refresh();
|
|
377
|
-
await user.touch();
|
|
671
|
+
await user.touch(); // update only timestamps
|
|
378
672
|
await user.load("posts"); // lazy eager loading
|
|
379
|
-
user.toJSON();
|
|
673
|
+
user.toJSON(); // plain object
|
|
380
674
|
|
|
381
675
|
// Increment / Decrement
|
|
382
676
|
await user.increment("login_count");
|
|
@@ -384,8 +678,14 @@ await user.increment("login_count", 5, { last_login_at: new Date() });
|
|
|
384
678
|
await user.decrement("stock", 10);
|
|
385
679
|
|
|
386
680
|
// First-or-Create / Update-or-Create
|
|
387
|
-
const user = await User.firstOrCreate(
|
|
388
|
-
|
|
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
|
+
);
|
|
389
689
|
```
|
|
390
690
|
|
|
391
691
|
### Default Attributes
|
|
@@ -402,7 +702,7 @@ class User extends Model {
|
|
|
402
702
|
|
|
403
703
|
const user = new User({ name: "Ada" });
|
|
404
704
|
user.active; // true
|
|
405
|
-
user.role;
|
|
705
|
+
user.role; // "member"
|
|
406
706
|
```
|
|
407
707
|
|
|
408
708
|
These are model defaults, not database defaults. Values provided by the caller override them.
|
|
@@ -427,23 +727,23 @@ const user = new User({
|
|
|
427
727
|
settings: { theme: "dark" },
|
|
428
728
|
});
|
|
429
729
|
|
|
430
|
-
user.$attributes.active;
|
|
431
|
-
user.active;
|
|
432
|
-
user.settings.theme;
|
|
730
|
+
user.$attributes.active; // 1
|
|
731
|
+
user.active; // true
|
|
732
|
+
user.settings.theme; // "dark"
|
|
433
733
|
```
|
|
434
734
|
|
|
435
735
|
Supported built-in casts:
|
|
436
736
|
|
|
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`
|
|
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 |
|
|
447
747
|
|
|
448
748
|
Custom casts can implement `CastsAttributes`:
|
|
449
749
|
|
|
@@ -482,13 +782,13 @@ class User extends Model {
|
|
|
482
782
|
static softDeletes = true;
|
|
483
783
|
}
|
|
484
784
|
|
|
485
|
-
await user.delete();
|
|
486
|
-
await user.restore();
|
|
487
|
-
await user.forceDelete();
|
|
785
|
+
await user.delete(); // sets deleted_at
|
|
786
|
+
await user.restore(); // clears deleted_at
|
|
787
|
+
await user.forceDelete(); // permanently deletes
|
|
488
788
|
|
|
489
|
-
await User.all();
|
|
490
|
-
await User.withTrashed().get();
|
|
491
|
-
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
|
|
492
792
|
await User.onlyTrashed().restore();
|
|
493
793
|
```
|
|
494
794
|
|
|
@@ -526,17 +826,17 @@ await User.withoutGlobalScopes().get();
|
|
|
526
826
|
```ts
|
|
527
827
|
class User extends Model {
|
|
528
828
|
posts() {
|
|
529
|
-
return this.hasMany(Post);
|
|
829
|
+
return this.hasMany(Post); // foreignKey: user_id, localKey: id
|
|
530
830
|
}
|
|
531
831
|
|
|
532
832
|
profile() {
|
|
533
|
-
return this.hasOne(Profile);
|
|
833
|
+
return this.hasOne(Profile); // foreignKey: user_id, localKey: id
|
|
534
834
|
}
|
|
535
835
|
}
|
|
536
836
|
|
|
537
837
|
class Post extends Model {
|
|
538
838
|
author() {
|
|
539
|
-
return this.belongsTo(User);
|
|
839
|
+
return this.belongsTo(User); // foreignKey: user_id, ownerKey: id
|
|
540
840
|
}
|
|
541
841
|
}
|
|
542
842
|
```
|
|
@@ -632,8 +932,7 @@ const usersWithoutPosts = await User.doesntHave("posts").get();
|
|
|
632
932
|
Add relation aggregate columns:
|
|
633
933
|
|
|
634
934
|
```ts
|
|
635
|
-
const users = await User
|
|
636
|
-
.withCount("posts")
|
|
935
|
+
const users = await User.withCount("posts")
|
|
637
936
|
.withSum("posts", "views")
|
|
638
937
|
.withAvg("posts", "score")
|
|
639
938
|
.withMin("posts", "created_at")
|
|
@@ -655,7 +954,7 @@ MorphMap.register("Video", Video);
|
|
|
655
954
|
|
|
656
955
|
class Comment extends Model {
|
|
657
956
|
commentable() {
|
|
658
|
-
return this.morphTo("commentable");
|
|
957
|
+
return this.morphTo("commentable"); // reads commentable_type / commentable_id
|
|
659
958
|
}
|
|
660
959
|
}
|
|
661
960
|
|
|
@@ -698,7 +997,7 @@ Pivot table: `taggables(tag_id, taggable_id, taggable_type)`.
|
|
|
698
997
|
|
|
699
998
|
```ts
|
|
700
999
|
class Post extends Model {
|
|
701
|
-
static morphName = "post";
|
|
1000
|
+
static morphName = "post"; // stored in {name}_type column
|
|
702
1001
|
}
|
|
703
1002
|
```
|
|
704
1003
|
|
|
@@ -859,8 +1158,8 @@ Bunny can introspect your database schema and generate TypeScript declaration fi
|
|
|
859
1158
|
|
|
860
1159
|
```ts
|
|
861
1160
|
const user = await User.first();
|
|
862
|
-
user.name;
|
|
863
|
-
user.email = "a@example.com";
|
|
1161
|
+
user.name; // ✅ autocomplete + type-checking
|
|
1162
|
+
user.email = "a@example.com"; // ✅ typed setter
|
|
864
1163
|
```
|
|
865
1164
|
|
|
866
1165
|
### Generate Types
|
|
@@ -896,9 +1195,9 @@ export default {
|
|
|
896
1195
|
|
|
897
1196
|
With `modelsPath`, Bunny conventionally maps tables to singular PascalCase model modules and writes the generated declarations into `types/` beside each model root:
|
|
898
1197
|
|
|
899
|
-
| Table
|
|
900
|
-
|
|
901
|
-
| `users`
|
|
1198
|
+
| Table | Generated augmentation |
|
|
1199
|
+
| ------------ | -------------------------- |
|
|
1200
|
+
| `users` | `../User` / `User` |
|
|
902
1201
|
| `blog_posts` | `../BlogPost` / `BlogPost` |
|
|
903
1202
|
| `categories` | `../Category` / `Category` |
|
|
904
1203
|
|
|
@@ -959,7 +1258,7 @@ class User extends Model<UserAttributes> {
|
|
|
959
1258
|
// $attributes and getAttribute are now typed
|
|
960
1259
|
const user = await User.first();
|
|
961
1260
|
user.getAttribute("name"); // string
|
|
962
|
-
user.$attributes.email;
|
|
1261
|
+
user.$attributes.email; // string
|
|
963
1262
|
```
|
|
964
1263
|
|
|
965
1264
|
---
|
|
@@ -972,7 +1271,7 @@ Bunny includes a full test suite built with `bun:test`.
|
|
|
972
1271
|
bun test
|
|
973
1272
|
```
|
|
974
1273
|
|
|
975
|
-
|
|
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.
|
|
976
1275
|
|
|
977
1276
|
---
|
|
978
1277
|
|