@bunyad/orm 0.1.0-alpha.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 +70 -0
- package/dist/casts.d.ts +50 -0
- package/dist/casts.js +309 -0
- package/dist/concerns.d.ts +64 -0
- package/dist/concerns.js +216 -0
- package/dist/decorators.d.ts +7 -0
- package/dist/decorators.js +12 -0
- package/dist/eager-fetch.d.ts +7 -0
- package/dist/eager-fetch.js +38 -0
- package/dist/eager.d.ts +24 -0
- package/dist/eager.js +854 -0
- package/dist/factory.d.ts +68 -0
- package/dist/factory.js +265 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +9 -0
- package/dist/model-events.d.ts +43 -0
- package/dist/model-events.js +138 -0
- package/dist/model-helpers.d.ts +32 -0
- package/dist/model-helpers.js +99 -0
- package/dist/model-query.d.ts +312 -0
- package/dist/model-query.js +3170 -0
- package/dist/model-strictness.d.ts +35 -0
- package/dist/model-strictness.js +114 -0
- package/dist/model.d.ts +490 -0
- package/dist/model.js +1931 -0
- package/dist/orm-collection.d.ts +37 -0
- package/dist/orm-collection.js +150 -0
- package/dist/pivot.d.ts +9 -0
- package/dist/pivot.js +9 -0
- package/dist/relations.d.ts +335 -0
- package/dist/relations.js +1164 -0
- package/dist/scopes.d.ts +36 -0
- package/dist/scopes.js +128 -0
- package/package.json +49 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Model, ModelClass } from "./model.js";
|
|
2
|
+
export type FactoryAttributes = Record<string, unknown>;
|
|
3
|
+
export type FactoryState = FactoryAttributes | ((attributes: FactoryAttributes) => FactoryAttributes | Promise<FactoryAttributes>);
|
|
4
|
+
export type FactorySequenceState = FactoryAttributes | ((sequence: number) => FactoryAttributes);
|
|
5
|
+
/**
|
|
6
|
+
* Lightweight faker for factory definitions (`fake().email()`).
|
|
7
|
+
*/
|
|
8
|
+
export type FakeGenerator = {
|
|
9
|
+
uuid(): string;
|
|
10
|
+
word(): string;
|
|
11
|
+
words(count?: number): string;
|
|
12
|
+
sentence(): string;
|
|
13
|
+
name(): string;
|
|
14
|
+
firstName(): string;
|
|
15
|
+
lastName(): string;
|
|
16
|
+
email(): string;
|
|
17
|
+
number(max?: number): number;
|
|
18
|
+
boolean(): boolean;
|
|
19
|
+
};
|
|
20
|
+
/** Bun-friendly faker used inside factory `definition()`. */
|
|
21
|
+
export declare function fake(): FakeGenerator;
|
|
22
|
+
/**
|
|
23
|
+
* Model factory — `definition` / `make` / `create` / `count`, plus
|
|
24
|
+
* fluent `state`, `sequence`, relationship helpers, and lifecycle callbacks.
|
|
25
|
+
*/
|
|
26
|
+
/** Marks a factory after `count()`, so `create()` / `make()` type as `T[]`. */
|
|
27
|
+
type ManyModels = {
|
|
28
|
+
readonly __many: true;
|
|
29
|
+
};
|
|
30
|
+
export declare abstract class Factory<T extends Model = Model> {
|
|
31
|
+
#private;
|
|
32
|
+
protected abstract model(): ModelClass;
|
|
33
|
+
/** Public model class accessor for relationship factories. */
|
|
34
|
+
modelClass(): ModelClass;
|
|
35
|
+
definition(): FactoryAttributes | Promise<FactoryAttributes>;
|
|
36
|
+
/** Number of models to make/create. */
|
|
37
|
+
count(amount: number): this & ManyModels;
|
|
38
|
+
/** Merge attribute overrides (object or callback) into every model. */
|
|
39
|
+
state(state: FactoryState): this;
|
|
40
|
+
/**
|
|
41
|
+
* Cycle attribute sets across models.
|
|
42
|
+
* Sequence callbacks receive a 1-based index.
|
|
43
|
+
*/
|
|
44
|
+
sequence(...states: FactorySequenceState[]): this;
|
|
45
|
+
/** Run after an in-memory model is built (`make` / before persist). */
|
|
46
|
+
afterMaking(callback: (model: T) => void | Promise<void>): this;
|
|
47
|
+
/** Run after a model is persisted (`create`). */
|
|
48
|
+
afterCreating(callback: (model: T) => void | Promise<void>): this;
|
|
49
|
+
/**
|
|
50
|
+
* Create related models after the parent (`has(PostFactory.new().count(3))`).
|
|
51
|
+
*/
|
|
52
|
+
has(factory: Factory, relationship?: string): this;
|
|
53
|
+
/**
|
|
54
|
+
* Associate a parent model / factory (`for(TeamFactory.new())`).
|
|
55
|
+
*/
|
|
56
|
+
for(parent: Model | Factory, relationship?: string): this;
|
|
57
|
+
/**
|
|
58
|
+
* Attach related models on a belongs-to-many after create.
|
|
59
|
+
*/
|
|
60
|
+
hasAttached(related: Factory | Model | Model[], pivot?: FactoryAttributes, relationship?: string): this;
|
|
61
|
+
/** `Factory::new()`. */
|
|
62
|
+
static new<T extends Factory>(this: new () => T): T;
|
|
63
|
+
/** Build an in-memory model instance (not persisted). */
|
|
64
|
+
make(attributes?: FactoryAttributes): Promise<this extends ManyModels ? T[] : T>;
|
|
65
|
+
/** Persist model(s) via `Model.forceCreate` (factories set guarded columns too). */
|
|
66
|
+
create(attributes?: FactoryAttributes): Promise<this extends ManyModels ? T[] : T>;
|
|
67
|
+
}
|
|
68
|
+
export {};
|
package/dist/factory.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
const WORDS = [
|
|
2
|
+
"alpha",
|
|
3
|
+
"bravo",
|
|
4
|
+
"cedar",
|
|
5
|
+
"delta",
|
|
6
|
+
"ember",
|
|
7
|
+
"flint",
|
|
8
|
+
"grove",
|
|
9
|
+
"harbor",
|
|
10
|
+
"ivory",
|
|
11
|
+
"jade",
|
|
12
|
+
"kite",
|
|
13
|
+
"lunar",
|
|
14
|
+
"maple",
|
|
15
|
+
"nova",
|
|
16
|
+
"orbit",
|
|
17
|
+
"pine",
|
|
18
|
+
"quartz",
|
|
19
|
+
"river",
|
|
20
|
+
"sage",
|
|
21
|
+
"tide",
|
|
22
|
+
];
|
|
23
|
+
function pickWord() {
|
|
24
|
+
return WORDS[Math.floor(Math.random() * WORDS.length)];
|
|
25
|
+
}
|
|
26
|
+
/** Bun-friendly faker used inside factory `definition()`. */
|
|
27
|
+
export function fake() {
|
|
28
|
+
return {
|
|
29
|
+
uuid: () => crypto.randomUUID(),
|
|
30
|
+
word: () => pickWord(),
|
|
31
|
+
words(count = 3) {
|
|
32
|
+
return Array.from({ length: count }, () => pickWord()).join(" ");
|
|
33
|
+
},
|
|
34
|
+
sentence() {
|
|
35
|
+
const text = this.words(6);
|
|
36
|
+
return text.charAt(0).toUpperCase() + text.slice(1) + ".";
|
|
37
|
+
},
|
|
38
|
+
firstName: () => pickWord().replace(/^./, (c) => c.toUpperCase()),
|
|
39
|
+
lastName: () => pickWord().replace(/^./, (c) => c.toUpperCase()),
|
|
40
|
+
name() {
|
|
41
|
+
return `${this.firstName()} ${this.lastName()}`;
|
|
42
|
+
},
|
|
43
|
+
email() {
|
|
44
|
+
return `${pickWord()}.${pickWord()}${Math.floor(Math.random() * 1000)}@example.test`;
|
|
45
|
+
},
|
|
46
|
+
number(max = 1000) {
|
|
47
|
+
return Math.floor(Math.random() * Math.max(1, max));
|
|
48
|
+
},
|
|
49
|
+
boolean: () => Math.random() >= 0.5,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function singularTable(table) {
|
|
53
|
+
if (table.endsWith("ies"))
|
|
54
|
+
return `${table.slice(0, -3)}y`;
|
|
55
|
+
if (table.endsWith("s"))
|
|
56
|
+
return table.slice(0, -1);
|
|
57
|
+
return table;
|
|
58
|
+
}
|
|
59
|
+
function guessRelationName(from, related) {
|
|
60
|
+
const base = related.name.replace(/Factory$/, "");
|
|
61
|
+
const singular = base.charAt(0).toLowerCase() + base.slice(1);
|
|
62
|
+
const plural = singular.endsWith("s") ? singular : `${singular}s`;
|
|
63
|
+
const parent = new from();
|
|
64
|
+
for (const candidate of [plural, singular]) {
|
|
65
|
+
try {
|
|
66
|
+
parent.related(candidate);
|
|
67
|
+
return candidate;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// try next
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return plural;
|
|
74
|
+
}
|
|
75
|
+
export class Factory {
|
|
76
|
+
#count = 1;
|
|
77
|
+
#many = false;
|
|
78
|
+
#states = [];
|
|
79
|
+
#sequences = [];
|
|
80
|
+
#sequenceIndex = 0;
|
|
81
|
+
#afterMaking = [];
|
|
82
|
+
#afterCreating = [];
|
|
83
|
+
#has = [];
|
|
84
|
+
#for = [];
|
|
85
|
+
#hasAttached = [];
|
|
86
|
+
/** Public model class accessor for relationship factories. */
|
|
87
|
+
modelClass() {
|
|
88
|
+
return this.model();
|
|
89
|
+
}
|
|
90
|
+
definition() {
|
|
91
|
+
return {};
|
|
92
|
+
}
|
|
93
|
+
/** Number of models to make/create. */
|
|
94
|
+
count(amount) {
|
|
95
|
+
this.#count = amount;
|
|
96
|
+
this.#many = true;
|
|
97
|
+
return this;
|
|
98
|
+
}
|
|
99
|
+
/** Merge attribute overrides (object or callback) into every model. */
|
|
100
|
+
state(state) {
|
|
101
|
+
this.#states.push(state);
|
|
102
|
+
return this;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Cycle attribute sets across models.
|
|
106
|
+
* Sequence callbacks receive a 1-based index.
|
|
107
|
+
*/
|
|
108
|
+
sequence(...states) {
|
|
109
|
+
this.#sequences.push(...states);
|
|
110
|
+
return this;
|
|
111
|
+
}
|
|
112
|
+
/** Run after an in-memory model is built (`make` / before persist). */
|
|
113
|
+
afterMaking(callback) {
|
|
114
|
+
this.#afterMaking.push(callback);
|
|
115
|
+
return this;
|
|
116
|
+
}
|
|
117
|
+
/** Run after a model is persisted (`create`). */
|
|
118
|
+
afterCreating(callback) {
|
|
119
|
+
this.#afterCreating.push(callback);
|
|
120
|
+
return this;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Create related models after the parent (`has(PostFactory.new().count(3))`).
|
|
124
|
+
*/
|
|
125
|
+
has(factory, relationship) {
|
|
126
|
+
this.#has.push({ factory, relationship });
|
|
127
|
+
return this;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Associate a parent model / factory (`for(TeamFactory.new())`).
|
|
131
|
+
*/
|
|
132
|
+
for(parent, relationship) {
|
|
133
|
+
this.#for.push({ parent, relationship });
|
|
134
|
+
return this;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Attach related models on a belongs-to-many after create.
|
|
138
|
+
*/
|
|
139
|
+
hasAttached(related, pivot = {}, relationship) {
|
|
140
|
+
this.#hasAttached.push({ related, pivot, relationship });
|
|
141
|
+
return this;
|
|
142
|
+
}
|
|
143
|
+
/** `Factory::new()`. */
|
|
144
|
+
static new() {
|
|
145
|
+
return new this();
|
|
146
|
+
}
|
|
147
|
+
async #resolveAttributes(attributes = {}) {
|
|
148
|
+
let attrs = { ...(await this.definition()) };
|
|
149
|
+
for (const state of this.#states) {
|
|
150
|
+
const patch = typeof state === "function" ? await state(attrs) : state;
|
|
151
|
+
attrs = { ...attrs, ...patch };
|
|
152
|
+
}
|
|
153
|
+
if (this.#sequences.length > 0) {
|
|
154
|
+
this.#sequenceIndex += 1;
|
|
155
|
+
const seq = this.#sequences[(this.#sequenceIndex - 1) % this.#sequences.length];
|
|
156
|
+
const patch = typeof seq === "function" ? seq(this.#sequenceIndex) : seq;
|
|
157
|
+
attrs = { ...attrs, ...patch };
|
|
158
|
+
}
|
|
159
|
+
for (const spec of this.#for) {
|
|
160
|
+
const parentModel = spec.parent instanceof Factory
|
|
161
|
+
? (await spec.parent.create())
|
|
162
|
+
: spec.parent;
|
|
163
|
+
const ParentCtor = parentModel.constructor;
|
|
164
|
+
const fkName = spec.relationship?.endsWith("_id")
|
|
165
|
+
? spec.relationship
|
|
166
|
+
: spec.relationship
|
|
167
|
+
? `${spec.relationship}_id`
|
|
168
|
+
: `${singularTable(ParentCtor.table)}_id`;
|
|
169
|
+
attrs[fkName] = parentModel[ParentCtor.primaryKey];
|
|
170
|
+
}
|
|
171
|
+
return { ...attrs, ...attributes };
|
|
172
|
+
}
|
|
173
|
+
async #createRelated(parent) {
|
|
174
|
+
const ParentCtor = parent.constructor;
|
|
175
|
+
const parentId = parent[ParentCtor.primaryKey];
|
|
176
|
+
for (const spec of this.#has) {
|
|
177
|
+
const RelatedCtor = spec.factory.modelClass();
|
|
178
|
+
const relName = spec.relationship ?? guessRelationName(ParentCtor, RelatedCtor);
|
|
179
|
+
let fk = `${singularTable(ParentCtor.table)}_id`;
|
|
180
|
+
let useAttach = false;
|
|
181
|
+
try {
|
|
182
|
+
const rel = parent.related(relName);
|
|
183
|
+
if (typeof rel.attach === "function") {
|
|
184
|
+
useAttach = true;
|
|
185
|
+
}
|
|
186
|
+
else if (typeof rel.getForeignKeyName === "function") {
|
|
187
|
+
fk = rel.getForeignKeyName();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
// use default fk
|
|
192
|
+
}
|
|
193
|
+
if (useAttach) {
|
|
194
|
+
const related = await spec.factory.create();
|
|
195
|
+
const list = Array.isArray(related) ? related : [related];
|
|
196
|
+
const rel = parent.related(relName);
|
|
197
|
+
const ids = list.map((m) => m[m.constructor.primaryKey]);
|
|
198
|
+
await rel.attach(ids);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
await spec.factory.state({ [fk]: parentId }).create();
|
|
202
|
+
}
|
|
203
|
+
for (const spec of this.#hasAttached) {
|
|
204
|
+
let models;
|
|
205
|
+
if (spec.related instanceof Factory) {
|
|
206
|
+
const created = await spec.related.create();
|
|
207
|
+
models = Array.isArray(created) ? created : [created];
|
|
208
|
+
}
|
|
209
|
+
else if (Array.isArray(spec.related)) {
|
|
210
|
+
models = spec.related;
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
models = [spec.related];
|
|
214
|
+
}
|
|
215
|
+
if (models.length === 0)
|
|
216
|
+
continue;
|
|
217
|
+
const RelatedCtor = models[0].constructor;
|
|
218
|
+
const relName = spec.relationship ?? guessRelationName(ParentCtor, RelatedCtor);
|
|
219
|
+
const rel = parent.related(relName);
|
|
220
|
+
const ids = models.map((m) => m[m.constructor.primaryKey]);
|
|
221
|
+
await rel.attach(ids);
|
|
222
|
+
void spec.pivot;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/** Build an in-memory model instance (not persisted). */
|
|
226
|
+
async make(attributes = {}) {
|
|
227
|
+
const n = this.#count;
|
|
228
|
+
const many = this.#many;
|
|
229
|
+
this.#count = 1;
|
|
230
|
+
this.#many = false;
|
|
231
|
+
const ModelClass = this.model();
|
|
232
|
+
const items = [];
|
|
233
|
+
for (let i = 0; i < n; i++) {
|
|
234
|
+
const base = await this.#resolveAttributes(attributes);
|
|
235
|
+
const model = new ModelClass(base);
|
|
236
|
+
for (const cb of this.#afterMaking) {
|
|
237
|
+
await cb(model);
|
|
238
|
+
}
|
|
239
|
+
items.push(model);
|
|
240
|
+
}
|
|
241
|
+
return (many ? items : items[0]);
|
|
242
|
+
}
|
|
243
|
+
/** Persist model(s) via `Model.forceCreate` (factories set guarded columns too). */
|
|
244
|
+
async create(attributes = {}) {
|
|
245
|
+
const n = this.#count;
|
|
246
|
+
const many = this.#many;
|
|
247
|
+
this.#count = 1;
|
|
248
|
+
this.#many = false;
|
|
249
|
+
const ModelClass = this.model();
|
|
250
|
+
const items = [];
|
|
251
|
+
for (let i = 0; i < n; i++) {
|
|
252
|
+
const base = await this.#resolveAttributes(attributes);
|
|
253
|
+
const model = (await ModelClass.forceCreate(base));
|
|
254
|
+
for (const cb of this.#afterMaking) {
|
|
255
|
+
await cb(model);
|
|
256
|
+
}
|
|
257
|
+
for (const cb of this.#afterCreating) {
|
|
258
|
+
await cb(model);
|
|
259
|
+
}
|
|
260
|
+
await this.#createRelated(model);
|
|
261
|
+
items.push(model);
|
|
262
|
+
}
|
|
263
|
+
return (many ? items : items[0]);
|
|
264
|
+
}
|
|
265
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { Model, ModelQuery, HasMany, HasManyThrough, HasOne, HasOneThrough, BelongsTo, BelongsToMany, MorphToMany, MorphMany, MorphOne, MorphTo, morphMap, clearMorphMap, eagerLoadModels, eagerLoadAggregates, ModelNotFoundException, LazyLoadingViolationException, MassAssignmentException, MissingAttributeException, resetModelStrictnessForTests, isModelCtor, Attribute, AsCollection, AsFluent, AsEnumCollection, resetModelEventsForTests, type ModelClass, type RelationFactory, type RelationsMap, type CastType, type CastDefinition, type EnumCast, type ModelEventName, type ModelEventListener, type ModelObserver, type ModelQueryOptions, type EagerAggregateSpec, type MorphToManyOptions, resolveConnection, } from "./model.js";
|
|
2
|
+
export type { GlobalScopeCallback } from "./scopes.js";
|
|
3
|
+
export { clearGlobalScopes } from "./scopes.js";
|
|
4
|
+
export { UseBuilder, resolveModelBuilder, type ModelBuilderConstructor, } from "./decorators.js";
|
|
5
|
+
export { HasUuids, HasUlids, HasFactory, SoftDeletes, Prunable, MassPrunable, prune, pruneAll, prunableModelClasses, massPrunableModelClasses, clearPrunableRegistries, uuid7, ulid, } from "./concerns.js";
|
|
6
|
+
export { OrmCollection } from "./orm-collection.js";
|
|
7
|
+
export { Factory, fake, type FactoryAttributes, type FactoryState, type FactorySequenceState, type FakeGenerator, } from "./factory.js";
|
|
8
|
+
export { Pivot } from "./pivot.js";
|
|
9
|
+
export { LengthAwarePaginator } from "@bunyad/database";
|
|
10
|
+
export { Collection, collect } from "@bunyad/common";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { Model, ModelQuery, HasMany, HasManyThrough, HasOne, HasOneThrough, BelongsTo, BelongsToMany, MorphToMany, MorphMany, MorphOne, MorphTo, morphMap, clearMorphMap, eagerLoadModels, eagerLoadAggregates, ModelNotFoundException, LazyLoadingViolationException, MassAssignmentException, MissingAttributeException, resetModelStrictnessForTests, isModelCtor, Attribute, AsCollection, AsFluent, AsEnumCollection, resetModelEventsForTests, resolveConnection, } from "./model.js";
|
|
2
|
+
export { clearGlobalScopes } from "./scopes.js";
|
|
3
|
+
export { UseBuilder, resolveModelBuilder, } from "./decorators.js";
|
|
4
|
+
export { HasUuids, HasUlids, HasFactory, SoftDeletes, Prunable, MassPrunable, prune, pruneAll, prunableModelClasses, massPrunableModelClasses, clearPrunableRegistries, uuid7, ulid, } from "./concerns.js";
|
|
5
|
+
export { OrmCollection } from "./orm-collection.js";
|
|
6
|
+
export { Factory, fake, } from "./factory.js";
|
|
7
|
+
export { Pivot } from "./pivot.js";
|
|
8
|
+
export { LengthAwarePaginator } from "@bunyad/database";
|
|
9
|
+
export { Collection, collect } from "@bunyad/common";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Model } from "./model.js";
|
|
2
|
+
/** Eloquent model lifecycle event names. */
|
|
3
|
+
export type ModelEventName = "retrieved" | "creating" | "created" | "updating" | "updated" | "saving" | "saved" | "deleting" | "deleted" | "trashed" | "forceDeleting" | "forceDeleted" | "restoring" | "restored" | "replicating";
|
|
4
|
+
export declare const MODEL_EVENT_NAMES: readonly ModelEventName[];
|
|
5
|
+
/**
|
|
6
|
+
* Listener for a model event.
|
|
7
|
+
* Return `false` from a before-event (`creating`, `saving`, …) to cancel.
|
|
8
|
+
*/
|
|
9
|
+
export type ModelEventListener<T extends Model = Model> = (model: T) => void | boolean | Promise<void | boolean>;
|
|
10
|
+
/** Observer object with optional methods named after events. */
|
|
11
|
+
export type ModelObserver<T extends Model = Model> = {
|
|
12
|
+
[K in ModelEventName]?: (model: T) => void | boolean | Promise<void | boolean>;
|
|
13
|
+
};
|
|
14
|
+
type ModelCtor = abstract new (...args: never[]) => Model;
|
|
15
|
+
/** Whether events are currently suppressed for this model class. */
|
|
16
|
+
export declare function modelEventsDisabled(ctor: ModelCtor): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Ensure `boot` / `booted` have run once for this model class.
|
|
19
|
+
* Call sites: first query, save, or `static::created(...)` registration.
|
|
20
|
+
*/
|
|
21
|
+
export declare function bootIfNotBooted(ctor: ModelCtor & {
|
|
22
|
+
boot?: () => void;
|
|
23
|
+
booted?: () => void;
|
|
24
|
+
}): void;
|
|
25
|
+
/** Register a listener for a model event on this class. */
|
|
26
|
+
export declare function registerModelEvent(ctor: ModelCtor, event: ModelEventName, listener: ModelEventListener): void;
|
|
27
|
+
/**
|
|
28
|
+
* Fire a model event. Returns `false` if a listener cancelled the operation.
|
|
29
|
+
* After-events (`created`, `saved`, …) ignore `false` return values for control flow
|
|
30
|
+
* but still run all listeners.
|
|
31
|
+
* No-listener path is synchronous so SQLite save/find skip Promise microtasks.
|
|
32
|
+
*/
|
|
33
|
+
export declare function fireModelEvent(model: Model, event: ModelEventName): boolean | Promise<boolean>;
|
|
34
|
+
/** True when at least one listener is registered for this event. */
|
|
35
|
+
export declare function hasModelEventListeners(ctor: ModelCtor, event: ModelEventName): boolean;
|
|
36
|
+
export declare function hasAnyModelEventListeners(ctor: ModelCtor): boolean;
|
|
37
|
+
/** Run `callback` with model events disabled for `ctor`. */
|
|
38
|
+
export declare function withoutModelEvents<T>(ctor: ModelCtor, callback: () => T | Promise<T>): T | Promise<T>;
|
|
39
|
+
/** Register an observer instance or class (Laravel `Model::observe`). */
|
|
40
|
+
export declare function observeModel<T extends Model>(ctor: abstract new (...args: never[]) => T, observer: ModelObserver<T> | (new () => ModelObserver<T>) | Array<ModelObserver<T> | (new () => ModelObserver<T>)>): void;
|
|
41
|
+
/** Test helper — clear boot state and listeners for a model class. */
|
|
42
|
+
export declare function resetModelEventsForTests(ctor: ModelCtor): void;
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
export const MODEL_EVENT_NAMES = [
|
|
2
|
+
"retrieved",
|
|
3
|
+
"creating",
|
|
4
|
+
"created",
|
|
5
|
+
"updating",
|
|
6
|
+
"updated",
|
|
7
|
+
"saving",
|
|
8
|
+
"saved",
|
|
9
|
+
"deleting",
|
|
10
|
+
"deleted",
|
|
11
|
+
"trashed",
|
|
12
|
+
"forceDeleting",
|
|
13
|
+
"forceDeleted",
|
|
14
|
+
"restoring",
|
|
15
|
+
"restored",
|
|
16
|
+
"replicating",
|
|
17
|
+
];
|
|
18
|
+
const booted = new WeakSet();
|
|
19
|
+
const listeners = new WeakMap();
|
|
20
|
+
const eventsDisabled = new WeakMap();
|
|
21
|
+
function listenerMap(ctor) {
|
|
22
|
+
let map = listeners.get(ctor);
|
|
23
|
+
if (!map) {
|
|
24
|
+
map = new Map();
|
|
25
|
+
listeners.set(ctor, map);
|
|
26
|
+
}
|
|
27
|
+
return map;
|
|
28
|
+
}
|
|
29
|
+
/** Whether events are currently suppressed for this model class. */
|
|
30
|
+
export function modelEventsDisabled(ctor) {
|
|
31
|
+
return (eventsDisabled.get(ctor) ?? 0) > 0;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Ensure `boot` / `booted` have run once for this model class.
|
|
35
|
+
* Call sites: first query, save, or `static::created(...)` registration.
|
|
36
|
+
*/
|
|
37
|
+
export function bootIfNotBooted(ctor) {
|
|
38
|
+
if (booted.has(ctor))
|
|
39
|
+
return;
|
|
40
|
+
booted.add(ctor);
|
|
41
|
+
ctor.boot?.();
|
|
42
|
+
ctor.booted?.();
|
|
43
|
+
}
|
|
44
|
+
/** Register a listener for a model event on this class. */
|
|
45
|
+
export function registerModelEvent(ctor, event, listener) {
|
|
46
|
+
bootIfNotBooted(ctor);
|
|
47
|
+
const map = listenerMap(ctor);
|
|
48
|
+
const list = map.get(event) ?? [];
|
|
49
|
+
list.push({ listener });
|
|
50
|
+
map.set(event, list);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Fire a model event. Returns `false` if a listener cancelled the operation.
|
|
54
|
+
* After-events (`created`, `saved`, …) ignore `false` return values for control flow
|
|
55
|
+
* but still run all listeners.
|
|
56
|
+
* No-listener path is synchronous so SQLite save/find skip Promise microtasks.
|
|
57
|
+
*/
|
|
58
|
+
export function fireModelEvent(model, event) {
|
|
59
|
+
const ctor = model.constructor;
|
|
60
|
+
if (modelEventsDisabled(ctor))
|
|
61
|
+
return true;
|
|
62
|
+
const list = listeners.get(ctor)?.get(event);
|
|
63
|
+
if (!list || list.length === 0)
|
|
64
|
+
return true;
|
|
65
|
+
return fireModelEventList(model, event, list);
|
|
66
|
+
}
|
|
67
|
+
async function fireModelEventList(model, event, list) {
|
|
68
|
+
const isBefore = event.endsWith("ing") && event !== "replicating";
|
|
69
|
+
for (const entry of list) {
|
|
70
|
+
const result = await entry.listener(model);
|
|
71
|
+
if (isBefore && result === false)
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
/** True when at least one listener is registered for this event. */
|
|
77
|
+
export function hasModelEventListeners(ctor, event) {
|
|
78
|
+
if (modelEventsDisabled(ctor))
|
|
79
|
+
return false;
|
|
80
|
+
const list = listeners.get(ctor)?.get(event);
|
|
81
|
+
return Boolean(list && list.length > 0);
|
|
82
|
+
}
|
|
83
|
+
export function hasAnyModelEventListeners(ctor) {
|
|
84
|
+
if (modelEventsDisabled(ctor))
|
|
85
|
+
return false;
|
|
86
|
+
const map = listeners.get(ctor);
|
|
87
|
+
if (!map)
|
|
88
|
+
return false;
|
|
89
|
+
for (const list of map.values()) {
|
|
90
|
+
if (list.length > 0)
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
/** Run `callback` with model events disabled for `ctor`. */
|
|
96
|
+
export function withoutModelEvents(ctor, callback) {
|
|
97
|
+
eventsDisabled.set(ctor, (eventsDisabled.get(ctor) ?? 0) + 1);
|
|
98
|
+
const finish = () => {
|
|
99
|
+
const depth = (eventsDisabled.get(ctor) ?? 1) - 1;
|
|
100
|
+
if (depth <= 0)
|
|
101
|
+
eventsDisabled.delete(ctor);
|
|
102
|
+
else
|
|
103
|
+
eventsDisabled.set(ctor, depth);
|
|
104
|
+
};
|
|
105
|
+
try {
|
|
106
|
+
const result = callback();
|
|
107
|
+
if (result instanceof Promise) {
|
|
108
|
+
return result.finally(finish);
|
|
109
|
+
}
|
|
110
|
+
finish();
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
finish();
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Register an observer instance or class (Laravel `Model::observe`). */
|
|
119
|
+
export function observeModel(ctor, observer) {
|
|
120
|
+
const list = Array.isArray(observer) ? observer : [observer];
|
|
121
|
+
for (const item of list) {
|
|
122
|
+
const instance = typeof item === "function"
|
|
123
|
+
? new item()
|
|
124
|
+
: item;
|
|
125
|
+
for (const event of MODEL_EVENT_NAMES) {
|
|
126
|
+
const method = instance[event];
|
|
127
|
+
if (typeof method === "function") {
|
|
128
|
+
registerModelEvent(ctor, event, (model) => method.call(instance, model));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** Test helper — clear boot state and listeners for a model class. */
|
|
134
|
+
export function resetModelEventsForTests(ctor) {
|
|
135
|
+
booted.delete(ctor);
|
|
136
|
+
listeners.delete(ctor);
|
|
137
|
+
eventsDisabled.delete(ctor);
|
|
138
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared ORM helpers (Model / relations / eager / ModelQuery).
|
|
3
|
+
*/
|
|
4
|
+
import type { Connection } from "@bunyad/database";
|
|
5
|
+
import type { ModelClass } from "./model.js";
|
|
6
|
+
export declare function singular(name: string): string;
|
|
7
|
+
/** Parse `relation as alias` strings. */
|
|
8
|
+
export declare function parseRelationAlias(raw: string): {
|
|
9
|
+
relation: string;
|
|
10
|
+
alias?: string;
|
|
11
|
+
};
|
|
12
|
+
/** Constraint applied while eager-loading a relation (`with({ posts: (q) => … })`). */
|
|
13
|
+
export type EagerRelationConstraint = (query: {
|
|
14
|
+
where: (...args: never[]) => unknown;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
}) => void;
|
|
17
|
+
/** Normalized eager-load entry (path + optional constraint). */
|
|
18
|
+
export type NormalizedEagerRelation = {
|
|
19
|
+
name: string;
|
|
20
|
+
constraint?: EagerRelationConstraint;
|
|
21
|
+
};
|
|
22
|
+
/** Flatten `with('a', { b: true, c: fn }, ['d'])` into path + constraint entries. */
|
|
23
|
+
export declare function normalizeWithRelations(relations: Array<string | string[] | Record<string, unknown>>): NormalizedEagerRelation[];
|
|
24
|
+
/** Coerce string paths or normalized entries for eager-load callers. */
|
|
25
|
+
export declare function coerceEagerRelations(relations: Array<string | NormalizedEagerRelation>): NormalizedEagerRelation[];
|
|
26
|
+
export declare function usesSoftDeletes(model: ModelClass): boolean;
|
|
27
|
+
export declare function deletedAtColumn(model: ModelClass): string;
|
|
28
|
+
/** Soft-delete predicate for a quoted table/alias (empty when model is not soft-deleting). */
|
|
29
|
+
export declare function softDeleteAliasSql(Related: ModelClass, aliasSql: string): string;
|
|
30
|
+
/** Current timestamp in the active driver's storage form. */
|
|
31
|
+
export declare function nowForConnection(connection?: Connection): Date | string;
|
|
32
|
+
export declare function uniqueKeys(values: unknown[]): unknown[];
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { connections, dateTimeForStorage, wrapSqlName } from "@bunyad/database";
|
|
2
|
+
export function singular(name) {
|
|
3
|
+
if (name.endsWith("ies"))
|
|
4
|
+
return `${name.slice(0, -3)}y`;
|
|
5
|
+
if (name.endsWith("s"))
|
|
6
|
+
return name.slice(0, -1);
|
|
7
|
+
return name;
|
|
8
|
+
}
|
|
9
|
+
/** Parse `relation as alias` strings. */
|
|
10
|
+
export function parseRelationAlias(raw) {
|
|
11
|
+
const text = typeof raw === "string" ? raw : String(raw ?? "");
|
|
12
|
+
const m = text.match(/^(.+?)\s+as\s+(.+)$/i);
|
|
13
|
+
if (!m)
|
|
14
|
+
return { relation: text.trim() };
|
|
15
|
+
return { relation: m[1].trim(), alias: m[2].trim() };
|
|
16
|
+
}
|
|
17
|
+
/** Flatten `with('a', { b: true, c: fn }, ['d'])` into path + constraint entries. */
|
|
18
|
+
export function normalizeWithRelations(relations) {
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const rel of relations) {
|
|
21
|
+
if (typeof rel === "string") {
|
|
22
|
+
if (rel.trim())
|
|
23
|
+
out.push({ name: rel });
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (Array.isArray(rel)) {
|
|
27
|
+
for (const name of rel) {
|
|
28
|
+
if (typeof name === "string" && name.trim())
|
|
29
|
+
out.push({ name });
|
|
30
|
+
}
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (rel && typeof rel === "object") {
|
|
34
|
+
for (const [name, value] of Object.entries(rel)) {
|
|
35
|
+
if (value === false || value == null)
|
|
36
|
+
continue;
|
|
37
|
+
if (typeof name !== "string" || !name.trim())
|
|
38
|
+
continue;
|
|
39
|
+
if (typeof value === "function") {
|
|
40
|
+
out.push({
|
|
41
|
+
name,
|
|
42
|
+
constraint: value,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
out.push({ name });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
/** Coerce string paths or normalized entries for eager-load callers. */
|
|
54
|
+
export function coerceEagerRelations(relations) {
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const rel of relations) {
|
|
57
|
+
if (typeof rel === "string") {
|
|
58
|
+
if (rel.trim())
|
|
59
|
+
out.push({ name: rel });
|
|
60
|
+
}
|
|
61
|
+
else if (rel?.name?.trim()) {
|
|
62
|
+
out.push(rel);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
export function usesSoftDeletes(model) {
|
|
68
|
+
return model.softDeletes === true;
|
|
69
|
+
}
|
|
70
|
+
export function deletedAtColumn(model) {
|
|
71
|
+
return model.deletedAt ?? "deleted_at";
|
|
72
|
+
}
|
|
73
|
+
/** Soft-delete predicate for a quoted table/alias (empty when model is not soft-deleting). */
|
|
74
|
+
export function softDeleteAliasSql(Related, aliasSql) {
|
|
75
|
+
if (!usesSoftDeletes(Related))
|
|
76
|
+
return "";
|
|
77
|
+
const dialect = Related.getConnection().dialect;
|
|
78
|
+
const col = wrapSqlName(dialect, deletedAtColumn(Related));
|
|
79
|
+
return ` AND ${aliasSql}.${col} IS NULL`;
|
|
80
|
+
}
|
|
81
|
+
/** Current timestamp in the active driver's storage form. */
|
|
82
|
+
export function nowForConnection(connection) {
|
|
83
|
+
const driver = connection?.driver ?? connections.connection().driver ?? "sqlite";
|
|
84
|
+
return dateTimeForStorage(new Date(), driver);
|
|
85
|
+
}
|
|
86
|
+
export function uniqueKeys(values) {
|
|
87
|
+
const seen = new Set();
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const value of values) {
|
|
90
|
+
if (value === undefined || value === null)
|
|
91
|
+
continue;
|
|
92
|
+
const key = String(value);
|
|
93
|
+
if (seen.has(key))
|
|
94
|
+
continue;
|
|
95
|
+
seen.add(key);
|
|
96
|
+
out.push(value);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|