@bunnykit/orm 0.1.0
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/LICENSE +21 -0
- package/README.md +904 -0
- package/dist/bin/bunny.d.ts +2 -0
- package/dist/bin/bunny.js +108 -0
- package/dist/src/connection/Connection.d.ts +13 -0
- package/dist/src/connection/Connection.js +49 -0
- package/dist/src/index.d.ts +20 -0
- package/dist/src/index.js +18 -0
- package/dist/src/migration/Migration.d.ts +4 -0
- package/dist/src/migration/Migration.js +2 -0
- package/dist/src/migration/MigrationCreator.d.ts +5 -0
- package/dist/src/migration/MigrationCreator.js +39 -0
- package/dist/src/migration/Migrator.d.ts +21 -0
- package/dist/src/migration/Migrator.js +137 -0
- package/dist/src/model/BelongsToMany.d.ts +27 -0
- package/dist/src/model/BelongsToMany.js +118 -0
- package/dist/src/model/Model.d.ts +166 -0
- package/dist/src/model/Model.js +763 -0
- package/dist/src/model/MorphMap.d.ts +7 -0
- package/dist/src/model/MorphMap.js +12 -0
- package/dist/src/model/MorphRelations.d.ts +81 -0
- package/dist/src/model/MorphRelations.js +296 -0
- package/dist/src/model/Observer.d.ts +19 -0
- package/dist/src/model/Observer.js +21 -0
- package/dist/src/query/Builder.d.ts +106 -0
- package/dist/src/query/Builder.js +466 -0
- package/dist/src/schema/Blueprint.d.ts +66 -0
- package/dist/src/schema/Blueprint.js +200 -0
- package/dist/src/schema/Schema.d.ts +16 -0
- package/dist/src/schema/Schema.js +135 -0
- package/dist/src/schema/grammars/Grammar.d.ts +26 -0
- package/dist/src/schema/grammars/Grammar.js +95 -0
- package/dist/src/schema/grammars/MySqlGrammar.d.ts +18 -0
- package/dist/src/schema/grammars/MySqlGrammar.js +96 -0
- package/dist/src/schema/grammars/PostgresGrammar.d.ts +16 -0
- package/dist/src/schema/grammars/PostgresGrammar.js +88 -0
- package/dist/src/schema/grammars/SQLiteGrammar.d.ts +16 -0
- package/dist/src/schema/grammars/SQLiteGrammar.js +108 -0
- package/dist/src/typegen/TypeGenerator.d.ts +29 -0
- package/dist/src/typegen/TypeGenerator.js +171 -0
- package/dist/src/typegen/TypeMapper.d.ts +4 -0
- package/dist/src/typegen/TypeMapper.js +27 -0
- package/dist/src/types/index.d.ts +53 -0
- package/dist/src/types/index.js +1 -0
- package/dist/src/utils.d.ts +1 -0
- package/dist/src/utils.js +6 -0
- package/package.json +62 -0
package/README.md
ADDED
|
@@ -0,0 +1,904 @@
|
|
|
1
|
+
# Bunny
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- 🔥 **Bun-native** — Built on top of `bun:sql` for maximum performance
|
|
10
|
+
- 📦 **Multi-database** — SQLite, MySQL, and PostgreSQL support
|
|
11
|
+
- 🔷 **Fully Typed** — Written in TypeScript with generics everywhere
|
|
12
|
+
- 🏗️ **Schema Builder** — Programmatic table creation, indexes, foreign keys
|
|
13
|
+
- 🔍 **Query Builder** — Chainable `where`, `join`, `orderBy`, `groupBy`, etc.
|
|
14
|
+
- 🧬 **Eloquent-style Models** — Property attributes, defaults, casts, dirty tracking, soft deletes, scopes
|
|
15
|
+
- 🔗 **Relations** — Standard, many-to-many, polymorphic, through, one-of-many, and relation queries
|
|
16
|
+
- 👁️ **Observers** — Lifecycle hooks (`creating`, `created`, `updating`, `updated`, etc.)
|
|
17
|
+
- 🚀 **Migrations & CLI** — Create, run, and rollback migrations from the command line
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
bun add @bunnykit/orm
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
> **Note:** This package is Bun-only. Install and run it with Bun >= 1.1; npm, yarn, pnpm, and Node.js runtime usage are not supported.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Configuration
|
|
32
|
+
|
|
33
|
+
Create a `bunny.config.ts` (or `.js`) in your project root:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
export default {
|
|
37
|
+
connection: {
|
|
38
|
+
// Option 1: connection string
|
|
39
|
+
url: "sqlite://app.db",
|
|
40
|
+
|
|
41
|
+
// Option 2: driver config (for MySQL / Postgres)
|
|
42
|
+
// driver: "mysql",
|
|
43
|
+
// host: "localhost",
|
|
44
|
+
// port: 3306,
|
|
45
|
+
// database: "mydb",
|
|
46
|
+
// username: "root",
|
|
47
|
+
// password: "secret",
|
|
48
|
+
},
|
|
49
|
+
migrationsPath: "./database/migrations",
|
|
50
|
+
};
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Or use environment variables:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
export DATABASE_URL="sqlite://app.db"
|
|
57
|
+
export MIGRATIONS_PATH="./database/migrations"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Quick Start
|
|
63
|
+
|
|
64
|
+
### Define a Model
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { Model } from "@bunnykit/orm";
|
|
68
|
+
|
|
69
|
+
class User extends Model {
|
|
70
|
+
// Optional — inferred as "users" if omitted
|
|
71
|
+
static table = "users";
|
|
72
|
+
|
|
73
|
+
posts() {
|
|
74
|
+
return this.hasMany(Post);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
class Post extends Model {
|
|
79
|
+
static table = "posts";
|
|
80
|
+
|
|
81
|
+
author() {
|
|
82
|
+
return this.belongsTo(User);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Set the Database Connection
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { Connection, Model, Schema } from "@bunnykit/orm";
|
|
91
|
+
|
|
92
|
+
const connection = new Connection({ url: "sqlite://app.db" });
|
|
93
|
+
Model.setConnection(connection);
|
|
94
|
+
Schema.setConnection(connection);
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Create Tables
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
import { Schema } from "@bunnykit/orm";
|
|
101
|
+
|
|
102
|
+
await Schema.create("users", (table) => {
|
|
103
|
+
table.increments("id");
|
|
104
|
+
table.string("name");
|
|
105
|
+
table.string("email").unique();
|
|
106
|
+
table.timestamps(); // created_at & updated_at
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await Schema.create("posts", (table) => {
|
|
110
|
+
table.increments("id");
|
|
111
|
+
table.integer("user_id").unsigned();
|
|
112
|
+
table.string("title");
|
|
113
|
+
table.text("body").nullable();
|
|
114
|
+
table.timestamps();
|
|
115
|
+
});
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### CRUD Operations
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
// Create
|
|
122
|
+
const user = await User.create({ name: "Alice", email: "alice@example.com" });
|
|
123
|
+
|
|
124
|
+
// Find
|
|
125
|
+
const found = await User.find(1);
|
|
126
|
+
|
|
127
|
+
// Query
|
|
128
|
+
const adults = await User.where("age", ">=", 18)
|
|
129
|
+
.orderBy("name")
|
|
130
|
+
.get();
|
|
131
|
+
|
|
132
|
+
// Update
|
|
133
|
+
user.name = "Alice Smith";
|
|
134
|
+
await user.save();
|
|
135
|
+
|
|
136
|
+
// Delete
|
|
137
|
+
await user.delete();
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Schema Builder
|
|
143
|
+
|
|
144
|
+
### Creating Tables
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
await Schema.create("products", (table) => {
|
|
148
|
+
table.increments("id");
|
|
149
|
+
table.uuid("uuid").unique();
|
|
150
|
+
table.string("name", 100);
|
|
151
|
+
table.text("description").nullable();
|
|
152
|
+
table.integer("stock").unsigned().default(0);
|
|
153
|
+
table.decimal("price", 10, 2);
|
|
154
|
+
table.boolean("active").default(true);
|
|
155
|
+
table.json("metadata").nullable();
|
|
156
|
+
table.timestamps();
|
|
157
|
+
table.softDeletes(); // deleted_at
|
|
158
|
+
});
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Available Column Types
|
|
162
|
+
|
|
163
|
+
| Method | Description |
|
|
164
|
+
|--------|-------------|
|
|
165
|
+
| `increments(name)` | Auto-incrementing integer primary key |
|
|
166
|
+
| `bigIncrements(name)` | Auto-incrementing big integer |
|
|
167
|
+
| `string(name, length=255)` | VARCHAR |
|
|
168
|
+
| `text(name)` | TEXT |
|
|
169
|
+
| `integer(name)` | INTEGER |
|
|
170
|
+
| `bigInteger(name)` | BIGINT |
|
|
171
|
+
| `smallInteger(name)` | SMALLINT |
|
|
172
|
+
| `tinyInteger(name)` | TINYINT |
|
|
173
|
+
| `float(name, p=8, s=2)` | FLOAT |
|
|
174
|
+
| `double(name, p=8, s=2)` | DOUBLE |
|
|
175
|
+
| `decimal(name, p=8, s=2)` | DECIMAL |
|
|
176
|
+
| `boolean(name)` | BOOLEAN |
|
|
177
|
+
| `date(name)` | DATE |
|
|
178
|
+
| `dateTime(name)` | DATETIME |
|
|
179
|
+
| `time(name)` | TIME |
|
|
180
|
+
| `timestamp(name)` | TIMESTAMP |
|
|
181
|
+
| `json(name)` | JSON |
|
|
182
|
+
| `jsonb(name)` | JSONB (Postgres) |
|
|
183
|
+
| `binary(name)` | BLOB / BYTEA |
|
|
184
|
+
| `uuid(name)` | UUID |
|
|
185
|
+
| `enum(name, values)` | ENUM |
|
|
186
|
+
|
|
187
|
+
### Column Modifiers
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
table.string("email").unique(); // UNIQUE index
|
|
191
|
+
table.string("slug").index(); // INDEX
|
|
192
|
+
table.string("name").nullable(); // NULLABLE
|
|
193
|
+
table.integer("role").default(1); // DEFAULT value
|
|
194
|
+
table.string("code").comment("SKU code");
|
|
195
|
+
table.integer("user_id").unsigned();
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Altering Tables
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
// Add columns
|
|
202
|
+
await Schema.table("users", (table) => {
|
|
203
|
+
table.string("phone").nullable();
|
|
204
|
+
table.timestamp("last_login").nullable();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Rename
|
|
208
|
+
await Schema.rename("users", "customers");
|
|
209
|
+
|
|
210
|
+
// Drop
|
|
211
|
+
await Schema.drop("old_table");
|
|
212
|
+
await Schema.dropIfExists("old_table");
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Foreign Keys
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
await Schema.create("posts", (table) => {
|
|
219
|
+
table.increments("id");
|
|
220
|
+
table.integer("user_id").unsigned();
|
|
221
|
+
table.foreign("user_id").references("id").on("users").onDelete("cascade");
|
|
222
|
+
});
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## Query Builder
|
|
228
|
+
|
|
229
|
+
Every model exposes a query builder via static methods:
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
// Static entry points
|
|
233
|
+
User.where("active", true);
|
|
234
|
+
User.where({ role: "admin", active: true });
|
|
235
|
+
User.whereIn("id", [1, 2, 3]);
|
|
236
|
+
User.whereNull("deleted_at");
|
|
237
|
+
User.whereNotNull("email");
|
|
238
|
+
|
|
239
|
+
// Chaining
|
|
240
|
+
const results = await User
|
|
241
|
+
.where("age", ">=", 18)
|
|
242
|
+
.whereIn("role", ["admin", "moderator"])
|
|
243
|
+
.orderBy("created_at", "desc")
|
|
244
|
+
.limit(10)
|
|
245
|
+
.offset(0)
|
|
246
|
+
.get();
|
|
247
|
+
|
|
248
|
+
// Aggregates
|
|
249
|
+
const count = await User.where("active", true).count();
|
|
250
|
+
const exists = await User.where("email", "test@example.com").exists();
|
|
251
|
+
|
|
252
|
+
// Joins
|
|
253
|
+
const posts = await Post
|
|
254
|
+
.query()
|
|
255
|
+
.select("posts.*", "users.name as author_name")
|
|
256
|
+
.join("users", "posts.user_id", "=", "users.id")
|
|
257
|
+
.get();
|
|
258
|
+
|
|
259
|
+
// Pluck
|
|
260
|
+
const emails = await User.pluck("email");
|
|
261
|
+
|
|
262
|
+
// First / Find
|
|
263
|
+
const user = await User.where("email", "alice@example.com").first();
|
|
264
|
+
const byId = await User.find(1);
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## Models
|
|
270
|
+
|
|
271
|
+
### Conventions
|
|
272
|
+
|
|
273
|
+
- **Table name**: inferred from the class name in `snake_case` + plural `s`.
|
|
274
|
+
- `class User` → table `users`
|
|
275
|
+
- `class BlogPost` → table `blog_posts`
|
|
276
|
+
- **Primary key**: defaults to `id`
|
|
277
|
+
- **Timestamps**: `created_at` and `updated_at` are managed automatically (disable with `static timestamps = false`)
|
|
278
|
+
|
|
279
|
+
### Defining Models
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
class Product extends Model {
|
|
283
|
+
static table = "products"; // override table name
|
|
284
|
+
static primaryKey = "sku"; // override primary key
|
|
285
|
+
static timestamps = false; // disable timestamps
|
|
286
|
+
static softDeletes = true; // use deleted_at instead of hard deletes
|
|
287
|
+
|
|
288
|
+
static attributes = {
|
|
289
|
+
active: true,
|
|
290
|
+
status: "draft",
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
static casts = {
|
|
294
|
+
active: "boolean",
|
|
295
|
+
price: "decimal:2",
|
|
296
|
+
metadata: "json",
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
### Model Methods
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
// Static
|
|
305
|
+
const all = await User.all();
|
|
306
|
+
const user = await User.create({ name: "Alice" });
|
|
307
|
+
const found = await User.find(1);
|
|
308
|
+
const first = await User.first();
|
|
309
|
+
const builder = User.where("active", true);
|
|
310
|
+
|
|
311
|
+
// Instance
|
|
312
|
+
user.fill({ name: "Bob", email: "bob@example.com" });
|
|
313
|
+
user.name; // property access
|
|
314
|
+
user.name = "Charlie"; // property assignment
|
|
315
|
+
user.getAttribute("name"); // explicit access still works
|
|
316
|
+
user.setAttribute("name", "Dana");
|
|
317
|
+
user.isDirty(); // true if attributes changed
|
|
318
|
+
user.getDirty(); // { name: "Charlie" }
|
|
319
|
+
await user.save();
|
|
320
|
+
await user.delete();
|
|
321
|
+
await user.refresh();
|
|
322
|
+
user.toJSON(); // plain object
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
### Default Attributes
|
|
326
|
+
|
|
327
|
+
Use `static attributes` to give new model instances in-memory defaults before saving:
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
class User extends Model {
|
|
331
|
+
static attributes = {
|
|
332
|
+
active: true,
|
|
333
|
+
role: "member",
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const user = new User({ name: "Ada" });
|
|
338
|
+
user.active; // true
|
|
339
|
+
user.role; // "member"
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
These are model defaults, not database defaults. Values provided by the caller override them.
|
|
343
|
+
|
|
344
|
+
### Attribute Casting
|
|
345
|
+
|
|
346
|
+
`static casts` transforms values on read and serializes them on write:
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
class User extends Model {
|
|
350
|
+
static casts = {
|
|
351
|
+
active: "boolean",
|
|
352
|
+
login_count: "integer",
|
|
353
|
+
price: "decimal:2",
|
|
354
|
+
settings: "json",
|
|
355
|
+
secret: "encrypted",
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const user = new User({
|
|
360
|
+
active: true,
|
|
361
|
+
settings: { theme: "dark" },
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
user.$attributes.active; // 1
|
|
365
|
+
user.active; // true
|
|
366
|
+
user.settings.theme; // "dark"
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
Supported built-in casts:
|
|
370
|
+
|
|
371
|
+
| Cast | Behavior |
|
|
372
|
+
|------|----------|
|
|
373
|
+
| `boolean`, `bool` | Stores `1` / `0`, reads boolean |
|
|
374
|
+
| `number`, `integer`, `int`, `float`, `double` | Reads/writes numbers |
|
|
375
|
+
| `decimal:2` | Stores fixed precision string |
|
|
376
|
+
| `string` | Reads/writes string |
|
|
377
|
+
| `date`, `datetime` | Reads as `Date`, stores ISO string for `Date` input |
|
|
378
|
+
| `json`, `array`, `object` | Stores JSON string, reads parsed value |
|
|
379
|
+
| `enum` | Stores enum `.value` when present |
|
|
380
|
+
| `encrypted` | Base64 encodes on write and decodes on read |
|
|
381
|
+
|
|
382
|
+
Custom casts can implement `CastsAttributes`:
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
import type { CastsAttributes, Model } from "@bunnykit/orm";
|
|
386
|
+
|
|
387
|
+
class UppercaseCast implements CastsAttributes {
|
|
388
|
+
get(_model: Model, _key: string, value: unknown) {
|
|
389
|
+
return String(value).toLowerCase();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
set(_model: Model, _key: string, value: unknown) {
|
|
393
|
+
return String(value).toUpperCase();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
class User extends Model {
|
|
398
|
+
static casts = {
|
|
399
|
+
code: UppercaseCast,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
You can also add instance-only casts at runtime:
|
|
405
|
+
|
|
406
|
+
```ts
|
|
407
|
+
user.mergeCasts({ count: "string" });
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
### Soft Deletes
|
|
411
|
+
|
|
412
|
+
Enable soft deletes with `static softDeletes = true` and a `deleted_at` column:
|
|
413
|
+
|
|
414
|
+
```ts
|
|
415
|
+
class User extends Model {
|
|
416
|
+
static softDeletes = true;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
await user.delete(); // sets deleted_at
|
|
420
|
+
await user.restore(); // clears deleted_at
|
|
421
|
+
await user.forceDelete(); // permanently deletes
|
|
422
|
+
|
|
423
|
+
await User.all(); // excludes trashed rows
|
|
424
|
+
await User.withTrashed().get(); // includes trashed rows
|
|
425
|
+
await User.onlyTrashed().get(); // only trashed rows
|
|
426
|
+
await User.onlyTrashed().restore();
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
### Scopes
|
|
430
|
+
|
|
431
|
+
Local scopes are static methods named `scopeName`:
|
|
432
|
+
|
|
433
|
+
```ts
|
|
434
|
+
class User extends Model {
|
|
435
|
+
static scopeActive(query) {
|
|
436
|
+
return query.where("active", true);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const users = await User.scope("active").get();
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
Global scopes apply automatically to all queries:
|
|
444
|
+
|
|
445
|
+
```ts
|
|
446
|
+
User.addGlobalScope("tenant", (query) => {
|
|
447
|
+
query.where("tenant_id", 1);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
await User.withoutGlobalScope("tenant").get();
|
|
451
|
+
await User.withoutGlobalScopes().get();
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
---
|
|
455
|
+
|
|
456
|
+
## Relationships
|
|
457
|
+
|
|
458
|
+
### Standard Relations
|
|
459
|
+
|
|
460
|
+
```ts
|
|
461
|
+
class User extends Model {
|
|
462
|
+
posts() {
|
|
463
|
+
return this.hasMany(Post); // foreignKey: user_id, localKey: id
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
profile() {
|
|
467
|
+
return this.hasOne(Profile); // foreignKey: user_id, localKey: id
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
class Post extends Model {
|
|
472
|
+
author() {
|
|
473
|
+
return this.belongsTo(User); // foreignKey: user_id, ownerKey: id
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
Keys are **automatically inferred** from the model names. You can override them:
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
this.hasMany(Post, "author_id", "uuid");
|
|
482
|
+
this.belongsTo(User, "author_id", "uuid");
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
### Belongs To Helpers
|
|
486
|
+
|
|
487
|
+
Use `associate` and `dissociate` to update the foreign key for a `belongsTo` relation:
|
|
488
|
+
|
|
489
|
+
```ts
|
|
490
|
+
const post = new Post({ title: "Draft" });
|
|
491
|
+
post.author().associate(user);
|
|
492
|
+
post.user_id; // user's id
|
|
493
|
+
|
|
494
|
+
post.author().dissociate();
|
|
495
|
+
post.user_id; // null
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
### Through Relations
|
|
499
|
+
|
|
500
|
+
Use `hasManyThrough` and `hasOneThrough` for distant relations through an intermediate model:
|
|
501
|
+
|
|
502
|
+
```ts
|
|
503
|
+
class Country extends Model {
|
|
504
|
+
posts() {
|
|
505
|
+
return this.hasManyThrough(Post, User);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
profile() {
|
|
509
|
+
return this.hasOneThrough(Profile, User);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
By convention, Bunny expects:
|
|
515
|
+
|
|
516
|
+
- intermediate table foreign key to parent: `country_id`
|
|
517
|
+
- final table foreign key to intermediate: `user_id`
|
|
518
|
+
|
|
519
|
+
You can override keys:
|
|
520
|
+
|
|
521
|
+
```ts
|
|
522
|
+
this.hasManyThrough(Post, User, "country_uuid", "author_id", "uuid", "id");
|
|
523
|
+
this.hasOneThrough(Profile, User, "country_id", "user_id");
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
### One-of-Many Relations
|
|
527
|
+
|
|
528
|
+
Convert a `hasMany` relation into a single latest, oldest, or aggregate-selected relation:
|
|
529
|
+
|
|
530
|
+
```ts
|
|
531
|
+
class User extends Model {
|
|
532
|
+
posts() {
|
|
533
|
+
return this.hasMany(Post);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
latestPost() {
|
|
537
|
+
return this.posts().latestOfMany("id");
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
oldestPost() {
|
|
541
|
+
return this.posts().oldestOfMany("id");
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
highestScoringPost() {
|
|
545
|
+
return this.posts().ofMany("score", "max");
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const post = await user.latestPost().getResults();
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
### Relation Queries and Aggregates
|
|
553
|
+
|
|
554
|
+
Filter models by related records:
|
|
555
|
+
|
|
556
|
+
```ts
|
|
557
|
+
const usersWithPosts = await User.has("posts").get();
|
|
558
|
+
|
|
559
|
+
const usersWithPublishedPosts = await User.whereHas("posts", (query) => {
|
|
560
|
+
query.where("status", "published");
|
|
561
|
+
}).get();
|
|
562
|
+
|
|
563
|
+
const usersWithoutPosts = await User.doesntHave("posts").get();
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
Add relation aggregate columns:
|
|
567
|
+
|
|
568
|
+
```ts
|
|
569
|
+
const users = await User
|
|
570
|
+
.withCount("posts")
|
|
571
|
+
.withSum("posts", "views")
|
|
572
|
+
.withAvg("posts", "score")
|
|
573
|
+
.withMin("posts", "created_at")
|
|
574
|
+
.withMax("posts", "created_at")
|
|
575
|
+
.get();
|
|
576
|
+
|
|
577
|
+
users[0].posts_count;
|
|
578
|
+
users[0].posts_sum_views;
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
### Polymorphic Relations
|
|
582
|
+
|
|
583
|
+
```ts
|
|
584
|
+
import { Model, MorphMap } from "@bunnykit/orm";
|
|
585
|
+
|
|
586
|
+
// Register morph types so morphTo knows which model to instantiate
|
|
587
|
+
MorphMap.register("Post", Post);
|
|
588
|
+
MorphMap.register("Video", Video);
|
|
589
|
+
|
|
590
|
+
class Comment extends Model {
|
|
591
|
+
commentable() {
|
|
592
|
+
return this.morphTo("commentable"); // reads commentable_type / commentable_id
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
class Post extends Model {
|
|
597
|
+
comments() {
|
|
598
|
+
return this.morphMany(Comment, "commentable");
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
class Video extends Model {
|
|
603
|
+
comments() {
|
|
604
|
+
return this.morphMany(Comment, "commentable");
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
thumbnail() {
|
|
608
|
+
return this.morphOne(Image, "imageable");
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
### Many-to-Many Polymorphic
|
|
614
|
+
|
|
615
|
+
```ts
|
|
616
|
+
class Post extends Model {
|
|
617
|
+
tags() {
|
|
618
|
+
return this.morphToMany(Tag, "taggable");
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
class Tag extends Model {
|
|
623
|
+
posts() {
|
|
624
|
+
return this.morphedByMany(Post, "taggable");
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
Pivot table: `taggables(tag_id, taggable_id, taggable_type)`.
|
|
630
|
+
|
|
631
|
+
### Customizing Morph Type
|
|
632
|
+
|
|
633
|
+
```ts
|
|
634
|
+
class Post extends Model {
|
|
635
|
+
static morphName = "post"; // stored in {name}_type column
|
|
636
|
+
}
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
---
|
|
640
|
+
|
|
641
|
+
## Observers
|
|
642
|
+
|
|
643
|
+
Register observers to hook into model lifecycle events:
|
|
644
|
+
|
|
645
|
+
```ts
|
|
646
|
+
import { ObserverRegistry } from "@bunnykit/orm";
|
|
647
|
+
|
|
648
|
+
ObserverRegistry.register(User, {
|
|
649
|
+
async creating(model) {
|
|
650
|
+
console.log("About to create:", model.getAttribute("email"));
|
|
651
|
+
},
|
|
652
|
+
async created(model) {
|
|
653
|
+
console.log("Created user id:", model.getAttribute("id"));
|
|
654
|
+
},
|
|
655
|
+
async updating(model) {
|
|
656
|
+
console.log("User is changing:", model.getDirty());
|
|
657
|
+
},
|
|
658
|
+
async updated(model) {
|
|
659
|
+
// ...
|
|
660
|
+
},
|
|
661
|
+
async saving(model) {
|
|
662
|
+
// Runs before both create and update
|
|
663
|
+
},
|
|
664
|
+
async saved(model) {
|
|
665
|
+
// Runs after both create and update
|
|
666
|
+
},
|
|
667
|
+
async deleting(model) {
|
|
668
|
+
// ...
|
|
669
|
+
},
|
|
670
|
+
async deleted(model) {
|
|
671
|
+
// ...
|
|
672
|
+
},
|
|
673
|
+
});
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
---
|
|
677
|
+
|
|
678
|
+
## Migrations
|
|
679
|
+
|
|
680
|
+
### CLI Commands
|
|
681
|
+
|
|
682
|
+
```bash
|
|
683
|
+
# Create a new migration file
|
|
684
|
+
bun run bunny migrate:make CreateUsersTable
|
|
685
|
+
|
|
686
|
+
# Run all pending migrations
|
|
687
|
+
bun run bunny migrate
|
|
688
|
+
|
|
689
|
+
# Rollback the last batch
|
|
690
|
+
bun run bunny migrate:rollback
|
|
691
|
+
|
|
692
|
+
# Show migration status
|
|
693
|
+
bun run bunny migrate:status
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
### Migration File Structure
|
|
697
|
+
|
|
698
|
+
```ts
|
|
699
|
+
import { Migration, Schema } from "@bunnykit/orm";
|
|
700
|
+
|
|
701
|
+
export default class CreateUsersTable extends Migration {
|
|
702
|
+
async up(): Promise<void> {
|
|
703
|
+
await Schema.create("users", (table) => {
|
|
704
|
+
table.increments("id");
|
|
705
|
+
table.string("name");
|
|
706
|
+
table.string("email").unique();
|
|
707
|
+
table.timestamps();
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
async down(): Promise<void> {
|
|
712
|
+
await Schema.dropIfExists("users");
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
Migrations are tracked in a `migrations` table (auto-created on first run).
|
|
718
|
+
|
|
719
|
+
### Auto Type Generation
|
|
720
|
+
|
|
721
|
+
If you set `typesOutDir` in your config, types are **automatically regenerated** after every `migrate` and `migrate:rollback`:
|
|
722
|
+
|
|
723
|
+
```bash
|
|
724
|
+
bun run bunny migrate
|
|
725
|
+
# → Migrated: 2026xxxx_create_users_table.ts
|
|
726
|
+
# → Regenerated types in ./src/generated/models
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
No extra step needed — your models stay in sync with the schema automatically.
|
|
730
|
+
|
|
731
|
+
---
|
|
732
|
+
|
|
733
|
+
## Using as a Library (Programmatic Migrations)
|
|
734
|
+
|
|
735
|
+
```ts
|
|
736
|
+
import { Connection, Migrator, MigrationCreator } from "@bunnykit/orm";
|
|
737
|
+
|
|
738
|
+
const connection = new Connection({ url: "sqlite://app.db" });
|
|
739
|
+
|
|
740
|
+
// Create a migration file
|
|
741
|
+
const creator = new MigrationCreator();
|
|
742
|
+
const path = await creator.create("CreateOrdersTable", "./database/migrations");
|
|
743
|
+
|
|
744
|
+
// Run migrations
|
|
745
|
+
const migrator = new Migrator(connection, "./database/migrations");
|
|
746
|
+
await migrator.run();
|
|
747
|
+
|
|
748
|
+
// Rollback
|
|
749
|
+
await migrator.rollback();
|
|
750
|
+
```
|
|
751
|
+
|
|
752
|
+
---
|
|
753
|
+
|
|
754
|
+
## TypeScript Tips
|
|
755
|
+
|
|
756
|
+
### Typing Model Attributes
|
|
757
|
+
|
|
758
|
+
```ts
|
|
759
|
+
interface UserAttributes {
|
|
760
|
+
id: number;
|
|
761
|
+
name: string;
|
|
762
|
+
email: string;
|
|
763
|
+
created_at: string;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
class User extends Model {
|
|
767
|
+
static table = "users";
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Type inference works automatically on static methods
|
|
771
|
+
const user = await User.create({ name: "Alice", email: "a@example.com" });
|
|
772
|
+
// user is typed as User
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
### Query Builder Typing
|
|
776
|
+
|
|
777
|
+
```ts
|
|
778
|
+
// Builder<T> is inferred from the model class
|
|
779
|
+
const builder = User.where("name", "Alice"); // Builder<User>
|
|
780
|
+
const users: User[] = await builder.get();
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
---
|
|
784
|
+
|
|
785
|
+
## Type Generation (IntelliSense)
|
|
786
|
+
|
|
787
|
+
Bunny can introspect your database schema and generate TypeScript declaration files for your existing models. This gives you **full IntelliSense** for model properties without changing your model source files:
|
|
788
|
+
|
|
789
|
+
```ts
|
|
790
|
+
const user = await User.first();
|
|
791
|
+
user.name; // ✅ autocomplete + type-checking
|
|
792
|
+
user.email = "a@example.com"; // ✅ typed setter
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
### Generate Types
|
|
796
|
+
|
|
797
|
+
```bash
|
|
798
|
+
# Generate into default directory (./generated/models)
|
|
799
|
+
bun run bunny types:generate
|
|
800
|
+
|
|
801
|
+
# Generate into a custom directory
|
|
802
|
+
bun run bunny types:generate ./src/generated
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
Or configure in `bunny.config.ts`:
|
|
806
|
+
|
|
807
|
+
```ts
|
|
808
|
+
export default {
|
|
809
|
+
connection: { url: "sqlite://app.db" },
|
|
810
|
+
migrationsPath: "./database/migrations",
|
|
811
|
+
typesOutDir: "./src/generated/model-types", // auto-regenerate .d.ts files on every migration
|
|
812
|
+
typeDeclarationImportPrefix: "../models",
|
|
813
|
+
// Optional overrides for non-conventional model names or paths:
|
|
814
|
+
typeDeclarations: {
|
|
815
|
+
admin_users: { path: "../models/AdminAccount", className: "AdminAccount" },
|
|
816
|
+
},
|
|
817
|
+
};
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
With `typeDeclarationImportPrefix`, Bunny conventionally maps tables to singular PascalCase model modules:
|
|
821
|
+
|
|
822
|
+
| Table | Generated augmentation |
|
|
823
|
+
|-------|------------------------|
|
|
824
|
+
| `users` | `../models/User` / `User` |
|
|
825
|
+
| `blog_posts` | `../models/BlogPost` / `BlogPost` |
|
|
826
|
+
| `categories` | `../models/Category` / `Category` |
|
|
827
|
+
|
|
828
|
+
Set `typeDeclarationSingularModels: false` if your model classes use plural names.
|
|
829
|
+
|
|
830
|
+
### Using Generated Declarations
|
|
831
|
+
|
|
832
|
+
For each table, Bunny generates an `Attributes` interface. If you configure `typeDeclarations`, it also augments your real model class:
|
|
833
|
+
|
|
834
|
+
```ts
|
|
835
|
+
// generated/model-types/users.d.ts
|
|
836
|
+
export interface UsersAttributes {
|
|
837
|
+
id: number;
|
|
838
|
+
name: string;
|
|
839
|
+
email: string | null;
|
|
840
|
+
created_at: string;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
declare module "../models/User" {
|
|
844
|
+
interface User extends UsersAttributes {}
|
|
845
|
+
}
|
|
846
|
+
```
|
|
847
|
+
|
|
848
|
+
Your actual model stays hand-written:
|
|
849
|
+
|
|
850
|
+
```ts
|
|
851
|
+
// models/User.ts
|
|
852
|
+
import { Model } from "@bunnykit/orm";
|
|
853
|
+
|
|
854
|
+
export class User extends Model {
|
|
855
|
+
static table = "users";
|
|
856
|
+
|
|
857
|
+
posts() {
|
|
858
|
+
return this.hasMany(Post);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
Editors that include the generated `.d.ts` files in `tsconfig.json` will understand `user.name`, `user.email`, etc. The generated files can be safely **gitignored** and regenerated whenever your schema changes.
|
|
864
|
+
|
|
865
|
+
If you still want generated base classes, use the programmatic generator with `{ stubs: true }`.
|
|
866
|
+
|
|
867
|
+
### Manual Typing (Without Codegen)
|
|
868
|
+
|
|
869
|
+
If you prefer not to use codegen, you can pass a type parameter directly:
|
|
870
|
+
|
|
871
|
+
```ts
|
|
872
|
+
interface UserAttributes {
|
|
873
|
+
id: number;
|
|
874
|
+
name: string;
|
|
875
|
+
email: string;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
class User extends Model<UserAttributes> {
|
|
879
|
+
static table = "users";
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// $attributes and getAttribute are now typed
|
|
883
|
+
const user = await User.first();
|
|
884
|
+
user.getAttribute("name"); // string
|
|
885
|
+
user.$attributes.email; // string
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
---
|
|
889
|
+
|
|
890
|
+
## Testing
|
|
891
|
+
|
|
892
|
+
Bunny includes a full test suite built with `bun:test`.
|
|
893
|
+
|
|
894
|
+
```bash
|
|
895
|
+
bun test
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
92 tests covering connection management, schema grammars, query builder, model CRUD, casts, scopes, soft deletes, relations, observers, migrations, and type generation.
|
|
899
|
+
|
|
900
|
+
---
|
|
901
|
+
|
|
902
|
+
## License
|
|
903
|
+
|
|
904
|
+
MIT
|