@buenojs/bueno 0.8.3 → 0.8.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +136 -16
- package/dist/cli/{index.js → bin.js} +3036 -1421
- package/dist/container/index.js +250 -0
- package/dist/context/index.js +219 -0
- package/dist/database/index.js +493 -0
- package/dist/frontend/index.js +7697 -0
- package/dist/health/index.js +364 -0
- package/dist/i18n/index.js +345 -0
- package/dist/index.js +11043 -6482
- package/dist/jobs/index.js +819 -0
- package/dist/lock/index.js +367 -0
- package/dist/logger/index.js +281 -0
- package/dist/metrics/index.js +289 -0
- package/dist/middleware/index.js +77 -0
- package/dist/migrations/index.js +571 -0
- package/dist/modules/index.js +3346 -0
- package/dist/notification/index.js +484 -0
- package/dist/observability/index.js +331 -0
- package/dist/openapi/index.js +776 -0
- package/dist/orm/index.js +1356 -0
- package/dist/router/index.js +886 -0
- package/dist/rpc/index.js +691 -0
- package/dist/schema/index.js +400 -0
- package/dist/telemetry/index.js +595 -0
- package/dist/template/index.js +640 -0
- package/dist/templates/index.js +640 -0
- package/dist/testing/index.js +1111 -0
- package/dist/types/index.js +60 -0
- package/package.json +121 -27
- package/src/cache/index.ts +2 -1
- package/src/cli/bin.ts +2 -2
- package/src/cli/commands/build.ts +183 -165
- package/src/cli/commands/dev.ts +96 -89
- package/src/cli/commands/generate.ts +142 -111
- package/src/cli/commands/help.ts +20 -16
- package/src/cli/commands/index.ts +3 -6
- package/src/cli/commands/migration.ts +124 -105
- package/src/cli/commands/new.ts +392 -438
- package/src/cli/commands/start.ts +81 -79
- package/src/cli/core/args.ts +68 -50
- package/src/cli/core/console.ts +89 -95
- package/src/cli/core/index.ts +4 -4
- package/src/cli/core/prompt.ts +65 -62
- package/src/cli/core/spinner.ts +23 -20
- package/src/cli/index.ts +46 -38
- package/src/cli/templates/database/index.ts +61 -0
- package/src/cli/templates/database/mysql.ts +14 -0
- package/src/cli/templates/database/none.ts +16 -0
- package/src/cli/templates/database/postgresql.ts +14 -0
- package/src/cli/templates/database/sqlite.ts +14 -0
- package/src/cli/templates/deploy.ts +29 -26
- package/src/cli/templates/docker.ts +41 -30
- package/src/cli/templates/frontend/index.ts +63 -0
- package/src/cli/templates/frontend/none.ts +17 -0
- package/src/cli/templates/frontend/react.ts +140 -0
- package/src/cli/templates/frontend/solid.ts +134 -0
- package/src/cli/templates/frontend/svelte.ts +131 -0
- package/src/cli/templates/frontend/vue.ts +130 -0
- package/src/cli/templates/generators/index.ts +339 -0
- package/src/cli/templates/generators/types.ts +56 -0
- package/src/cli/templates/index.ts +35 -2
- package/src/cli/templates/project/api.ts +81 -0
- package/src/cli/templates/project/default.ts +140 -0
- package/src/cli/templates/project/fullstack.ts +111 -0
- package/src/cli/templates/project/index.ts +95 -0
- package/src/cli/templates/project/minimal.ts +45 -0
- package/src/cli/templates/project/types.ts +94 -0
- package/src/cli/templates/project/website.ts +263 -0
- package/src/cli/utils/fs.ts +55 -41
- package/src/cli/utils/index.ts +3 -2
- package/src/cli/utils/strings.ts +47 -33
- package/src/cli/utils/version.ts +47 -0
- package/src/config/env-validation.ts +100 -0
- package/src/config/env.ts +169 -41
- package/src/config/index.ts +28 -20
- package/src/config/loader.ts +25 -16
- package/src/config/merge.ts +21 -10
- package/src/config/types.ts +545 -25
- package/src/config/validation.ts +215 -7
- package/src/container/forward-ref.ts +22 -22
- package/src/container/index.ts +34 -12
- package/src/context/index.ts +11 -1
- package/src/database/index.ts +7 -190
- package/src/database/orm/builder.ts +457 -0
- package/src/database/orm/casts/index.ts +130 -0
- package/src/database/orm/casts/types.ts +25 -0
- package/src/database/orm/compiler.ts +304 -0
- package/src/database/orm/hooks/index.ts +114 -0
- package/src/database/orm/index.ts +61 -0
- package/src/database/orm/model-registry.ts +59 -0
- package/src/database/orm/model.ts +821 -0
- package/src/database/orm/relationships/base.ts +146 -0
- package/src/database/orm/relationships/belongs-to-many.ts +179 -0
- package/src/database/orm/relationships/belongs-to.ts +56 -0
- package/src/database/orm/relationships/has-many.ts +45 -0
- package/src/database/orm/relationships/has-one.ts +41 -0
- package/src/database/orm/relationships/index.ts +11 -0
- package/src/database/orm/scopes/index.ts +55 -0
- package/src/events/__tests__/event-system.test.ts +235 -0
- package/src/events/config.ts +238 -0
- package/src/events/example-usage.ts +185 -0
- package/src/events/index.ts +278 -0
- package/src/events/manager.ts +385 -0
- package/src/events/registry.ts +182 -0
- package/src/events/types.ts +124 -0
- package/src/frontend/api-routes.ts +65 -23
- package/src/frontend/bundler.ts +76 -34
- package/src/frontend/console-client.ts +2 -2
- package/src/frontend/console-stream.ts +94 -38
- package/src/frontend/dev-server.ts +94 -46
- package/src/frontend/file-router.ts +61 -19
- package/src/frontend/frameworks/index.ts +37 -10
- package/src/frontend/frameworks/react.ts +10 -8
- package/src/frontend/frameworks/solid.ts +11 -9
- package/src/frontend/frameworks/svelte.ts +15 -9
- package/src/frontend/frameworks/vue.ts +13 -11
- package/src/frontend/hmr-client.ts +12 -10
- package/src/frontend/hmr.ts +146 -103
- package/src/frontend/index.ts +14 -5
- package/src/frontend/islands.ts +41 -22
- package/src/frontend/isr.ts +59 -37
- package/src/frontend/layout.ts +36 -21
- package/src/frontend/ssr/react.ts +74 -27
- package/src/frontend/ssr/solid.ts +54 -20
- package/src/frontend/ssr/svelte.ts +48 -14
- package/src/frontend/ssr/vue.ts +50 -18
- package/src/frontend/ssr.ts +83 -39
- package/src/frontend/types.ts +91 -56
- package/src/health/index.ts +21 -9
- package/src/i18n/engine.ts +305 -0
- package/src/i18n/index.ts +38 -0
- package/src/i18n/loader.ts +218 -0
- package/src/i18n/middleware.ts +164 -0
- package/src/i18n/negotiator.ts +162 -0
- package/src/i18n/types.ts +158 -0
- package/src/index.ts +179 -27
- package/src/jobs/drivers/memory.ts +315 -0
- package/src/jobs/drivers/redis.ts +459 -0
- package/src/jobs/index.ts +30 -0
- package/src/jobs/queue.ts +281 -0
- package/src/jobs/types.ts +295 -0
- package/src/jobs/worker.ts +380 -0
- package/src/logger/index.ts +1 -3
- package/src/logger/transports/index.ts +62 -22
- package/src/metrics/index.ts +25 -16
- package/src/migrations/index.ts +9 -0
- package/src/modules/filters.ts +13 -17
- package/src/modules/guards.ts +49 -26
- package/src/modules/index.ts +409 -298
- package/src/modules/interceptors.ts +58 -20
- package/src/modules/lazy.ts +11 -19
- package/src/modules/lifecycle.ts +15 -7
- package/src/modules/metadata.ts +15 -5
- package/src/modules/pipes.ts +94 -72
- package/src/notification/channels/base.ts +68 -0
- package/src/notification/channels/email.ts +105 -0
- package/src/notification/channels/push.ts +104 -0
- package/src/notification/channels/sms.ts +105 -0
- package/src/notification/channels/whatsapp.ts +104 -0
- package/src/notification/index.ts +48 -0
- package/src/notification/service.ts +354 -0
- package/src/notification/types.ts +344 -0
- package/src/observability/__tests__/observability.test.ts +483 -0
- package/src/observability/breadcrumbs.ts +114 -0
- package/src/observability/index.ts +136 -0
- package/src/observability/interceptor.ts +85 -0
- package/src/observability/service.ts +303 -0
- package/src/observability/trace.ts +37 -0
- package/src/observability/types.ts +196 -0
- package/src/openapi/__tests__/decorators.test.ts +335 -0
- package/src/openapi/__tests__/document-builder.test.ts +285 -0
- package/src/openapi/__tests__/route-scanner.test.ts +334 -0
- package/src/openapi/__tests__/schema-generator.test.ts +275 -0
- package/src/openapi/decorators.ts +328 -0
- package/src/openapi/document-builder.ts +274 -0
- package/src/openapi/index.ts +112 -0
- package/src/openapi/metadata.ts +112 -0
- package/src/openapi/route-scanner.ts +289 -0
- package/src/openapi/schema-generator.ts +256 -0
- package/src/openapi/swagger-module.ts +166 -0
- package/src/openapi/types.ts +398 -0
- package/src/orm/index.ts +10 -0
- package/src/rpc/index.ts +3 -1
- package/src/schema/index.ts +9 -0
- package/src/security/index.ts +15 -6
- package/src/ssg/index.ts +9 -8
- package/src/telemetry/index.ts +76 -22
- package/src/template/index.ts +7 -0
- package/src/templates/engine.ts +224 -0
- package/src/templates/index.ts +9 -0
- package/src/templates/loader.ts +331 -0
- package/src/templates/renderers/markdown.ts +212 -0
- package/src/templates/renderers/simple.ts +269 -0
- package/src/templates/types.ts +154 -0
- package/src/testing/index.ts +100 -27
- package/src/types/optional-deps.d.ts +347 -187
- package/src/validation/index.ts +92 -2
- package/src/validation/schemas.ts +536 -0
- package/tests/integration/fullstack.test.ts +4 -4
- package/tests/unit/database.test.ts +2 -72
- package/tests/unit/env-validation.test.ts +166 -0
- package/tests/unit/events.test.ts +910 -0
- package/tests/unit/i18n.test.ts +455 -0
- package/tests/unit/jobs.test.ts +493 -0
- package/tests/unit/notification.test.ts +988 -0
- package/tests/unit/observability.test.ts +453 -0
- package/tests/unit/orm/builder.test.ts +323 -0
- package/tests/unit/orm/casts.test.ts +179 -0
- package/tests/unit/orm/compiler.test.ts +220 -0
- package/tests/unit/orm/eager-loading.test.ts +285 -0
- package/tests/unit/orm/hooks.test.ts +191 -0
- package/tests/unit/orm/model.test.ts +373 -0
- package/tests/unit/orm/relationships.test.ts +303 -0
- package/tests/unit/orm/scopes.test.ts +74 -0
- package/tests/unit/templates-simple.test.ts +53 -0
- package/tests/unit/templates.test.ts +454 -0
- package/tests/unit/validation.test.ts +18 -24
- package/tsconfig.json +11 -3
|
@@ -0,0 +1,821 @@
|
|
|
1
|
+
// @ts-nocheck - Abstract class generic constraints cause false positives in TypeScript.
|
|
2
|
+
// The implementation is logically correct; see .idea/orm-implementation-status.md
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Model Base Class
|
|
6
|
+
*
|
|
7
|
+
* Active Record pattern implementation inspired by Laravel Eloquent.
|
|
8
|
+
* Provides model lifecycle, attributes, persistence, and relationships.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Database } from "../index";
|
|
12
|
+
import { OrmQueryBuilder } from "./builder";
|
|
13
|
+
import { type CastDefinition, CastRegistry } from "./casts";
|
|
14
|
+
import {
|
|
15
|
+
HookRunner,
|
|
16
|
+
type ModelHookCallback,
|
|
17
|
+
type ModelHookName,
|
|
18
|
+
} from "./hooks";
|
|
19
|
+
import { getModelDatabase, registerModelDatabase } from "./model-registry";
|
|
20
|
+
import { BelongsTo, BelongsToMany, HasMany, HasOne } from "./relationships";
|
|
21
|
+
import type { ScopeDefinition, ScopeRegistry } from "./scopes";
|
|
22
|
+
|
|
23
|
+
export class ModelNotFoundError extends Error {
|
|
24
|
+
constructor(message: string) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "ModelNotFoundError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class ModelOperationAbortedError extends Error {
|
|
31
|
+
constructor(message: string) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = "ModelOperationAbortedError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Base Model class
|
|
39
|
+
*
|
|
40
|
+
* Generic over TAttributes (the shape of model attributes)
|
|
41
|
+
*/
|
|
42
|
+
export abstract class Model<
|
|
43
|
+
TAttributes extends Record<string, unknown> = Record<string, unknown>,
|
|
44
|
+
> {
|
|
45
|
+
// ============= Static Configuration =============
|
|
46
|
+
|
|
47
|
+
static readonly table: string;
|
|
48
|
+
static readonly primaryKey: string = "id";
|
|
49
|
+
static readonly timestamps: boolean = true;
|
|
50
|
+
static readonly createdAtColumn: string = "created_at";
|
|
51
|
+
static readonly updatedAtColumn: string = "updated_at";
|
|
52
|
+
static readonly softDeletes: boolean = false;
|
|
53
|
+
static readonly deletedAtColumn: string = "deleted_at";
|
|
54
|
+
static readonly fillable: string[] = [];
|
|
55
|
+
static readonly guarded: string[] = [];
|
|
56
|
+
static readonly casts: Record<string, CastDefinition> = {};
|
|
57
|
+
|
|
58
|
+
private static scopeRegistry = new Map<string, ScopeRegistry<any>>();
|
|
59
|
+
private static hookRegistry = new Map<
|
|
60
|
+
string,
|
|
61
|
+
Map<ModelHookName, ModelHookCallback<any>[]>
|
|
62
|
+
>();
|
|
63
|
+
|
|
64
|
+
// ============= Instance State =============
|
|
65
|
+
|
|
66
|
+
protected _attributes: TAttributes = {} as TAttributes;
|
|
67
|
+
protected _original: TAttributes = {} as TAttributes;
|
|
68
|
+
protected _relations: Map<string, unknown> = new Map();
|
|
69
|
+
protected _exists = false;
|
|
70
|
+
protected _isDirty = false;
|
|
71
|
+
|
|
72
|
+
// ============= Constructor / Initialization =============
|
|
73
|
+
|
|
74
|
+
constructor(attributes?: Partial<TAttributes>) {
|
|
75
|
+
if (attributes) {
|
|
76
|
+
this.fill(attributes);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Return a Proxy to intercept property access
|
|
80
|
+
return new Proxy(this, {
|
|
81
|
+
get: (target, prop: string) => {
|
|
82
|
+
// Internal properties first
|
|
83
|
+
if (prop.startsWith("_")) {
|
|
84
|
+
return (target as any)[prop];
|
|
85
|
+
}
|
|
86
|
+
// Check if relation is loaded (from eager loading or lazy access)
|
|
87
|
+
// This must come before checking if it's a function, so eager-loaded
|
|
88
|
+
// relations take precedence over the relationship methods
|
|
89
|
+
if ((target as any)._relations.has(prop)) {
|
|
90
|
+
return (target as any)._relations.get(prop);
|
|
91
|
+
}
|
|
92
|
+
// Then check if it's a method
|
|
93
|
+
if (typeof (target as any)[prop] === "function") {
|
|
94
|
+
return (target as any)[prop];
|
|
95
|
+
}
|
|
96
|
+
// Finally try to get as attribute
|
|
97
|
+
return target.getAttribute(prop as keyof TAttributes);
|
|
98
|
+
},
|
|
99
|
+
set: (target, prop: string, value) => {
|
|
100
|
+
// Internal properties and methods
|
|
101
|
+
if (prop.startsWith("_") || prop in Object.getPrototypeOf(target)) {
|
|
102
|
+
(target as any)[prop] = value;
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
// Set as attribute
|
|
106
|
+
target.setAttribute(prop as keyof TAttributes, value as any);
|
|
107
|
+
return true;
|
|
108
|
+
},
|
|
109
|
+
has: (target, prop: string) => {
|
|
110
|
+
// Check internal properties
|
|
111
|
+
if (prop.startsWith("_") || prop in Object.getPrototypeOf(target)) {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
// Check relations
|
|
115
|
+
if ((target as any)._relations.has(prop)) {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
// Check attributes
|
|
119
|
+
return (target as any)._attributes.hasOwnProperty(prop);
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ============= Static Query API =============
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Create a query builder for this model
|
|
128
|
+
*/
|
|
129
|
+
static query<M extends Model>(
|
|
130
|
+
this: { new (): M } & typeof Model,
|
|
131
|
+
): ModelQueryBuilder<M> {
|
|
132
|
+
return new ModelQueryBuilder<M>(this);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Find a model by ID
|
|
137
|
+
*/
|
|
138
|
+
static async find<M extends Model>(
|
|
139
|
+
this: { new (): M } & typeof Model,
|
|
140
|
+
id: unknown,
|
|
141
|
+
): Promise<M | null> {
|
|
142
|
+
return this.query().where("id", id).first();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Find a model by ID or throw
|
|
147
|
+
*/
|
|
148
|
+
static async findOrFail<M extends Model>(
|
|
149
|
+
this: { new (): M } & typeof Model,
|
|
150
|
+
id: unknown,
|
|
151
|
+
): Promise<M> {
|
|
152
|
+
const result = await this.find(id);
|
|
153
|
+
if (!result) {
|
|
154
|
+
throw new ModelNotFoundError(`${this.name} with id ${id} not found`);
|
|
155
|
+
}
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Create a where clause
|
|
161
|
+
*/
|
|
162
|
+
static where<M extends Model>(
|
|
163
|
+
this: { new (): M } & typeof Model,
|
|
164
|
+
column: string,
|
|
165
|
+
operator: unknown,
|
|
166
|
+
value?: unknown,
|
|
167
|
+
): ModelQueryBuilder<M> {
|
|
168
|
+
return this.query().where(column, operator, value);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Get all records
|
|
173
|
+
*/
|
|
174
|
+
static async all<M extends Model>(
|
|
175
|
+
this: { new (): M } & typeof Model,
|
|
176
|
+
): Promise<M[]> {
|
|
177
|
+
return this.query().get();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Create and persist a new model
|
|
182
|
+
*/
|
|
183
|
+
static async create<M extends Model>(
|
|
184
|
+
this: { new (): M } & typeof Model,
|
|
185
|
+
data: Record<string, unknown>,
|
|
186
|
+
): Promise<M> {
|
|
187
|
+
// @ts-expect-error - Abstract class instantiation is valid here; this is the concrete subclass
|
|
188
|
+
const instance = new this();
|
|
189
|
+
instance.fill(data);
|
|
190
|
+
await instance.save();
|
|
191
|
+
return instance;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* First or create — get or create based on conditions
|
|
196
|
+
*/
|
|
197
|
+
static async firstOrCreate<M extends Model>(
|
|
198
|
+
this: { new (): M } & typeof Model,
|
|
199
|
+
conditions: Partial<TAttributes>,
|
|
200
|
+
values?: Partial<TAttributes>,
|
|
201
|
+
): Promise<M> {
|
|
202
|
+
let instance = await this.query();
|
|
203
|
+
for (const [key, value] of Object.entries(conditions)) {
|
|
204
|
+
instance = instance.where(key, value) as any;
|
|
205
|
+
}
|
|
206
|
+
const found = await instance.first();
|
|
207
|
+
if (found) return found;
|
|
208
|
+
|
|
209
|
+
const create_data = { ...conditions, ...values };
|
|
210
|
+
return this.create(create_data as Record<string, unknown>);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Update or create — update or create based on conditions
|
|
215
|
+
*/
|
|
216
|
+
static async updateOrCreate<M extends Model>(
|
|
217
|
+
this: { new (): M } & typeof Model,
|
|
218
|
+
conditions: Partial<TAttributes>,
|
|
219
|
+
values: Partial<TAttributes>,
|
|
220
|
+
): Promise<M> {
|
|
221
|
+
let query = this.query();
|
|
222
|
+
for (const [key, value] of Object.entries(conditions)) {
|
|
223
|
+
query = query.where(key, value) as any;
|
|
224
|
+
}
|
|
225
|
+
const found = await query.first();
|
|
226
|
+
|
|
227
|
+
if (found) {
|
|
228
|
+
await found.fill(values).save();
|
|
229
|
+
return found;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return this.create({ ...conditions, ...values } as Record<string, unknown>);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ============= Attribute Access =============
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Get an attribute value
|
|
239
|
+
*/
|
|
240
|
+
getAttribute<K extends keyof TAttributes>(key: K): TAttributes[K] {
|
|
241
|
+
return this._attributes[key];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Set an attribute value
|
|
246
|
+
*/
|
|
247
|
+
setAttribute<K extends keyof TAttributes>(
|
|
248
|
+
key: K,
|
|
249
|
+
value: TAttributes[K],
|
|
250
|
+
): void {
|
|
251
|
+
this._attributes[key] = value;
|
|
252
|
+
this._isDirty = true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Fill attributes from data (respects fillable/guarded)
|
|
257
|
+
*/
|
|
258
|
+
fill(data: Partial<TAttributes>): this {
|
|
259
|
+
const guarded = (this.constructor as typeof Model).guarded;
|
|
260
|
+
const fillable = (this.constructor as typeof Model).fillable;
|
|
261
|
+
|
|
262
|
+
for (const [key, value] of Object.entries(data)) {
|
|
263
|
+
if (guarded.includes("*")) break;
|
|
264
|
+
if (guarded.includes(key)) continue;
|
|
265
|
+
if (fillable.length > 0 && !fillable.includes(key)) continue;
|
|
266
|
+
|
|
267
|
+
this.setAttribute(
|
|
268
|
+
key as keyof TAttributes,
|
|
269
|
+
value as TAttributes[keyof TAttributes],
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return this;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Force fill (bypass fillable/guarded)
|
|
278
|
+
*/
|
|
279
|
+
forceFill(data: Partial<TAttributes>): this {
|
|
280
|
+
for (const [key, value] of Object.entries(data)) {
|
|
281
|
+
this.setAttribute(
|
|
282
|
+
key as keyof TAttributes,
|
|
283
|
+
value as TAttributes[keyof TAttributes],
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
return this;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Convert to JSON
|
|
291
|
+
*/
|
|
292
|
+
toJSON(): TAttributes {
|
|
293
|
+
return { ...this._attributes };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Convert to plain object
|
|
298
|
+
*/
|
|
299
|
+
toObject(): TAttributes {
|
|
300
|
+
return { ...this._attributes };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ============= Dirty Tracking =============
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Check if model or specific attribute is dirty
|
|
307
|
+
*/
|
|
308
|
+
isDirty(key?: string): boolean {
|
|
309
|
+
if (key) {
|
|
310
|
+
return (
|
|
311
|
+
this._attributes[key as keyof TAttributes] !==
|
|
312
|
+
this._original[key as keyof TAttributes]
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
return this._isDirty;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Check if model or specific attribute is clean
|
|
320
|
+
*/
|
|
321
|
+
isClean(key?: string): boolean {
|
|
322
|
+
return !this.isDirty(key);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Get dirty attributes
|
|
327
|
+
*/
|
|
328
|
+
getDirty(): Partial<TAttributes> {
|
|
329
|
+
const dirty: Partial<TAttributes> = {};
|
|
330
|
+
for (const [key, value] of Object.entries(this._attributes)) {
|
|
331
|
+
if (value !== this._original[key as keyof TAttributes]) {
|
|
332
|
+
dirty[key as keyof TAttributes] =
|
|
333
|
+
value as TAttributes[keyof TAttributes];
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return dirty;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Get original attribute values
|
|
341
|
+
*/
|
|
342
|
+
getOriginal<K extends keyof TAttributes>(
|
|
343
|
+
key?: K,
|
|
344
|
+
): TAttributes[K] | Partial<TAttributes> {
|
|
345
|
+
if (key) {
|
|
346
|
+
return this._original[key];
|
|
347
|
+
}
|
|
348
|
+
return { ...this._original };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ============= Persistence =============
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Save the model (create or update)
|
|
355
|
+
*/
|
|
356
|
+
async save(): Promise<void> {
|
|
357
|
+
const modelClass = this.constructor as typeof Model;
|
|
358
|
+
const db = getModelDatabase(modelClass.name);
|
|
359
|
+
|
|
360
|
+
if (this._exists) {
|
|
361
|
+
// UPDATE
|
|
362
|
+
if (!this.isDirty()) return;
|
|
363
|
+
|
|
364
|
+
const dirty = this.getDirty();
|
|
365
|
+
if (modelClass.timestamps) {
|
|
366
|
+
const now = new Date().toISOString();
|
|
367
|
+
dirty[modelClass.updatedAtColumn as keyof TAttributes] =
|
|
368
|
+
now as TAttributes[keyof TAttributes];
|
|
369
|
+
// Also update the in-memory attribute so model.updated_at reflects the new value
|
|
370
|
+
this._attributes[modelClass.updatedAtColumn as keyof TAttributes] =
|
|
371
|
+
now as TAttributes[keyof TAttributes];
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const builder = new OrmQueryBuilder(db, modelClass.table);
|
|
375
|
+
builder.where(
|
|
376
|
+
modelClass.primaryKey,
|
|
377
|
+
this.getAttribute(modelClass.primaryKey as keyof TAttributes),
|
|
378
|
+
);
|
|
379
|
+
await builder.update(dirty);
|
|
380
|
+
|
|
381
|
+
this._original = { ...this._attributes };
|
|
382
|
+
this._isDirty = false;
|
|
383
|
+
} else {
|
|
384
|
+
// INSERT
|
|
385
|
+
const data = { ...this._attributes };
|
|
386
|
+
|
|
387
|
+
if (modelClass.timestamps) {
|
|
388
|
+
const now = new Date().toISOString();
|
|
389
|
+
data[modelClass.createdAtColumn as keyof TAttributes] =
|
|
390
|
+
now as TAttributes[keyof TAttributes];
|
|
391
|
+
data[modelClass.updatedAtColumn as keyof TAttributes] =
|
|
392
|
+
now as TAttributes[keyof TAttributes];
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const builder = new OrmQueryBuilder<TAttributes>(db, modelClass.table);
|
|
396
|
+
const result = await builder.insert(data);
|
|
397
|
+
|
|
398
|
+
this._attributes = result;
|
|
399
|
+
this._original = { ...result };
|
|
400
|
+
this._exists = true;
|
|
401
|
+
this._isDirty = false;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Delete the model (soft or hard)
|
|
407
|
+
*/
|
|
408
|
+
async delete(): Promise<void> {
|
|
409
|
+
const modelClass = this.constructor as typeof Model;
|
|
410
|
+
const db = getModelDatabase(modelClass.name);
|
|
411
|
+
|
|
412
|
+
if (modelClass.softDeletes) {
|
|
413
|
+
// Soft delete: set deleted_at
|
|
414
|
+
this.setAttribute(
|
|
415
|
+
modelClass.deletedAtColumn as keyof TAttributes,
|
|
416
|
+
new Date().toISOString() as TAttributes[keyof TAttributes],
|
|
417
|
+
);
|
|
418
|
+
await this.save();
|
|
419
|
+
} else {
|
|
420
|
+
// Hard delete
|
|
421
|
+
const builder = new OrmQueryBuilder(db, modelClass.table);
|
|
422
|
+
builder.where(
|
|
423
|
+
modelClass.primaryKey,
|
|
424
|
+
this.getAttribute(modelClass.primaryKey as keyof TAttributes),
|
|
425
|
+
);
|
|
426
|
+
await builder.delete();
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Restore a soft-deleted model
|
|
432
|
+
*/
|
|
433
|
+
async restore(): Promise<void> {
|
|
434
|
+
const modelClass = this.constructor as typeof Model;
|
|
435
|
+
if (!modelClass.softDeletes) {
|
|
436
|
+
throw new Error(`Model ${modelClass.name} does not use soft deletes`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
this.setAttribute(
|
|
440
|
+
modelClass.deletedAtColumn as keyof TAttributes,
|
|
441
|
+
null as TAttributes[keyof TAttributes],
|
|
442
|
+
);
|
|
443
|
+
await this.save();
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Refresh the model from the database
|
|
448
|
+
*/
|
|
449
|
+
async refresh(): Promise<void> {
|
|
450
|
+
const modelClass = this.constructor as typeof Model;
|
|
451
|
+
const id = this.getAttribute(modelClass.primaryKey as keyof TAttributes);
|
|
452
|
+
const fresh = await (modelClass.query() as any).find(id);
|
|
453
|
+
|
|
454
|
+
if (fresh) {
|
|
455
|
+
this._attributes = fresh._attributes;
|
|
456
|
+
this._original = { ...fresh._attributes };
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Get a fresh instance of this model
|
|
462
|
+
*/
|
|
463
|
+
async fresh(): Promise<this> {
|
|
464
|
+
const modelClass = this.constructor as typeof Model;
|
|
465
|
+
const id = this.getAttribute(modelClass.primaryKey as keyof TAttributes);
|
|
466
|
+
return (modelClass.query() as any).find(id);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ============= Relationships =============
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Define a one-to-one relationship
|
|
473
|
+
*/
|
|
474
|
+
hasOne<TRelated extends Model>(
|
|
475
|
+
relatedClass: { new (): TRelated } & typeof Model,
|
|
476
|
+
foreignKey: string,
|
|
477
|
+
localKey?: string,
|
|
478
|
+
): HasOne<TRelated> {
|
|
479
|
+
return new HasOne(this as any, relatedClass, foreignKey, localKey ?? "id");
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Define a one-to-many relationship
|
|
484
|
+
*/
|
|
485
|
+
hasMany<TRelated extends Model>(
|
|
486
|
+
relatedClass: { new (): TRelated } & typeof Model,
|
|
487
|
+
foreignKey: string,
|
|
488
|
+
localKey?: string,
|
|
489
|
+
): HasMany<TRelated> {
|
|
490
|
+
return new HasMany(this as any, relatedClass, foreignKey, localKey ?? "id");
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Define the inverse of a one-to-one or one-to-many relationship
|
|
495
|
+
*/
|
|
496
|
+
belongsTo<TRelated extends Model>(
|
|
497
|
+
relatedClass: { new (): TRelated } & typeof Model,
|
|
498
|
+
foreignKey: string,
|
|
499
|
+
ownerKey?: string,
|
|
500
|
+
): BelongsTo<TRelated> {
|
|
501
|
+
return new BelongsTo(
|
|
502
|
+
this as any,
|
|
503
|
+
relatedClass,
|
|
504
|
+
foreignKey,
|
|
505
|
+
ownerKey ?? "id",
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Define a many-to-many relationship
|
|
511
|
+
*/
|
|
512
|
+
belongsToMany<TRelated extends Model>(
|
|
513
|
+
relatedClass: { new (): TRelated } & typeof Model,
|
|
514
|
+
pivotTable: string,
|
|
515
|
+
foreignPivotKey: string,
|
|
516
|
+
relatedPivotKey: string,
|
|
517
|
+
parentKey?: string,
|
|
518
|
+
relatedKey?: string,
|
|
519
|
+
): BelongsToMany<TRelated> {
|
|
520
|
+
return new BelongsToMany(
|
|
521
|
+
this as any,
|
|
522
|
+
relatedClass,
|
|
523
|
+
pivotTable,
|
|
524
|
+
foreignPivotKey,
|
|
525
|
+
relatedPivotKey,
|
|
526
|
+
parentKey ?? "id",
|
|
527
|
+
relatedKey ?? "id",
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ============= Hydration =============
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Create model instances from database rows
|
|
535
|
+
*/
|
|
536
|
+
static hydrate<M extends Model>(
|
|
537
|
+
this: { new (): M } & typeof Model,
|
|
538
|
+
rows: Record<string, unknown>[],
|
|
539
|
+
): M[] {
|
|
540
|
+
return rows.map((row) => {
|
|
541
|
+
const instance = new this();
|
|
542
|
+
instance._attributes = {} as any;
|
|
543
|
+
|
|
544
|
+
for (const [key, value] of Object.entries(row)) {
|
|
545
|
+
const castDef = this.casts[key];
|
|
546
|
+
instance._attributes[key as keyof any] = castDef
|
|
547
|
+
? CastRegistry.deserialize(castDef, value)
|
|
548
|
+
: value;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
instance._original = { ...instance._attributes };
|
|
552
|
+
instance._exists = true;
|
|
553
|
+
return instance;
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// ============= Hook Management =============
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Register a hook callback
|
|
561
|
+
*/
|
|
562
|
+
static on<M extends Model>(
|
|
563
|
+
this: { new (): M } & typeof Model,
|
|
564
|
+
hookName: ModelHookName,
|
|
565
|
+
callback: ModelHookCallback<M>,
|
|
566
|
+
): void {
|
|
567
|
+
if (!Model.hookRegistry.has(this.name)) {
|
|
568
|
+
Model.hookRegistry.set(this.name, new Map());
|
|
569
|
+
}
|
|
570
|
+
const registry = Model.hookRegistry.get(this.name)!;
|
|
571
|
+
if (!registry.has(hookName)) {
|
|
572
|
+
registry.set(hookName, []);
|
|
573
|
+
}
|
|
574
|
+
registry.get(hookName)!.push(callback as any);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Get all callbacks for a hook
|
|
579
|
+
*/
|
|
580
|
+
static getHookCallbacks<M extends Model>(
|
|
581
|
+
this: { new (): M } & typeof Model,
|
|
582
|
+
hookName: ModelHookName,
|
|
583
|
+
): ModelHookCallback<M>[] {
|
|
584
|
+
const registry = Model.hookRegistry.get(this.name);
|
|
585
|
+
return (registry?.get(hookName) ?? []) as any;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Called when model class is first used (for static initialization)
|
|
590
|
+
*/
|
|
591
|
+
static booting(): void {
|
|
592
|
+
// Override in subclass
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* ModelQueryBuilder — extended OrmQueryBuilder with Model-specific features
|
|
598
|
+
*/
|
|
599
|
+
export class ModelQueryBuilder<M extends Model> extends OrmQueryBuilder<any> {
|
|
600
|
+
private modelClass: { new (): M } & typeof Model;
|
|
601
|
+
private eagerLoads: Map<string, ((q: OrmQueryBuilder<any>) => void) | null> =
|
|
602
|
+
new Map();
|
|
603
|
+
|
|
604
|
+
constructor(modelClass: { new (): M } & typeof Model) {
|
|
605
|
+
const db = getModelDatabase(modelClass.name);
|
|
606
|
+
super(db, modelClass.table as string);
|
|
607
|
+
this.modelClass = modelClass;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Eager load a relationship
|
|
612
|
+
*/
|
|
613
|
+
with(relation: string, callback?: (q: OrmQueryBuilder<any>) => void): this {
|
|
614
|
+
this.eagerLoads.set(relation, callback ?? null);
|
|
615
|
+
return this;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Override get() to hydrate Model instances
|
|
620
|
+
*/
|
|
621
|
+
override async get(): Promise<M[]> {
|
|
622
|
+
const rows = await super.get();
|
|
623
|
+
const models = this.modelClass.hydrate(rows);
|
|
624
|
+
|
|
625
|
+
// Load eager relationships
|
|
626
|
+
for (const [relation, callback] of this.eagerLoads) {
|
|
627
|
+
await this.loadRelation(models, relation, callback);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return models;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Override first() to hydrate Model instance
|
|
635
|
+
* Note: We must fetch raw data directly to avoid double-hydration
|
|
636
|
+
* (since get() is overridden to already hydrate)
|
|
637
|
+
*/
|
|
638
|
+
override async first(): Promise<M | null> {
|
|
639
|
+
// Get raw row from parent OrmQueryBuilder.get() bypassing our override
|
|
640
|
+
const rows = await OrmQueryBuilder.prototype.get.call(this);
|
|
641
|
+
if (rows.length === 0) return null;
|
|
642
|
+
|
|
643
|
+
const row = rows[0];
|
|
644
|
+
const models = this.modelClass.hydrate([row]);
|
|
645
|
+
|
|
646
|
+
// Load eager relationships
|
|
647
|
+
for (const [relation, callback] of this.eagerLoads) {
|
|
648
|
+
await this.loadRelation(models, relation, callback ?? undefined);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
return models[0] ?? null;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Override paginate() to return hydrated Model instances
|
|
656
|
+
* Note: super.paginate() calls get() which already hydrates,
|
|
657
|
+
* so we just need to return the result as-is
|
|
658
|
+
*/
|
|
659
|
+
override async paginate(page: number, limit: number) {
|
|
660
|
+
const offset = (page - 1) * limit;
|
|
661
|
+
const [data, total] = await Promise.all([
|
|
662
|
+
this.clone().offset(offset).limit(limit).get(),
|
|
663
|
+
this.clone().count(),
|
|
664
|
+
]);
|
|
665
|
+
|
|
666
|
+
return {
|
|
667
|
+
data,
|
|
668
|
+
total,
|
|
669
|
+
page,
|
|
670
|
+
limit,
|
|
671
|
+
totalPages: Math.ceil(total / limit),
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Find first or create — find by conditions or create with values
|
|
677
|
+
*/
|
|
678
|
+
async firstOrCreate(
|
|
679
|
+
conditions: Record<string, unknown>,
|
|
680
|
+
values?: Record<string, unknown>,
|
|
681
|
+
): Promise<M> {
|
|
682
|
+
// Build a fresh query with conditions
|
|
683
|
+
const query = new ModelQueryBuilder(this.modelClass);
|
|
684
|
+
for (const [key, value] of Object.entries(conditions)) {
|
|
685
|
+
query.where(key, value);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Try to find existing
|
|
689
|
+
const found = await query.first();
|
|
690
|
+
if (found) return found;
|
|
691
|
+
|
|
692
|
+
// Create if not found
|
|
693
|
+
const createData = { ...conditions, ...values };
|
|
694
|
+
return this.modelClass.create(createData as Record<string, unknown>);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Update or create — update if exists or create if not
|
|
699
|
+
*/
|
|
700
|
+
async updateOrCreate(
|
|
701
|
+
conditions: Record<string, unknown>,
|
|
702
|
+
values: Record<string, unknown>,
|
|
703
|
+
): Promise<M> {
|
|
704
|
+
// Build a fresh query with conditions
|
|
705
|
+
const query = new ModelQueryBuilder(this.modelClass);
|
|
706
|
+
for (const [key, value] of Object.entries(conditions)) {
|
|
707
|
+
query.where(key, value);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Try to find existing
|
|
711
|
+
const found = await query.first();
|
|
712
|
+
if (found) {
|
|
713
|
+
// Update if found
|
|
714
|
+
found.fill(values);
|
|
715
|
+
await found.save();
|
|
716
|
+
return found;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Create if not found
|
|
720
|
+
const createData = { ...conditions, ...values };
|
|
721
|
+
return this.modelClass.create(createData as Record<string, unknown>);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Load eager relationships
|
|
726
|
+
* Supports nested dot-notation like "posts.comments"
|
|
727
|
+
*/
|
|
728
|
+
private async loadRelation(
|
|
729
|
+
models: M[],
|
|
730
|
+
relation: string,
|
|
731
|
+
callback?: (q: OrmQueryBuilder<any>) => void,
|
|
732
|
+
): Promise<void> {
|
|
733
|
+
// Return early if no models
|
|
734
|
+
if (models.length === 0) return;
|
|
735
|
+
|
|
736
|
+
// Handle nested dot-notation relations (e.g., "posts.comments")
|
|
737
|
+
const dotIndex = relation.indexOf(".");
|
|
738
|
+
if (dotIndex !== -1) {
|
|
739
|
+
const head = relation.substring(0, dotIndex);
|
|
740
|
+
const tail = relation.substring(dotIndex + 1);
|
|
741
|
+
|
|
742
|
+
// Load the first relation recursively without a callback
|
|
743
|
+
await this.loadRelation(models, head, undefined);
|
|
744
|
+
|
|
745
|
+
// Collect intermediate models from _relations
|
|
746
|
+
const intermediates = models.flatMap(
|
|
747
|
+
(m) => (m as any)._relations.get(head) ?? [],
|
|
748
|
+
);
|
|
749
|
+
|
|
750
|
+
if (intermediates.length === 0) return;
|
|
751
|
+
|
|
752
|
+
// Load nested relation on intermediate models
|
|
753
|
+
const intermediateModelClass = intermediates[0]
|
|
754
|
+
.constructor as typeof Model;
|
|
755
|
+
const intermediateBuilder = new ModelQueryBuilder(intermediateModelClass);
|
|
756
|
+
await intermediateBuilder.loadRelation(intermediates, tail, callback);
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Get a representative model to call the relationship method
|
|
761
|
+
const representative = models[0];
|
|
762
|
+
const relationMethod = (representative as any)[relation];
|
|
763
|
+
|
|
764
|
+
if (!relationMethod || typeof relationMethod !== "function") {
|
|
765
|
+
throw new Error(
|
|
766
|
+
`Relation "${relation}" not found on ${this.modelClass.name}`,
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Call the relationship method to get a relationship instance
|
|
771
|
+
const relationship = relationMethod.call(representative);
|
|
772
|
+
|
|
773
|
+
// Reset the query from single-model WHERE to clean state
|
|
774
|
+
(relationship as any).resetQuery();
|
|
775
|
+
|
|
776
|
+
// Apply eager constraints to load all parents at once
|
|
777
|
+
(relationship as any).addEagerConstraints(models);
|
|
778
|
+
|
|
779
|
+
// Apply optional callback to constrain the query
|
|
780
|
+
if (callback) {
|
|
781
|
+
callback(relationship.query);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Execute the query and get raw rows
|
|
785
|
+
const rawRows = await relationship.query.get();
|
|
786
|
+
|
|
787
|
+
// Hydrate raw rows into model instances
|
|
788
|
+
const related = (relationship.relatedClass as typeof Model).hydrate(
|
|
789
|
+
rawRows,
|
|
790
|
+
);
|
|
791
|
+
|
|
792
|
+
// Match relationship results back to parent models
|
|
793
|
+
relationship.match(models, related, relation);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Support local scopes via Proxy
|
|
798
|
+
*/
|
|
799
|
+
static createScoped<M extends Model>(
|
|
800
|
+
modelClass: { new (): M } & typeof Model,
|
|
801
|
+
): ModelQueryBuilder<M> {
|
|
802
|
+
const builder = new ModelQueryBuilder(modelClass);
|
|
803
|
+
|
|
804
|
+
return new Proxy(builder, {
|
|
805
|
+
get: (target, prop: string) => {
|
|
806
|
+
// Check for scope method
|
|
807
|
+
if (typeof prop === "string" && prop.startsWith("scope")) {
|
|
808
|
+
const methodName = `scope${prop.charAt(5).toUpperCase()}${prop.slice(6)}`;
|
|
809
|
+
if (typeof (modelClass as any)[methodName] === "function") {
|
|
810
|
+
return (...args: any[]) => {
|
|
811
|
+
return (modelClass as any)[methodName](target, ...args);
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Regular property access
|
|
817
|
+
return (target as any)[prop];
|
|
818
|
+
},
|
|
819
|
+
}) as ModelQueryBuilder<M>;
|
|
820
|
+
}
|
|
821
|
+
}
|