@bunnykit/orm 0.1.5 → 0.1.7

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