@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.
Files changed (32) hide show
  1. package/README.md +420 -81
  2. package/dist/bin/bunny.js +133 -19
  3. package/dist/src/config/BunnyConfig.d.ts +28 -0
  4. package/dist/src/config/BunnyConfig.js +14 -0
  5. package/dist/src/connection/Connection.d.ts +20 -1
  6. package/dist/src/connection/Connection.js +80 -2
  7. package/dist/src/connection/ConnectionManager.d.ts +40 -0
  8. package/dist/src/connection/ConnectionManager.js +104 -0
  9. package/dist/src/connection/TenantContext.d.ts +15 -0
  10. package/dist/src/connection/TenantContext.js +22 -0
  11. package/dist/src/index.d.ts +7 -0
  12. package/dist/src/index.js +4 -0
  13. package/dist/src/model/BelongsToMany.js +9 -6
  14. package/dist/src/model/Model.d.ts +41 -0
  15. package/dist/src/model/Model.js +217 -18
  16. package/dist/src/model/ModelNotFoundError.d.ts +5 -0
  17. package/dist/src/model/ModelNotFoundError.js +13 -0
  18. package/dist/src/model/MorphRelations.js +10 -10
  19. package/dist/src/query/Builder.d.ts +85 -7
  20. package/dist/src/query/Builder.js +489 -68
  21. package/dist/src/query/grammars/Grammar.d.ts +19 -0
  22. package/dist/src/query/grammars/Grammar.js +47 -0
  23. package/dist/src/query/grammars/MySqlGrammar.d.ts +13 -0
  24. package/dist/src/query/grammars/MySqlGrammar.js +59 -0
  25. package/dist/src/query/grammars/PostgresGrammar.d.ts +13 -0
  26. package/dist/src/query/grammars/PostgresGrammar.js +62 -0
  27. package/dist/src/query/grammars/SQLiteGrammar.d.ts +14 -0
  28. package/dist/src/query/grammars/SQLiteGrammar.js +63 -0
  29. package/dist/src/schema/Schema.js +44 -26
  30. package/dist/src/typegen/TypeGenerator.js +4 -2
  31. package/dist/src/types/index.d.ts +10 -0
  32. 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
- admin_users: { path: "../AdminAccount", className: "AdminAccount" },
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 | Description |
190
- |--------|-------------|
191
- | `increments(name)` | Auto-incrementing integer primary key |
192
- | `bigIncrements(name)` | Auto-incrementing big integer |
193
- | `string(name, length=255)` | VARCHAR |
194
- | `text(name)` | TEXT |
195
- | `integer(name)` | INTEGER |
196
- | `bigInteger(name)` | BIGINT |
197
- | `smallInteger(name)` | SMALLINT |
198
- | `tinyInteger(name)` | TINYINT |
199
- | `float(name, p=8, s=2)` | FLOAT |
200
- | `double(name, p=8, s=2)` | DOUBLE |
201
- | `decimal(name, p=8, s=2)` | DECIMAL |
202
- | `boolean(name)` | BOOLEAN |
203
- | `date(name)` | DATE |
204
- | `dateTime(name)` | DATETIME |
205
- | `time(name)` | TIME |
206
- | `timestamp(name)` | TIMESTAMP |
207
- | `json(name)` | JSON |
208
- | `jsonb(name)` | JSONB (Postgres) |
209
- | `binary(name)` | BLOB / BYTEA |
210
- | `uuid(name)` | UUID |
211
- | `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 |
212
356
 
213
357
  ### Column Modifiers
214
358
 
215
359
  ```ts
216
- table.string("email").unique(); // UNIQUE index
217
- table.string("slug").index(); // INDEX
218
- table.string("name").nullable(); // NULLABLE
219
- 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
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"; // override table name
310
- static primaryKey = "sku"; // override primary key
311
- static timestamps = false; // disable timestamps
312
- 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
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; // property access
340
- user.name = "Charlie"; // property assignment
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(); // true if attributes changed
344
- user.getDirty(); // { name: "Charlie" }
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.toJSON(); // plain object
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; // "member"
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; // 1
391
- user.active; // true
392
- user.settings.theme; // "dark"
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 | Behavior |
398
- |------|----------|
399
- | `boolean`, `bool` | Stores `1` / `0`, reads boolean |
400
- | `number`, `integer`, `int`, `float`, `double` | Reads/writes numbers |
401
- | `decimal:2` | Stores fixed precision string |
402
- | `string` | Reads/writes string |
403
- | `date`, `datetime` | Reads as `Date`, stores ISO string for `Date` input |
404
- | `json`, `array`, `object` | Stores JSON string, reads parsed value |
405
- | `enum` | Stores enum `.value` when present |
406
- | `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 |
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(); // sets deleted_at
446
- await user.restore(); // clears deleted_at
447
- 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
448
788
 
449
- await User.all(); // excludes trashed rows
450
- await User.withTrashed().get(); // includes trashed rows
451
- 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
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); // foreignKey: user_id, localKey: id
829
+ return this.hasMany(Post); // foreignKey: user_id, localKey: id
490
830
  }
491
831
 
492
832
  profile() {
493
- return this.hasOne(Profile); // foreignKey: user_id, localKey: id
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); // foreignKey: user_id, ownerKey: id
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"); // reads commentable_type / commentable_id
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"; // stored in {name}_type column
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; // ✅ autocomplete + type-checking
823
- user.email = "a@example.com"; // ✅ typed setter
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 | Generated augmentation |
860
- |-------|------------------------|
861
- | `users` | `../User` / `User` |
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; // string
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
- 92 tests covering connection management, schema grammars, query builder, model CRUD, casts, scopes, soft deletes, relations, observers, migrations, and type generation.
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