@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.
@@ -0,0 +1,37 @@
1
+ import { Collection } from "@bunyad/common";
2
+ import type { Model } from "./model.js";
3
+ /**
4
+ * ORM model collection (`get()` / relation `get()`).
5
+ */
6
+ export declare class OrmCollection<T extends Model = Model> extends Collection<T> {
7
+ #private;
8
+ constructor(items?: T[] | Collection<T> | Iterable<T> | null | undefined, options?: {
9
+ owned?: boolean;
10
+ });
11
+ /** Fresh `items` array — no copy. */
12
+ protected make(items: T[]): OrmCollection<T>;
13
+ /**
14
+ * Eager-load relations onto every model (`load('posts')` / constrained maps).
15
+ */
16
+ load(...relations: Array<string | Record<string, unknown>>): Promise<this>;
17
+ /**
18
+ * Load only relations that are not already present (`loadMissing`).
19
+ */
20
+ loadMissing(...relations: Array<string | Record<string, unknown>>): Promise<this>;
21
+ /** Laravel `$collection->loadCount(...)` — batched GROUP BY per relation. */
22
+ loadCount(...relations: string[]): Promise<this>;
23
+ loadSum(relation: string, column: string): Promise<this>;
24
+ loadAvg(relation: string, column: string): Promise<this>;
25
+ loadMin(relation: string, column: string): Promise<this>;
26
+ loadMax(relation: string, column: string): Promise<this>;
27
+ loadExists(...relations: string[]): Promise<this>;
28
+ /** Primary keys of all models. */
29
+ modelKeys(): Collection<unknown>;
30
+ /**
31
+ * Find model(s) by primary key (`$collection->find($id)`).
32
+ * Pass an array of keys to return a filtered OrmCollection.
33
+ */
34
+ find(key: string | number | bigint): T | null;
35
+ find(keys: Array<string | number | bigint>): OrmCollection<T>;
36
+ findOrFail(key: string | number | bigint): T;
37
+ }
@@ -0,0 +1,150 @@
1
+ import { Collection } from "@bunyad/common";
2
+ function relationLoaded(model, name) {
3
+ return Object.prototype.hasOwnProperty.call(model, name);
4
+ }
5
+ function parseRelationAlias(raw) {
6
+ const m = raw.match(/^(.+?)\s+as\s+(.+)$/i);
7
+ if (!m)
8
+ return { relation: raw.trim() };
9
+ return { relation: m[1].trim(), alias: m[2].trim() };
10
+ }
11
+ /**
12
+ * ORM model collection (`get()` / relation `get()`).
13
+ */
14
+ export class OrmCollection extends Collection {
15
+ constructor(items = [], options) {
16
+ super(items, options);
17
+ }
18
+ /** Fresh `items` array — no copy. */
19
+ make(items) {
20
+ return new OrmCollection(items, { owned: true });
21
+ }
22
+ /**
23
+ * Eager-load relations onto every model (`load('posts')` / constrained maps).
24
+ */
25
+ async load(...relations) {
26
+ if (relations.length === 0 || this.isEmpty())
27
+ return this;
28
+ const { eagerLoadModels } = await import("./model.js");
29
+ const { normalizeWithRelations } = await import("./model-helpers.js");
30
+ await eagerLoadModels(this.all(), normalizeWithRelations(relations));
31
+ return this;
32
+ }
33
+ /**
34
+ * Load only relations that are not already present (`loadMissing`).
35
+ */
36
+ async loadMissing(...relations) {
37
+ const { normalizeWithRelations } = await import("./model-helpers.js");
38
+ const normalized = normalizeWithRelations(relations);
39
+ const missing = normalized.filter((entry) => this.items.some((model) => !relationLoaded(model, entry.name)));
40
+ if (missing.length === 0)
41
+ return this;
42
+ if (this.isEmpty())
43
+ return this;
44
+ const { eagerLoadModels } = await import("./model.js");
45
+ await eagerLoadModels(this.all(), missing);
46
+ return this;
47
+ }
48
+ /** Laravel `$collection->loadCount(...)` — batched GROUP BY per relation. */
49
+ async loadCount(...relations) {
50
+ return this.#loadAggregates(relations.flat().map((raw) => {
51
+ const parsed = parseRelationAlias(raw);
52
+ return {
53
+ relation: parsed.relation,
54
+ alias: parsed.alias ?? `${parsed.relation}_count`,
55
+ fn: "count",
56
+ };
57
+ }));
58
+ }
59
+ async loadSum(relation, column) {
60
+ const parsed = parseRelationAlias(relation);
61
+ return this.#loadAggregates([
62
+ {
63
+ relation: parsed.relation,
64
+ alias: parsed.alias ?? `${parsed.relation}_sum_${column}`,
65
+ fn: "sum",
66
+ column,
67
+ },
68
+ ]);
69
+ }
70
+ async loadAvg(relation, column) {
71
+ const parsed = parseRelationAlias(relation);
72
+ return this.#loadAggregates([
73
+ {
74
+ relation: parsed.relation,
75
+ alias: parsed.alias ?? `${parsed.relation}_avg_${column}`,
76
+ fn: "avg",
77
+ column,
78
+ },
79
+ ]);
80
+ }
81
+ async loadMin(relation, column) {
82
+ const parsed = parseRelationAlias(relation);
83
+ return this.#loadAggregates([
84
+ {
85
+ relation: parsed.relation,
86
+ alias: parsed.alias ?? `${parsed.relation}_min_${column}`,
87
+ fn: "min",
88
+ column,
89
+ },
90
+ ]);
91
+ }
92
+ async loadMax(relation, column) {
93
+ const parsed = parseRelationAlias(relation);
94
+ return this.#loadAggregates([
95
+ {
96
+ relation: parsed.relation,
97
+ alias: parsed.alias ?? `${parsed.relation}_max_${column}`,
98
+ fn: "max",
99
+ column,
100
+ },
101
+ ]);
102
+ }
103
+ async loadExists(...relations) {
104
+ return this.#loadAggregates(relations.flat().map((raw) => {
105
+ const parsed = parseRelationAlias(raw);
106
+ return {
107
+ relation: parsed.relation,
108
+ alias: parsed.alias ?? `${parsed.relation}_exists`,
109
+ fn: "exists",
110
+ };
111
+ }));
112
+ }
113
+ async #loadAggregates(specs) {
114
+ if (specs.length === 0 || this.isEmpty())
115
+ return this;
116
+ const { eagerLoadAggregates } = await import("./model.js");
117
+ await eagerLoadAggregates(this.all(), specs);
118
+ return this;
119
+ }
120
+ /** Primary keys of all models. */
121
+ modelKeys() {
122
+ return this.map((model) => {
123
+ const ctor = model.constructor;
124
+ return model[ctor.primaryKey];
125
+ });
126
+ }
127
+ find(key) {
128
+ if (Array.isArray(key)) {
129
+ const want = new Set(key.map(String));
130
+ return this.make(this.items.filter((model) => {
131
+ const ctor = model.constructor;
132
+ const id = model[ctor.primaryKey];
133
+ return want.has(String(id));
134
+ }));
135
+ }
136
+ const want = String(key);
137
+ return (this.items.find((model) => {
138
+ const ctor = model.constructor;
139
+ const id = model[ctor.primaryKey];
140
+ return String(id) === want;
141
+ }) ?? null);
142
+ }
143
+ findOrFail(key) {
144
+ const model = this.find(key);
145
+ if (!model) {
146
+ throw new Error(`No query results for model [${String(key)}].`);
147
+ }
148
+ return model;
149
+ }
150
+ }
@@ -0,0 +1,9 @@
1
+ import { Model } from "./model.js";
2
+ /**
3
+ * Intermediate table model (`product_categories`, `model_has_roles`, …).
4
+ * Composite / non-incrementing keys — `create()` inserts without `RETURNING id`.
5
+ */
6
+ export declare class Pivot extends Model {
7
+ static incrementing: boolean;
8
+ static timestamps: boolean;
9
+ }
package/dist/pivot.js ADDED
@@ -0,0 +1,9 @@
1
+ import { Model } from "./model.js";
2
+ /**
3
+ * Intermediate table model (`product_categories`, `model_has_roles`, …).
4
+ * Composite / non-incrementing keys — `create()` inserts without `RETURNING id`.
5
+ */
6
+ export class Pivot extends Model {
7
+ static incrementing = false;
8
+ static timestamps = false;
9
+ }
@@ -0,0 +1,335 @@
1
+ import { OrmCollection } from "./orm-collection.js";
2
+ import { Model, type ModelClass } from "./model.js";
3
+ import type { ModelQuery } from "./model-query.js";
4
+ /** ModelQuery methods that mutate the builder and return it — forwarded onto relations. */
5
+ declare const RELATION_QUERY_CHAIN: readonly ["where", "orWhere", "whereIn", "whereNotIn", "whereNull", "whereNotNull", "whereBetween", "whereNotBetween", "whereColumn", "whereRaw", "orWhereRaw", "whereLike", "whereDate", "orderBy", "orderByDesc", "orderByRaw", "latest", "oldest", "limit", "offset", "take", "skip", "forPage", "select", "addSelect", "selectRaw", "groupBy", "having", "havingRaw", "when", "unless", "tap"];
6
+ /** ModelQuery methods that return a value — forwarded onto relations. */
7
+ declare const RELATION_QUERY_TERMINAL: readonly ["count", "exists", "sum", "avg", "min", "max", "toSql", "toRawSql", "getBindings"];
8
+ /** Write terminals, forwarded only onto relations that filter one table by a foreign key. */
9
+ declare const RELATION_QUERY_WRITE: readonly ["update", "delete", "forceDelete"];
10
+ type RelationQueryKey = (typeof RELATION_QUERY_CHAIN)[number] | (typeof RELATION_QUERY_TERMINAL)[number];
11
+ type RelationWriteKey = (typeof RELATION_QUERY_WRITE)[number];
12
+ /** Types for the methods `installRelationQueryForwarders` adds to every relation. */
13
+ type RelationQuery<T extends Model> = Pick<ModelQuery<T>, RelationQueryKey>;
14
+ type RelationWrite<T extends Model> = Pick<ModelQuery<T>, RelationWriteKey>;
15
+ /** Strip `__pivot_*` columns onto `model.pivot`. */
16
+ export declare function applyPivotAttributes(model: Model, attrs: Record<string, unknown>, pivotKeys: string[]): Model;
17
+ export declare function morphMap(map: Record<string, ModelClass>): void;
18
+ export declare function clearMorphMap(): void;
19
+ export declare function morphTypeFor(model: ModelClass): string;
20
+ export declare function resolveMorphType(type: string): ModelClass;
21
+ /** MorphMany relation. */
22
+ export declare class MorphMany<T extends Model = Model> {
23
+ #private;
24
+ private parent;
25
+ private related;
26
+ private typeColumn;
27
+ private idColumn;
28
+ private morphType;
29
+ private localKey?;
30
+ constructor(parent: Model, related: ModelClass, typeColumn: string, idColumn: string, morphType: string, localKey?: string | undefined);
31
+ getRelated(): ModelClass;
32
+ getTypeColumn(): string;
33
+ getIdColumn(): string;
34
+ getMorphType(): string;
35
+ getLocalKeyName(): string;
36
+ /** Constrained related query (`morphMany().where(…).get()`). */
37
+ getQuery(): ModelQuery<T>;
38
+ get(): Promise<OrmCollection<T>>;
39
+ first(): Promise<T | null>;
40
+ create(attributes: Record<string, unknown>): Promise<T>;
41
+ }
42
+ /** MorphOne relation. */
43
+ export declare class MorphOne<T extends Model = Model> {
44
+ #private;
45
+ private parent;
46
+ private related;
47
+ private typeColumn;
48
+ private idColumn;
49
+ private morphType;
50
+ private localKey?;
51
+ constructor(parent: Model, related: ModelClass, typeColumn: string, idColumn: string, morphType: string, localKey?: string | undefined);
52
+ getRelated(): ModelClass;
53
+ getTypeColumn(): string;
54
+ getIdColumn(): string;
55
+ getMorphType(): string;
56
+ getLocalKeyName(): string;
57
+ isOfMany(): boolean;
58
+ getOfManyColumn(): string | undefined;
59
+ getOfManyAggregate(): "min" | "max" | undefined;
60
+ latestOfMany(column?: string): this;
61
+ oldestOfMany(column?: string): this;
62
+ ofMany(column: string, aggregate: "min" | "max"): this;
63
+ getQuery(): ModelQuery<T>;
64
+ get(): Promise<T | null>;
65
+ first(): Promise<T | null>;
66
+ create(attributes: Record<string, unknown>): Promise<T>;
67
+ }
68
+ /** Laravel MorphTo relation. */
69
+ export declare class MorphTo {
70
+ private parent;
71
+ private typeColumn;
72
+ private idColumn;
73
+ private ownerKey?;
74
+ constructor(parent: Model, typeColumn: string, idColumn: string, ownerKey?: string | undefined);
75
+ getTypeColumn(): string;
76
+ getIdColumn(): string;
77
+ getOwnerKeyName(): string | undefined;
78
+ get(): Promise<Model | null>;
79
+ first(): Promise<Model | null>;
80
+ /** Laravel `associate` for morphTo. */
81
+ associate(model: Model): this;
82
+ /** Laravel `dissociate` for morphTo. */
83
+ dissociate(): this;
84
+ }
85
+ /** HasMany relation. */
86
+ export declare class HasMany<T extends Model = Model> {
87
+ #private;
88
+ private parent;
89
+ private related;
90
+ private foreignKey;
91
+ private localKey?;
92
+ constructor(parent: Model, related: ModelClass, foreignKey: string, localKey?: string | undefined);
93
+ getRelated(): ModelClass;
94
+ getForeignKeyName(): string;
95
+ getLocalKeyName(): string;
96
+ /** Constrained related query (`hasMany().where(…).orderBy(…).get()`). */
97
+ getQuery(): ModelQuery<T>;
98
+ get(): Promise<OrmCollection<T>>;
99
+ first(): Promise<T | null>;
100
+ create(attributes: Record<string, unknown>): Promise<T>;
101
+ /** Bump `updated_at` on matching related rows. */
102
+ touch(): Promise<boolean>;
103
+ }
104
+ /** HasOne relation. */
105
+ export declare class HasOne<T extends Model = Model> {
106
+ #private;
107
+ private parent;
108
+ private related;
109
+ private foreignKey;
110
+ private localKey?;
111
+ constructor(parent: Model, related: ModelClass, foreignKey: string, localKey?: string | undefined);
112
+ getRelated(): ModelClass;
113
+ getForeignKeyName(): string;
114
+ getLocalKeyName(): string;
115
+ isOfMany(): boolean;
116
+ getOfManyColumn(): string | undefined;
117
+ getOfManyAggregate(): "min" | "max" | undefined;
118
+ /** Latest related row by column (default primary key). */
119
+ latestOfMany(column?: string): this;
120
+ /** Oldest related row by column (default primary key). */
121
+ oldestOfMany(column?: string): this;
122
+ /** Constrain to the related row with min/max of `column`. */
123
+ ofMany(column: string, aggregate: "min" | "max"): this;
124
+ getQuery(): ModelQuery<T>;
125
+ get(): Promise<T | null>;
126
+ first(): Promise<T | null>;
127
+ create(attributes: Record<string, unknown>): Promise<T>;
128
+ /** Bump `updated_at` on matching related rows. */
129
+ touch(): Promise<boolean>;
130
+ }
131
+ /** HasManyThrough relation (Parent → Through → Related). */
132
+ export declare class HasManyThrough<T extends Model = Model> {
133
+ #private;
134
+ private parent;
135
+ private related;
136
+ private through;
137
+ private firstKey;
138
+ private secondKey;
139
+ private localKey;
140
+ private secondLocalKey;
141
+ constructor(parent: Model, related: ModelClass, through: ModelClass, firstKey: string, secondKey: string, localKey: string, secondLocalKey: string);
142
+ getRelated(): ModelClass;
143
+ getThrough(): ModelClass;
144
+ getFirstKeyName(): string;
145
+ getSecondKeyName(): string;
146
+ getLocalKeyName(): string;
147
+ getSecondLocalKeyName(): string;
148
+ getQuery(): ModelQuery<T>;
149
+ get(): Promise<OrmCollection<T>>;
150
+ first(): Promise<T | null>;
151
+ }
152
+ /**
153
+ * HasOneThrough — same join as HasManyThrough, returns a single related model.
154
+ */
155
+ export declare class HasOneThrough<T extends Model = Model> {
156
+ #private;
157
+ constructor(parent: Model, related: ModelClass, through: ModelClass, firstKey: string, secondKey: string, localKey: string, secondLocalKey: string);
158
+ getRelated(): ModelClass;
159
+ getThrough(): ModelClass;
160
+ getFirstKeyName(): string;
161
+ getSecondKeyName(): string;
162
+ getLocalKeyName(): string;
163
+ getSecondLocalKeyName(): string;
164
+ /** Underlying many-through relation (for eager load reuse). */
165
+ asHasManyThrough(): HasManyThrough<T>;
166
+ getQuery(): ModelQuery<T>;
167
+ get(): Promise<T | null>;
168
+ first(): Promise<T | null>;
169
+ }
170
+ /** BelongsTo relation. */
171
+ export declare class BelongsTo<T extends Model = Model> {
172
+ #private;
173
+ private child;
174
+ private related;
175
+ private foreignKey;
176
+ private ownerKey?;
177
+ constructor(child: Model, related: ModelClass, foreignKey: string, ownerKey?: string | undefined);
178
+ getRelated(): ModelClass;
179
+ getForeignKeyName(): string;
180
+ getOwnerKeyName(): string;
181
+ getQuery(): ModelQuery<T>;
182
+ get(): Promise<T | null>;
183
+ first(): Promise<T | null>;
184
+ /** Set the foreign key from a related model (or null). */
185
+ associate(model: T | null): this;
186
+ /** Clear the foreign key. */
187
+ dissociate(): this;
188
+ /**
189
+ * Bump related parent's `updated_at` (query update).
190
+ * Honors `Model.withoutTouching` / `isIgnoringTouch` on the related class.
191
+ */
192
+ touch(): Promise<boolean>;
193
+ /** Sync variant for Model sync-persist path. */
194
+ touchSync(): boolean;
195
+ }
196
+ /** BelongsToMany relation. */
197
+ export declare class BelongsToMany<T extends Model = Model> {
198
+ #private;
199
+ private parent;
200
+ private related;
201
+ private pivotTable;
202
+ private foreignPivotKey;
203
+ private relatedPivotKey;
204
+ constructor(parent: Model, related: ModelClass, pivotTable: string, foreignPivotKey: string, relatedPivotKey: string);
205
+ getRelated(): ModelClass;
206
+ getPivotTable(): string;
207
+ getForeignPivotKeyName(): string;
208
+ getRelatedPivotKeyName(): string;
209
+ getPivotColumns(): string[];
210
+ /** Include extra pivot table columns on related models as `model.pivot`. */
211
+ withPivot(...columns: Array<string | string[]>): this;
212
+ getQuery(): ModelQuery<T>;
213
+ get(): Promise<OrmCollection<T>>;
214
+ first(): Promise<T | null>;
215
+ attach(ids: Array<string | number> | string | number): Promise<void>;
216
+ detach(ids?: Array<string | number> | string | number): Promise<void>;
217
+ sync(ids: Array<string | number>): Promise<void>;
218
+ /** Attach missing ids only. */
219
+ syncWithoutDetaching(ids: Array<string | number>): Promise<void>;
220
+ /** Attach missing, detach present. */
221
+ toggle(ids: Array<string | number> | string | number): Promise<void>;
222
+ }
223
+ /** Options for `morphToMany` beyond the Laravel positional args. */
224
+ export type MorphToManyOptions = {
225
+ table?: string;
226
+ foreignPivotKey?: string;
227
+ relatedPivotKey?: string;
228
+ morphTypeColumn?: string;
229
+ /** Pivot `model_type` values matched (includes legacy aliases). */
230
+ morphTypes?: string[];
231
+ /** Tenant column on the pivot table (when set, rows are tenant-scoped). */
232
+ pivotTenantKey?: string;
233
+ /**
234
+ * Tenant column on the parent model.
235
+ * Defaults to `pivotTenantKey` when omitted.
236
+ */
237
+ parentTenantKey?: string;
238
+ };
239
+ /** Polymorphic many-to-many (e.g. Contact → Role via `model_has_roles`). */
240
+ export declare class MorphToMany<T extends Model = Model> {
241
+ #private;
242
+ private parent;
243
+ private related;
244
+ private pivotTable;
245
+ private foreignPivotKey;
246
+ private relatedPivotKey;
247
+ private morphTypeColumn;
248
+ private morphTypes;
249
+ private pivotTenantKey?;
250
+ private parentTenantKey?;
251
+ constructor(parent: Model, related: ModelClass, pivotTable: string, foreignPivotKey: string, relatedPivotKey: string, morphTypeColumn: string, morphTypes: string[], pivotTenantKey?: string | undefined, parentTenantKey?: string | undefined);
252
+ getRelated(): ModelClass;
253
+ getPivotTable(): string;
254
+ getForeignPivotKeyName(): string;
255
+ getRelatedPivotKeyName(): string;
256
+ getMorphTypeColumn(): string;
257
+ getMorphTypes(): string[];
258
+ getPivotTenantKey(): string | undefined;
259
+ getParentTenantKey(): string | undefined;
260
+ /** Primary morph type written on attach (first configured type). */
261
+ getMorphType(): string;
262
+ get(): Promise<OrmCollection<T>>;
263
+ attach(ids: Array<string | number> | string | number): Promise<void>;
264
+ detach(ids?: Array<string | number> | string | number): Promise<void>;
265
+ sync(ids: Array<string | number>): Promise<void>;
266
+ syncWithoutDetaching(ids: Array<string | number>): Promise<void>;
267
+ toggle(ids: Array<string | number> | string | number): Promise<void>;
268
+ }
269
+ export type RelationMeta = {
270
+ kind: "has";
271
+ related: ModelClass;
272
+ foreignKey: string;
273
+ localKey: string;
274
+ } | {
275
+ kind: "belongsTo";
276
+ related: ModelClass;
277
+ foreignKey: string;
278
+ ownerKey: string;
279
+ } | {
280
+ kind: "belongsToMany";
281
+ related: ModelClass;
282
+ pivotTable: string;
283
+ foreignPivotKey: string;
284
+ relatedPivotKey: string;
285
+ localKey: string;
286
+ pivotColumns: string[];
287
+ } | {
288
+ kind: "morphToMany";
289
+ related: ModelClass;
290
+ pivotTable: string;
291
+ foreignPivotKey: string;
292
+ relatedPivotKey: string;
293
+ localKey: string;
294
+ morphTypeColumn: string;
295
+ morphTypes: string[];
296
+ pivotTenantKey?: string;
297
+ parentTenantKey?: string;
298
+ } | {
299
+ kind: "hasManyThrough";
300
+ related: ModelClass;
301
+ through: ModelClass;
302
+ firstKey: string;
303
+ secondKey: string;
304
+ localKey: string;
305
+ secondLocalKey: string;
306
+ } | {
307
+ kind: "hasOneThrough";
308
+ related: ModelClass;
309
+ through: ModelClass;
310
+ firstKey: string;
311
+ secondKey: string;
312
+ localKey: string;
313
+ secondLocalKey: string;
314
+ };
315
+ export declare function resolveRelation(model: ModelClass, relation: string): RelationMeta | null;
316
+ export declare function countRelation(model: Model, relation: string): Promise<number>;
317
+ /** Run COUNT/SUM/AVG/MIN/MAX/EXISTS for a relation on one model instance. */
318
+ export declare function aggregateRelation(model: Model, relation: string, fn: "count" | "sum" | "avg" | "min" | "max" | "exists", column?: string): Promise<unknown>;
319
+ export interface HasMany<T extends Model = Model> extends RelationQuery<T>, RelationWrite<T> {
320
+ }
321
+ export interface HasOne<T extends Model = Model> extends RelationQuery<T>, RelationWrite<T> {
322
+ }
323
+ export interface MorphMany<T extends Model = Model> extends RelationQuery<T>, RelationWrite<T> {
324
+ }
325
+ export interface MorphOne<T extends Model = Model> extends RelationQuery<T>, RelationWrite<T> {
326
+ }
327
+ export interface HasManyThrough<T extends Model = Model> extends RelationQuery<T> {
328
+ }
329
+ export interface HasOneThrough<T extends Model = Model> extends RelationQuery<T> {
330
+ }
331
+ export interface BelongsTo<T extends Model = Model> extends RelationQuery<T> {
332
+ }
333
+ export interface BelongsToMany<T extends Model = Model> extends RelationQuery<T> {
334
+ }
335
+ export {};