@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 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
- admin_users: { path: "../AdminAccount", className: "AdminAccount" },
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 | Description |
191
- |--------|-------------|
192
- | `increments(name)` | Auto-incrementing integer primary key |
193
- | `bigIncrements(name)` | Auto-incrementing big integer |
194
- | `string(name, length=255)` | VARCHAR |
195
- | `text(name)` | TEXT |
196
- | `integer(name)` | INTEGER |
197
- | `bigInteger(name)` | BIGINT |
198
- | `smallInteger(name)` | SMALLINT |
199
- | `tinyInteger(name)` | TINYINT |
200
- | `float(name, p=8, s=2)` | FLOAT |
201
- | `double(name, p=8, s=2)` | DOUBLE |
202
- | `decimal(name, p=8, s=2)` | DECIMAL |
203
- | `boolean(name)` | BOOLEAN |
204
- | `date(name)` | DATE |
205
- | `dateTime(name)` | DATETIME |
206
- | `time(name)` | TIME |
207
- | `timestamp(name)` | TIMESTAMP |
208
- | `json(name)` | JSON |
209
- | `jsonb(name)` | JSONB (Postgres) |
210
- | `binary(name)` | BLOB / BYTEA |
211
- | `uuid(name)` | UUID |
212
- | `enum(name, values)` | ENUM |
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(); // UNIQUE index
218
- table.string("slug").index(); // INDEX
219
- table.string("name").nullable(); // NULLABLE
220
- table.integer("role").default(1); // DEFAULT value
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"; // override table name
339
- static primaryKey = "sku"; // override primary key
340
- static timestamps = false; // disable timestamps
341
- static softDeletes = true; // use deleted_at instead of hard deletes
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; // property access
369
- user.name = "Charlie"; // property assignment
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(); // true if attributes changed
373
- user.getDirty(); // { name: "Charlie" }
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(); // update only timestamps
671
+ await user.touch(); // update only timestamps
378
672
  await user.load("posts"); // lazy eager loading
379
- user.toJSON(); // plain object
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({ email: "alice@example.com" }, { name: "Alice" });
388
- const user = await User.updateOrCreate({ email: "alice@example.com" }, { name: "Alice Smith" });
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; // "member"
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; // 1
431
- user.active; // true
432
- user.settings.theme; // "dark"
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 | Behavior |
438
- |------|----------|
439
- | `boolean`, `bool` | Stores `1` / `0`, reads boolean |
440
- | `number`, `integer`, `int`, `float`, `double` | Reads/writes numbers |
441
- | `decimal:2` | Stores fixed precision string |
442
- | `string` | Reads/writes string |
443
- | `date`, `datetime` | Reads as `Date`, stores ISO string for `Date` input |
444
- | `json`, `array`, `object` | Stores JSON string, reads parsed value |
445
- | `enum` | Stores enum `.value` when present |
446
- | `encrypted` | Base64 encodes on write and decodes on read |
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(); // sets deleted_at
486
- await user.restore(); // clears deleted_at
487
- await user.forceDelete(); // permanently deletes
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(); // excludes trashed rows
490
- await User.withTrashed().get(); // includes trashed rows
491
- await User.onlyTrashed().get(); // only trashed rows
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); // foreignKey: user_id, localKey: id
829
+ return this.hasMany(Post); // foreignKey: user_id, localKey: id
530
830
  }
531
831
 
532
832
  profile() {
533
- return this.hasOne(Profile); // foreignKey: user_id, localKey: id
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); // foreignKey: user_id, ownerKey: id
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"); // reads commentable_type / commentable_id
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"; // stored in {name}_type column
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; // ✅ autocomplete + type-checking
863
- user.email = "a@example.com"; // ✅ typed setter
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 | Generated augmentation |
900
- |-------|------------------------|
901
- | `users` | `../User` / `User` |
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; // string
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
- 139 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, and latest/oldest.
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