@zerotal/orm 1.0.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/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/package.json +58 -0
- package/src/casts/Cast.ts +200 -0
- package/src/commands/DbSeedCommand.ts +71 -0
- package/src/commands/MakeFactoryCommand.ts +59 -0
- package/src/commands/MakeMigrationCommand.ts +109 -0
- package/src/commands/MakeModelCommand.ts +83 -0
- package/src/commands/MakeSeederCommand.ts +50 -0
- package/src/commands/MigrateCommand.ts +60 -0
- package/src/commands/MigrateFreshCommand.ts +41 -0
- package/src/commands/MigrateGenerateCommand.ts +110 -0
- package/src/commands/MigrateRollbackCommand.ts +43 -0
- package/src/commands/MigrateStatusCommand.ts +49 -0
- package/src/commands/_loadMigrations.ts +34 -0
- package/src/commands/index.ts +30 -0
- package/src/config.ts +182 -0
- package/src/conventions.ts +67 -0
- package/src/db/DB.ts +486 -0
- package/src/db/NPlusOneDetector.ts +176 -0
- package/src/db/QueryBuilder.ts +2458 -0
- package/src/db/ReadWriteRouter.ts +96 -0
- package/src/db/TransactionContext.ts +13 -0
- package/src/db/dialects/MysqlDialect.ts +57 -0
- package/src/db/dialects/PostgresDialect.ts +55 -0
- package/src/db/dialects/SqliteDialect.ts +54 -0
- package/src/db/dialects/index.ts +25 -0
- package/src/db/dialects/types.ts +67 -0
- package/src/db/resolver.ts +30 -0
- package/src/db/sql-types.ts +12 -0
- package/src/db/types.ts +296 -0
- package/src/errors/MassAssignmentError.ts +25 -0
- package/src/errors/MigrationError.ts +18 -0
- package/src/errors/ModelNotFoundError.ts +21 -0
- package/src/errors/NPlusOneError.ts +6 -0
- package/src/errors/RelationNotLoadedError.ts +19 -0
- package/src/errors/StateError.ts +18 -0
- package/src/errors/TransactionError.ts +13 -0
- package/src/errors/UnsupportedDialectError.ts +18 -0
- package/src/errors/index.ts +7 -0
- package/src/events.ts +112 -0
- package/src/global.d.ts +17 -0
- package/src/implicitBinding.ts +73 -0
- package/src/index.ts +255 -0
- package/src/model/BaseModel.ts +2499 -0
- package/src/model/ModelQueryBuilder.ts +1808 -0
- package/src/model/Observer.ts +73 -0
- package/src/model/OrmContext.ts +71 -0
- package/src/model/ReactiveProxy.ts +53 -0
- package/src/model/SoftDeletes.ts +108 -0
- package/src/model/State.ts +290 -0
- package/src/model/decorators/_metadata.ts +211 -0
- package/src/model/decorators/_registerRelation.ts +20 -0
- package/src/model/decorators/belongsTo.ts +38 -0
- package/src/model/decorators/column.ts +278 -0
- package/src/model/decorators/hasMany.ts +34 -0
- package/src/model/decorators/hasManyThrough.ts +50 -0
- package/src/model/decorators/hasOne.ts +34 -0
- package/src/model/decorators/hasOneThrough.ts +40 -0
- package/src/model/decorators/manyToMany.ts +55 -0
- package/src/model/decorators/morphMany.ts +38 -0
- package/src/model/decorators/morphOne.ts +38 -0
- package/src/model/decorators/morphTo.ts +51 -0
- package/src/model/decorators/morphToMany.ts +49 -0
- package/src/model/decorators/morphedByMany.ts +46 -0
- package/src/model/decorators/table.ts +124 -0
- package/src/model/hooks/HookRegistry.ts +110 -0
- package/src/model/mixins.ts +536 -0
- package/src/model/payload.ts +114 -0
- package/src/model/relations/RelationRegistry.ts +184 -0
- package/src/observability.ts +210 -0
- package/src/provider/DatabaseProvider.ts +266 -0
- package/src/schema/Blueprint.ts +900 -0
- package/src/schema/ColumnDefinition.ts +517 -0
- package/src/schema/Migration.ts +34 -0
- package/src/schema/MigrationCodegen.ts +108 -0
- package/src/schema/MigrationRunner.ts +351 -0
- package/src/schema/ModelInspector.ts +133 -0
- package/src/schema/Schema.ts +140 -0
- package/src/schema/SchemaDiffer.ts +137 -0
- package/src/schema/SchemaInspector.ts +164 -0
- package/src/schema/__test_migrations__/001_create_test_table.ts +15 -0
- package/src/schema/autoMigrate.ts +154 -0
- package/src/schema/index.ts +28 -0
- package/src/seeding/Seeder.ts +46 -0
- package/src/support/identifiers.ts +62 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog — @zerotal/orm
|
|
2
|
+
|
|
3
|
+
All notable changes to this package are documented here. The format is
|
|
4
|
+
based on [Keep a Changelog](https://keepachangelog.com/); this package
|
|
5
|
+
follows the Zerotal monorepo's unified versioning.
|
|
6
|
+
|
|
7
|
+
**Maturity: `stable`**
|
|
8
|
+
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
## [1.0.0] — 2026-08-05
|
|
12
|
+
|
|
13
|
+
_First public release._
|
|
14
|
+
|
|
15
|
+
### Notes
|
|
16
|
+
|
|
17
|
+
- Conforms to the Zerotal package conventions (provider in `src/provider/`, PascalCase config factory, `ZerotalError`-based errors, test coverage).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zerotal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# @zerotal/orm
|
|
2
|
+
|
|
3
|
+
> Active Record ORM for Bun — models, migrations, and a fluent query builder on top of `Bun.sql`.
|
|
4
|
+
|
|
5
|
+
`@zerotal/orm` maps TypeScript classes to database tables: declare columns with decorators, define relationships, and read/write data through a chainable query builder. It supports SQLite, PostgreSQL, and MySQL with the same model code, plus migrations, soft deletes, eager loading, and pagination.
|
|
6
|
+
|
|
7
|
+
Part of the [Zerotal](../../README.md) framework. Requires **Bun ≥ 1.3.14**.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bun add @zerotal/orm
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Setup
|
|
16
|
+
|
|
17
|
+
Register the provider in `bootstrap/providers.ts`:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { DatabaseProvider } from "@zerotal/orm";
|
|
21
|
+
|
|
22
|
+
export default [
|
|
23
|
+
// …your other providers
|
|
24
|
+
DatabaseProvider,
|
|
25
|
+
];
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Configure a connection in `config/database.ts`:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { DatabaseConfig } from "@zerotal/orm";
|
|
32
|
+
import { env } from "@zerotal/core";
|
|
33
|
+
|
|
34
|
+
export default DatabaseConfig({
|
|
35
|
+
driver: env("DB_DRIVER", "sqlite"), // 'sqlite' | 'postgres' | 'mysql'
|
|
36
|
+
url: env("DATABASE_URL", "./database/db.sqlite"),
|
|
37
|
+
replicas: [], // optional read-replica URLs
|
|
38
|
+
});
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
### Define a model
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { BaseModel, column, table, belongsTo, hasMany } from "@zerotal/orm";
|
|
47
|
+
import type { Columns } from "@zerotal/orm";
|
|
48
|
+
|
|
49
|
+
@(table("posts").withTimestamps().withSoftDeletes())
|
|
50
|
+
export class Post extends BaseModel {
|
|
51
|
+
static fillable: Columns<Post>[] = ["title", "body", "status", "userId"];
|
|
52
|
+
|
|
53
|
+
@column("string") title!: string;
|
|
54
|
+
@column("text") body!: string;
|
|
55
|
+
@column("string") status!: string;
|
|
56
|
+
@column("integer") userId!: number;
|
|
57
|
+
|
|
58
|
+
@belongsTo(() => User, { foreignKey: "userId" })
|
|
59
|
+
author!: User;
|
|
60
|
+
|
|
61
|
+
@hasMany(() => Comment, { foreignKey: "postId" })
|
|
62
|
+
comments!: Comment[];
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Query records
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
const post = await Post.find(1); // or findOrFail(1) to throw
|
|
70
|
+
const published = await Post.query()
|
|
71
|
+
.where("status", "published")
|
|
72
|
+
.orderBy("created_at", "desc")
|
|
73
|
+
.get<Post>();
|
|
74
|
+
|
|
75
|
+
const created = await Post.create({ title: "Hello", body: "…", status: "draft" });
|
|
76
|
+
|
|
77
|
+
post.fill({ title: "Updated" });
|
|
78
|
+
await post.save();
|
|
79
|
+
|
|
80
|
+
await post.delete(); // soft delete (table has .withSoftDeletes())
|
|
81
|
+
await post.restore(); // un-delete
|
|
82
|
+
await post.forceDelete(); // permanent
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Paginate
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
const page = await Post.query()
|
|
89
|
+
.where("status", "published")
|
|
90
|
+
.orderBy("created_at", "desc")
|
|
91
|
+
.paginate(15, Number(http.query("page", "1")));
|
|
92
|
+
|
|
93
|
+
page.data; // Post[] for this page
|
|
94
|
+
page.total; // total matching rows
|
|
95
|
+
page.lastPage; // number of pages
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Migrations
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import { Migration, Schema } from "@zerotal/orm";
|
|
102
|
+
|
|
103
|
+
export default class CreatePostsTable extends Migration {
|
|
104
|
+
async up(): Promise<void> {
|
|
105
|
+
await Schema.create("posts", (t) => {
|
|
106
|
+
t.increments("id");
|
|
107
|
+
t.integer("user_id").index();
|
|
108
|
+
t.string("title");
|
|
109
|
+
t.text("body");
|
|
110
|
+
t.softDeletes();
|
|
111
|
+
t.timestamps();
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async down(): Promise<void> {
|
|
116
|
+
await Schema.drop("posts");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Run them with `bun zt migrate` (`--fresh`, `migrate:rollback`, `migrate:status` also available).
|
|
122
|
+
|
|
123
|
+
### Raw query builder
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import { DB } from "@zerotal/orm";
|
|
127
|
+
|
|
128
|
+
const rows = await DB.table("settings").where("key", "theme").first();
|
|
129
|
+
await DB.table("settings").upsert(
|
|
130
|
+
{ key: "theme", value: "dark" },
|
|
131
|
+
{ key: "theme" },
|
|
132
|
+
{ value: "dark" },
|
|
133
|
+
);
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Exports
|
|
137
|
+
|
|
138
|
+
This package exposes two subpath entry points:
|
|
139
|
+
|
|
140
|
+
| Subpath | Contents |
|
|
141
|
+
| ------------- | --------------------------------------------------------------------------------- |
|
|
142
|
+
| `.` (default) | The full ORM runtime — see the table below. |
|
|
143
|
+
| `./commands` | CLI command classes used by the `zerotal` binary (`make:model`, `migrate`, etc.). |
|
|
144
|
+
|
|
145
|
+
Main exports from the default entry point:
|
|
146
|
+
|
|
147
|
+
- **Models** — `BaseModel` / `Model`, `ModelQueryBuilder`, `DB`, `QueryBuilder`
|
|
148
|
+
- **Decorators** — `column`, `table`, `belongsTo`, `hasMany`, `hasOne`, `manyToMany`, `morphTo`, `morphMany`, `morphOne`, `hasManyThrough`, `hasOneThrough`, `morphToMany`, `morphedByMany`
|
|
149
|
+
- **Schema / migrations** — `Schema`, `Blueprint`, `Migration`, `MigrationRunner`, `SchemaInspector`, `ModelInspector`, `SchemaDiffer`, `synchronizeSchema`
|
|
150
|
+
- **Seeding** — `Seeder`
|
|
151
|
+
- **Casts** — `Cast`, `JsonCast`, `ArrayCast`, `json`, `objectOf`, `arrayOf`
|
|
152
|
+
- **Hooks & observers** — `HookRegistry`, `ModelObserver`
|
|
153
|
+
- **N+1 detection** — `preventNPlusOne`, `allowNPlusOne`, `NPlusOneError`
|
|
154
|
+
- **Errors** — `ModelNotFoundError`, `RelationNotLoadedError`, `TransactionError`, `MigrationError`, `StateError`
|
|
155
|
+
- **Provider & config** — `DatabaseProvider`, `DatabaseConfig`
|
|
156
|
+
- **Types** — `Columns`, `InsertPayload`, `UpdatePayload`, `PaginateResult`, `CursorPaginateResult`, and more
|
|
157
|
+
|
|
158
|
+
## Documentation
|
|
159
|
+
|
|
160
|
+
- [ORM overview](../../docs/orm/index.md)
|
|
161
|
+
- [Queries](../../docs/orm/queries.md)
|
|
162
|
+
- [Relationships](../../docs/orm/relationships.md)
|
|
163
|
+
- [Casts & Mutators](../../docs/orm/casts.md)
|
|
164
|
+
- [Lifecycle & Events](../../docs/orm/lifecycle.md)
|
|
165
|
+
- [Serialization](../../docs/orm/serialization.md)
|
|
166
|
+
- [Factories](../../docs/orm/factories.md)
|
|
167
|
+
- [Query Builder](../../docs/query-builder.md)
|
|
168
|
+
- [Migrations](../../docs/migrations.md)
|
|
169
|
+
- [Pagination](../../docs/pagination.md)
|
|
170
|
+
- [Seeding](../../docs/seeding.md)
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zerotal/orm",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"maturity": "stable",
|
|
6
|
+
"private": false,
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./src/index.ts",
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.ts",
|
|
12
|
+
"./commands": "./src/commands/index.ts"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"CHANGELOG.md",
|
|
16
|
+
"src",
|
|
17
|
+
"!src/**/*.test.ts",
|
|
18
|
+
"!src/**/*.test.tsx",
|
|
19
|
+
"!src/**/*.spec.ts",
|
|
20
|
+
"!src/**/__fixtures__/**"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"bun": ">=1.3.14"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "bun test",
|
|
30
|
+
"typecheck": "tsc --noEmit"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@zerotal/core": "1.0.0",
|
|
34
|
+
"@zerotal/validator": "1.0.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"typescript": "^5.8.0"
|
|
38
|
+
},
|
|
39
|
+
"description": "Active Record ORM for Zerotal built on Bun.sql — models, relations, query builder, migrations, and schema.",
|
|
40
|
+
"keywords": [
|
|
41
|
+
"zerotal",
|
|
42
|
+
"bun",
|
|
43
|
+
"typescript",
|
|
44
|
+
"framework",
|
|
45
|
+
"orm",
|
|
46
|
+
"active-record",
|
|
47
|
+
"sql",
|
|
48
|
+
"database",
|
|
49
|
+
"migrations"
|
|
50
|
+
],
|
|
51
|
+
"repository": {
|
|
52
|
+
"type": "git",
|
|
53
|
+
"url": "git+https://github.com/zerotaldev/zerotal.git",
|
|
54
|
+
"directory": "packages/orm"
|
|
55
|
+
},
|
|
56
|
+
"homepage": "https://github.com/zerotaldev/zerotal/tree/main/packages/orm#readme",
|
|
57
|
+
"bugs": "https://github.com/zerotaldev/zerotal/issues"
|
|
58
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Column casts — convert a stored DB value to a rich model value on read, and
|
|
3
|
+
* back on write. The framework already accepts any `{ get, set }` object as a
|
|
4
|
+
* cast; `Cast` is the ergonomic, reusable base for custom ones (put yours in
|
|
5
|
+
* `app/casts/`), plus built-in JSON/array casters.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* // app/casts/MoneyCast.ts
|
|
9
|
+
* export class MoneyCast extends Cast<number> {
|
|
10
|
+
* get(db: unknown) { return Number(db) / 100; } // cents → dollars
|
|
11
|
+
* set(v: number) { return Math.round(v * 100); }
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* // model
|
|
15
|
+
* @column({ cast: new MoneyCast() }) price!: number;
|
|
16
|
+
* @column({ cast: arrayOf(Address) }) addresses!: Address[];
|
|
17
|
+
* @column({ cast: json<Settings>() }) settings!: Settings;
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A reusable column caster: DB value ⇄ model value.
|
|
22
|
+
*
|
|
23
|
+
* Any object with these two methods can be passed to `@column({ cast })`; the
|
|
24
|
+
* ORM calls `get` when hydrating an attribute from the database and `set` when
|
|
25
|
+
* writing it back.
|
|
26
|
+
*
|
|
27
|
+
* @typeParam T - The model-side (deserialized) value type.
|
|
28
|
+
* @category Casts
|
|
29
|
+
*/
|
|
30
|
+
export interface CastContract<T = unknown> {
|
|
31
|
+
/** DB value → model value (on read). */
|
|
32
|
+
get(dbValue: unknown): T;
|
|
33
|
+
/** Model value → DB value (on write). */
|
|
34
|
+
set(value: T): unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Base class for custom casts. Extend it, implement `get`/`set`, and reference an
|
|
39
|
+
* instance from a column: `@column({ cast: new MoneyCast() })`.
|
|
40
|
+
*
|
|
41
|
+
* @typeParam T - The model-side (deserialized) value type.
|
|
42
|
+
* @category Casts
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```ts
|
|
46
|
+
* // app/casts/MoneyCast.ts
|
|
47
|
+
* export class MoneyCast extends Cast<number> {
|
|
48
|
+
* get(db: unknown) { return Number(db) / 100; } // cents → dollars
|
|
49
|
+
* set(v: number) { return Math.round(v * 100); }
|
|
50
|
+
* }
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export abstract class Cast<T = unknown> implements CastContract<T> {
|
|
54
|
+
abstract get(dbValue: unknown): T;
|
|
55
|
+
abstract set(value: T): unknown;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A class constructor or mapper function used to hydrate a JSON value. */
|
|
59
|
+
export type CastMapper<T> = ((raw: unknown) => T) | (new (...args: never[]) => T);
|
|
60
|
+
|
|
61
|
+
function _parse(v: unknown): unknown {
|
|
62
|
+
if (v === null || v === undefined) return v;
|
|
63
|
+
if (typeof v === "string") {
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(v);
|
|
66
|
+
} catch {
|
|
67
|
+
return v;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return v;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function _isClass(fn: unknown): boolean {
|
|
74
|
+
return typeof fn === "function" && /^class[\s{]/.test(Function.prototype.toString.call(fn));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function _hydrate<T>(mapper: CastMapper<T> | undefined, raw: unknown): T {
|
|
78
|
+
if (mapper === undefined || raw === null || raw === undefined) return raw as T;
|
|
79
|
+
if (_isClass(mapper)) {
|
|
80
|
+
const C = mapper as unknown as { fromJSON?: (r: unknown) => T; prototype: object };
|
|
81
|
+
if (typeof C.fromJSON === "function") return C.fromJSON(raw);
|
|
82
|
+
// Hydrate without invoking the constructor (data/value objects).
|
|
83
|
+
return Object.assign(Object.create(C.prototype) as object, raw) as T;
|
|
84
|
+
}
|
|
85
|
+
return (mapper as (raw: unknown) => T)(raw);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A sub-field discovered on a cast's mapper class — consumed by the admin UI. */
|
|
89
|
+
export interface CastField {
|
|
90
|
+
name: string;
|
|
91
|
+
label: string;
|
|
92
|
+
type: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function _titleField(s: string): string {
|
|
96
|
+
return s
|
|
97
|
+
.replace(/[_-]+/g, " ")
|
|
98
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
99
|
+
.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
100
|
+
.trim();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function _widgetForValue(v: unknown): string {
|
|
104
|
+
if (typeof v === "boolean") return "toggle";
|
|
105
|
+
if (typeof v === "number") return "number";
|
|
106
|
+
return "text";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Introspect a mapper class into a field list (keys + inferred types from defaults). */
|
|
110
|
+
function _introspectFields(mapper: unknown): CastField[] {
|
|
111
|
+
if (!_isClass(mapper)) return [];
|
|
112
|
+
let inst: Record<string, unknown>;
|
|
113
|
+
try {
|
|
114
|
+
inst = new (mapper as new () => Record<string, unknown>)();
|
|
115
|
+
} catch {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
return Object.entries(inst)
|
|
119
|
+
.filter(([, v]) => typeof v !== "function")
|
|
120
|
+
.map(([k, v]) => ({ name: k, label: _titleField(k), type: _widgetForValue(v) }));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Cast a JSON column to a typed object, optionally hydrated into a class.
|
|
125
|
+
* On read the stored JSON is parsed (and mapped via the constructor's `fromJSON`
|
|
126
|
+
* or prototype hydration when a class mapper is given); on write it is stringified.
|
|
127
|
+
* Usually created via the {@link json} / {@link objectOf} helpers.
|
|
128
|
+
* @category Casts
|
|
129
|
+
*/
|
|
130
|
+
export class JsonCast<T = unknown> extends Cast<T | null> {
|
|
131
|
+
/** Structural hint for the admin UI: a single nested object. */
|
|
132
|
+
readonly shape = "object" as const;
|
|
133
|
+
constructor(private readonly mapper?: CastMapper<T>) {
|
|
134
|
+
super();
|
|
135
|
+
}
|
|
136
|
+
/** Sub-fields derived from the mapper class (empty for plain JSON). */
|
|
137
|
+
fields(): CastField[] {
|
|
138
|
+
return _introspectFields(this.mapper);
|
|
139
|
+
}
|
|
140
|
+
get(dbValue: unknown): T | null {
|
|
141
|
+
const v = _parse(dbValue);
|
|
142
|
+
return v === null || v === undefined ? (v as null) : _hydrate(this.mapper, v);
|
|
143
|
+
}
|
|
144
|
+
set(value: T | null): unknown {
|
|
145
|
+
return value === null || value === undefined ? null : JSON.stringify(value);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Cast a JSON column to an array of typed values, optionally hydrated. Non-array
|
|
151
|
+
* stored values read back as `[]`; on write the array is stringified. Usually
|
|
152
|
+
* created via the {@link arrayOf} helper.
|
|
153
|
+
* @category Casts
|
|
154
|
+
*/
|
|
155
|
+
export class ArrayCast<T = unknown> extends Cast<T[]> {
|
|
156
|
+
/** Structural hint for the admin UI: a repeating list of nested objects. */
|
|
157
|
+
readonly shape = "array" as const;
|
|
158
|
+
constructor(private readonly mapper?: CastMapper<T>) {
|
|
159
|
+
super();
|
|
160
|
+
}
|
|
161
|
+
/** Sub-fields of each element, derived from the mapper class. */
|
|
162
|
+
fields(): CastField[] {
|
|
163
|
+
return _introspectFields(this.mapper);
|
|
164
|
+
}
|
|
165
|
+
get(dbValue: unknown): T[] {
|
|
166
|
+
const v = _parse(dbValue);
|
|
167
|
+
if (!Array.isArray(v)) return [];
|
|
168
|
+
return v.map((x) => _hydrate(this.mapper, x));
|
|
169
|
+
}
|
|
170
|
+
set(value: T[]): unknown {
|
|
171
|
+
return JSON.stringify(value ?? []);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Typed JSON object cast: `@column({ cast: json<Settings>() })`.
|
|
177
|
+
* @param mapper - Optional class or function to hydrate the parsed value into.
|
|
178
|
+
* @category Casts
|
|
179
|
+
*/
|
|
180
|
+
export function json<T = unknown>(mapper?: CastMapper<T>): JsonCast<T> {
|
|
181
|
+
return new JsonCast<T>(mapper);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Alias of {@link json}, reads nicely with a class: `objectOf(Address)`.
|
|
186
|
+
* @param mapper - Optional class or function to hydrate the parsed value into.
|
|
187
|
+
* @category Casts
|
|
188
|
+
*/
|
|
189
|
+
export function objectOf<T = unknown>(mapper?: CastMapper<T>): JsonCast<T> {
|
|
190
|
+
return new JsonCast<T>(mapper);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Typed JSON list cast: `@column({ cast: arrayOf(Address) })`.
|
|
195
|
+
* @param mapper - Optional class or function to hydrate each element into.
|
|
196
|
+
* @category Casts
|
|
197
|
+
*/
|
|
198
|
+
export function arrayOf<T = unknown>(mapper?: CastMapper<T>): ArrayCast<T> {
|
|
199
|
+
return new ArrayCast<T>(mapper);
|
|
200
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Command } from "@zerotal/core";
|
|
2
|
+
import type { Seeder } from "../seeding/Seeder.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Runs the application's database seeders (`bun zt db:seed`).
|
|
6
|
+
*
|
|
7
|
+
* Imports `database/seeders/DatabaseSeeder.ts` and invokes its `run()` method,
|
|
8
|
+
* falling back to a legacy `database/seeders/index.ts` default-export function
|
|
9
|
+
* when the class-based seeder is absent.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```bash
|
|
13
|
+
* bun zt db:seed
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* @category Seeding
|
|
17
|
+
*/
|
|
18
|
+
export class DbSeedCommand extends Command {
|
|
19
|
+
static commandName = "db:seed";
|
|
20
|
+
static description = "Run database seeders from database/seeders/";
|
|
21
|
+
static needsApp = true;
|
|
22
|
+
|
|
23
|
+
async run(): Promise<void> {
|
|
24
|
+
const cwd = process.cwd();
|
|
25
|
+
const seederPath = `${cwd}/database/seeders/DatabaseSeeder.ts`;
|
|
26
|
+
|
|
27
|
+
const file = Bun.file(seederPath);
|
|
28
|
+
if (!(await file.exists())) {
|
|
29
|
+
// Fall back to legacy index.ts seeder format
|
|
30
|
+
const legacyPath = `${cwd}/database/seeders/index.ts`;
|
|
31
|
+
if (await Bun.file(legacyPath).exists()) {
|
|
32
|
+
try {
|
|
33
|
+
const mod = await import(legacyPath);
|
|
34
|
+
const seed = mod.default as (() => Promise<void>) | undefined;
|
|
35
|
+
if (!seed) {
|
|
36
|
+
this.error("Seeder index must export a default async function.");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
await seed();
|
|
40
|
+
this.info("Database seeded.");
|
|
41
|
+
} catch (err) {
|
|
42
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
43
|
+
this.error(`Failed to run seeders: ${msg}`);
|
|
44
|
+
}
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
this.error(`Seeder not found: ${seederPath}`);
|
|
49
|
+
this.dim("Create it with: bun zerotal.ts make:seeder DatabaseSeeder");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const mod = await import(seederPath);
|
|
55
|
+
const SeederClass = (mod.DatabaseSeeder ?? mod.default) as (new () => Seeder) | undefined;
|
|
56
|
+
|
|
57
|
+
if (!SeederClass) {
|
|
58
|
+
this.error("DatabaseSeeder not found as a named or default export.");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
this.section("Database Seeding");
|
|
63
|
+
const seeder = new SeederClass();
|
|
64
|
+
await seeder.run();
|
|
65
|
+
this.info("Database seeded successfully.");
|
|
66
|
+
} catch (err) {
|
|
67
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
68
|
+
this.error(`Failed to run seeders: ${msg}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Command, Str } from "@zerotal/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Scaffolds a new model factory (`bun zt make:factory`).
|
|
5
|
+
*
|
|
6
|
+
* Writes `database/factories/<Model>Factory.ts` defining a `Factory` for the
|
|
7
|
+
* named model, ready to be imported in tests to build and persist records.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```bash
|
|
11
|
+
* bun zt make:factory User
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* @category Scaffolding (make:*)
|
|
15
|
+
*/
|
|
16
|
+
export class MakeFactoryCommand extends Command {
|
|
17
|
+
static commandName = "make:factory";
|
|
18
|
+
static description = "Create a new model factory";
|
|
19
|
+
static needsApp = false;
|
|
20
|
+
static args = [{ name: "model", required: true, description: "Model name (e.g. User)" }];
|
|
21
|
+
|
|
22
|
+
async run(): Promise<void> {
|
|
23
|
+
const model = this.args["model"]!;
|
|
24
|
+
const path = `database/factories/${model}Factory.ts`;
|
|
25
|
+
|
|
26
|
+
if (await Bun.file(path).exists()) {
|
|
27
|
+
this.error(`File already exists: ${path}`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
await Bun.write(path, factoryStub(model));
|
|
32
|
+
this.info(`Created: ${path}`);
|
|
33
|
+
this.dim(
|
|
34
|
+
`Import in tests: import { ${model}Factory } from '../database/factories/${model}Factory.ts';`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Returns the source text of a factory file for the given model name.
|
|
41
|
+
*/
|
|
42
|
+
export function factoryStub(model: string): string {
|
|
43
|
+
const modelVar = Str.lcfirst(model);
|
|
44
|
+
return `import { Factory } from '@zerotal/testing';
|
|
45
|
+
import { ${model} } from '../../app/models/${model}.ts';
|
|
46
|
+
|
|
47
|
+
export const ${model}Factory = Factory.define(${model}, (fake) => ({
|
|
48
|
+
// Define default attributes here. Use fake for generated values:
|
|
49
|
+
// name: fake.string(10),
|
|
50
|
+
// email: fake.email(),
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
// Usage in tests:
|
|
54
|
+
// const ${modelVar} = await ${model}Factory.create();
|
|
55
|
+
// const ${modelVar}s = await ${model}Factory.createMany(5);
|
|
56
|
+
// const draft = ${model}Factory.make({ published: false });
|
|
57
|
+
// const post = await PostFactory.for(${modelVar}).create();
|
|
58
|
+
`;
|
|
59
|
+
}
|