@c9up/atlas 0.2.4 → 0.2.6
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/db.darwin-arm64.node +0 -0
- package/db.darwin-x64.node +0 -0
- package/db.linux-arm64-gnu.node +0 -0
- package/db.linux-x64-gnu.node +0 -0
- package/db.win32-x64-msvc.node +0 -0
- package/dist/BaseEntity.d.ts +40 -27
- package/dist/BaseEntity.d.ts.map +1 -1
- package/dist/BaseEntity.js +82 -81
- package/dist/BaseEntity.js.map +1 -1
- package/dist/BaseModel.d.ts +10 -0
- package/dist/BaseModel.d.ts.map +1 -1
- package/dist/BaseModel.js +10 -0
- package/dist/BaseModel.js.map +1 -1
- package/dist/BaseRepository.d.ts.map +1 -1
- package/dist/BaseRepository.js +41 -9
- package/dist/BaseRepository.js.map +1 -1
- package/dist/ConnectionManager.d.ts.map +1 -1
- package/dist/ConnectionManager.js +22 -1
- package/dist/ConnectionManager.js.map +1 -1
- package/dist/ModelQuery.d.ts +22 -1
- package/dist/ModelQuery.d.ts.map +1 -1
- package/dist/ModelQuery.js +54 -25
- package/dist/ModelQuery.js.map +1 -1
- package/dist/Transaction.d.ts +9 -0
- package/dist/Transaction.d.ts.map +1 -1
- package/dist/Transaction.js +14 -0
- package/dist/Transaction.js.map +1 -1
- package/dist/adapters/NapiDbAdapter.d.ts.map +1 -1
- package/dist/adapters/NapiDbAdapter.js +7 -0
- package/dist/adapters/NapiDbAdapter.js.map +1 -1
- package/dist/decorators/entity.d.ts +14 -0
- package/dist/decorators/entity.d.ts.map +1 -1
- package/dist/decorators/entity.js +22 -1
- package/dist/decorators/entity.js.map +1 -1
- package/dist/decorators/hooks.d.ts +28 -0
- package/dist/decorators/hooks.d.ts.map +1 -1
- package/dist/decorators/hooks.js +43 -0
- package/dist/decorators/hooks.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/query/DatabaseQueryBuilder.d.ts +24 -0
- package/dist/query/DatabaseQueryBuilder.d.ts.map +1 -1
- package/dist/query/DatabaseQueryBuilder.js +98 -3
- package/dist/query/DatabaseQueryBuilder.js.map +1 -1
- package/dist/schema/SchemaBuilder.d.ts +2 -1
- package/dist/schema/SchemaBuilder.d.ts.map +1 -1
- package/dist/schema/SchemaBuilder.js +2 -1
- package/dist/schema/SchemaBuilder.js.map +1 -1
- package/dist/schema/TableBuilder.d.ts +140 -132
- package/dist/schema/TableBuilder.d.ts.map +1 -1
- package/dist/schema/TableBuilder.js +232 -231
- package/dist/schema/TableBuilder.js.map +1 -1
- package/dist/services/db.d.ts +7 -0
- package/dist/services/db.d.ts.map +1 -1
- package/dist/services/db.js.map +1 -1
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +3 -3
- package/src/BaseEntity.ts +115 -85
- package/src/BaseModel.ts +10 -0
- package/src/BaseRepository.ts +47 -7
- package/src/ConnectionManager.ts +26 -1
- package/src/ModelQuery.ts +85 -26
- package/src/Transaction.ts +23 -0
- package/src/adapters/NapiDbAdapter.ts +7 -0
- package/src/decorators/entity.ts +27 -1
- package/src/decorators/hooks.ts +67 -0
- package/src/index.ts +8 -1
- package/src/query/DatabaseQueryBuilder.ts +118 -7
- package/src/schema/SchemaBuilder.ts +2 -1
- package/src/schema/TableBuilder.ts +331 -303
- package/src/services/db.ts +7 -0
package/src/ModelQuery.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
getRelationMetadata,
|
|
27
27
|
hasSoftDeletes,
|
|
28
28
|
type RelationMetadata,
|
|
29
|
+
toISODateOnly,
|
|
29
30
|
} from "./decorators/entity.js";
|
|
30
31
|
import { fireHooks } from "./decorators/hooks.js";
|
|
31
32
|
import { getNamingStrategy } from "./naming/NamingStrategy.js";
|
|
@@ -178,9 +179,17 @@ function buildValuePreparer(entityClass: new () => BaseEntity): ValuePreparer {
|
|
|
178
179
|
// Query-builder value transform — no model instance, but the attribute is
|
|
179
180
|
// known (Adonis Lucid signature: value, attribute, model).
|
|
180
181
|
if (p) return p(value, prop, undefined);
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
182
|
+
const dateCol = dateCols[prop];
|
|
183
|
+
if (dateCol && value != null) {
|
|
184
|
+
const iso =
|
|
185
|
+
value instanceof Date
|
|
186
|
+
? value.toISOString()
|
|
187
|
+
: dateTimeAtlasAdapter.prepare(value);
|
|
188
|
+
// Same narrowing as the repository write path: a date-only column
|
|
189
|
+
// stores a date, matching Lucid's `@column.date()`.
|
|
190
|
+
return dateCol.dateOnly && typeof iso === "string"
|
|
191
|
+
? toISODateOnly(iso)
|
|
192
|
+
: iso;
|
|
184
193
|
}
|
|
185
194
|
return value;
|
|
186
195
|
};
|
|
@@ -2222,41 +2231,77 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
2222
2231
|
|
|
2223
2232
|
// --- Top-level scalar executors (Story 29.5) ---
|
|
2224
2233
|
|
|
2225
|
-
/**
|
|
2226
|
-
|
|
2234
|
+
/**
|
|
2235
|
+
* `COUNT` — a terminal scalar, or a chainable projection when given an
|
|
2236
|
+
* alias.
|
|
2237
|
+
*
|
|
2238
|
+
* Lucid types this `count: Aggregate<this>`: `count('* as total')` returns
|
|
2239
|
+
* the BUILDER and the value lands in `$extras`. The no-alias form returning
|
|
2240
|
+
* a number is atlas's own, and matches what `DatabaseQueryBuilder` already
|
|
2241
|
+
* does — same method, same two shapes, on both builders.
|
|
2242
|
+
*/
|
|
2243
|
+
count(aliasExpr: `${string} as ${string}`): this;
|
|
2244
|
+
count(column?: string): Promise<number>;
|
|
2245
|
+
count(column?: string): Promise<number> | this {
|
|
2246
|
+
if (column !== undefined && ALIASED.test(column)) {
|
|
2247
|
+
return this.#projectAggregate("COUNT", column);
|
|
2248
|
+
}
|
|
2249
|
+
const col = column ?? "*";
|
|
2227
2250
|
const expr =
|
|
2228
|
-
|
|
2251
|
+
col === "*"
|
|
2229
2252
|
? "COUNT(*)"
|
|
2230
|
-
: `COUNT(${this.#quoteCol(this.#resolveColumn(
|
|
2231
|
-
return
|
|
2253
|
+
: `COUNT(${this.#quoteCol(this.#resolveColumn(col))})`;
|
|
2254
|
+
return this.#runScalar(expr).then((v) => Number(v ?? 0));
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
/** Project `FN(col) AS alias` and keep chaining — the Lucid `Aggregate` form. */
|
|
2258
|
+
#projectAggregate(fn: string, aliasExpr: string): this {
|
|
2259
|
+
const match = ALIASED.exec(aliasExpr.trim());
|
|
2260
|
+
const column = (match?.[1] ?? "").trim();
|
|
2261
|
+
const alias = match?.[2] ?? "";
|
|
2262
|
+
const inner =
|
|
2263
|
+
column === "*" ? "*" : this.#quoteCol(this.#resolveColumn(column));
|
|
2264
|
+
this.#selectRaw.push({
|
|
2265
|
+
sql: `${fn}(${inner}) AS ${this.#quoteAliasName(alias)}`,
|
|
2266
|
+
params: [],
|
|
2267
|
+
});
|
|
2268
|
+
return this;
|
|
2232
2269
|
}
|
|
2233
2270
|
|
|
2234
|
-
|
|
2235
|
-
|
|
2271
|
+
sum(aliasExpr: `${string} as ${string}`): this;
|
|
2272
|
+
sum(column: string): Promise<number | null>;
|
|
2273
|
+
sum(column: string): Promise<number | null> | this {
|
|
2274
|
+
if (ALIASED.test(column)) return this.#projectAggregate("SUM", column);
|
|
2275
|
+
return this.#runScalar(
|
|
2236
2276
|
`SUM(${this.#quoteCol(this.#resolveColumn(column))})`,
|
|
2237
|
-
);
|
|
2238
|
-
return v === null || v === undefined ? null : Number(v);
|
|
2277
|
+
).then((v) => (v === null || v === undefined ? null : Number(v)));
|
|
2239
2278
|
}
|
|
2240
2279
|
|
|
2241
|
-
|
|
2242
|
-
|
|
2280
|
+
avg(aliasExpr: `${string} as ${string}`): this;
|
|
2281
|
+
avg(column: string): Promise<number | null>;
|
|
2282
|
+
avg(column: string): Promise<number | null> | this {
|
|
2283
|
+
if (ALIASED.test(column)) return this.#projectAggregate("AVG", column);
|
|
2284
|
+
return this.#runScalar(
|
|
2243
2285
|
`AVG(${this.#quoteCol(this.#resolveColumn(column))})`,
|
|
2244
|
-
);
|
|
2245
|
-
return v === null || v === undefined ? null : Number(v);
|
|
2286
|
+
).then((v) => (v === null || v === undefined ? null : Number(v)));
|
|
2246
2287
|
}
|
|
2247
2288
|
|
|
2248
|
-
|
|
2249
|
-
|
|
2289
|
+
min(aliasExpr: `${string} as ${string}`): this;
|
|
2290
|
+
min(column: string): Promise<number | null>;
|
|
2291
|
+
min(column: string): Promise<number | null> | this {
|
|
2292
|
+
if (ALIASED.test(column)) return this.#projectAggregate("MIN", column);
|
|
2293
|
+
return this.#runScalar(
|
|
2250
2294
|
`MIN(${this.#quoteCol(this.#resolveColumn(column))})`,
|
|
2251
|
-
);
|
|
2252
|
-
return v === null || v === undefined ? null : Number(v);
|
|
2295
|
+
).then((v) => (v === null || v === undefined ? null : Number(v)));
|
|
2253
2296
|
}
|
|
2254
2297
|
|
|
2255
|
-
|
|
2256
|
-
|
|
2298
|
+
max(aliasExpr: `${string} as ${string}`): this;
|
|
2299
|
+
max(column: string): Promise<number | null>;
|
|
2300
|
+
max(column: string): Promise<number | null> | this {
|
|
2301
|
+
if (ALIASED.test(column)) return this.#projectAggregate("MAX", column);
|
|
2302
|
+
return this.#runScalar(
|
|
2257
2303
|
`MAX(${this.#quoteCol(this.#resolveColumn(column))})`,
|
|
2258
|
-
);
|
|
2259
|
-
return v === null || v === undefined ? null : Number(v);
|
|
2304
|
+
).then((v) => (v === null || v === undefined ? null : Number(v)));
|
|
2260
2305
|
}
|
|
2261
2306
|
|
|
2262
2307
|
/**
|
|
@@ -4580,6 +4625,14 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
4580
4625
|
cursor?: string;
|
|
4581
4626
|
limit: number;
|
|
4582
4627
|
orderBy: string | string[];
|
|
4628
|
+
/**
|
|
4629
|
+
* Which way the cursor walks. Default `asc`.
|
|
4630
|
+
*
|
|
4631
|
+
* `desc` is what a newest-first feed needs — the common case for cursor
|
|
4632
|
+
* pagination. The direction was hardcoded to `asc`, and any `orderBy`
|
|
4633
|
+
* already set on the query was overwritten by it without a word.
|
|
4634
|
+
*/
|
|
4635
|
+
direction?: "asc" | "desc";
|
|
4583
4636
|
}): Promise<{ items: T[]; nextCursor: string | null; hasMore: boolean }> {
|
|
4584
4637
|
// Keep BOTH forms: `props` (model property names) to read the cursor value
|
|
4585
4638
|
// off the hydrated entity, and `cols` (resolved DB columns) for the SQL
|
|
@@ -4591,6 +4644,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
4591
4644
|
if (cols.length === 0)
|
|
4592
4645
|
throw new Error("cursorPaginate requires at least one orderBy column");
|
|
4593
4646
|
const lim = Math.max(1, Math.floor(opts.limit));
|
|
4647
|
+
const descending = opts.direction === "desc";
|
|
4594
4648
|
const clone = this.clone();
|
|
4595
4649
|
|
|
4596
4650
|
if (opts.cursor) {
|
|
@@ -4617,7 +4671,9 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
4617
4671
|
for (let i = 0; i < cols.length; i++) {
|
|
4618
4672
|
q.orWhere((inner) => {
|
|
4619
4673
|
for (let j = 0; j < i; j++) inner.where(cols[j], decoded.v[j]);
|
|
4620
|
-
|
|
4674
|
+
// The comparison has to follow the walk: `>` reads forward
|
|
4675
|
+
// through an ascending order, `<` through a descending one.
|
|
4676
|
+
inner.where(cols[i], descending ? "<" : ">", decoded.v[i]);
|
|
4621
4677
|
});
|
|
4622
4678
|
}
|
|
4623
4679
|
});
|
|
@@ -4625,7 +4681,7 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
4625
4681
|
|
|
4626
4682
|
clone.#orderBys = cols.map((column) => ({
|
|
4627
4683
|
column,
|
|
4628
|
-
direction: "asc" as const,
|
|
4684
|
+
direction: descending ? ("desc" as const) : ("asc" as const),
|
|
4629
4685
|
}));
|
|
4630
4686
|
clone.#limit = lim + 1;
|
|
4631
4687
|
// `#doExec` (not `exec`) — cursorPaginate is an atlas-specific terminal, not a
|
|
@@ -5476,3 +5532,6 @@ export class ModelQuery<T extends BaseEntity> {
|
|
|
5476
5532
|
}
|
|
5477
5533
|
}
|
|
5478
5534
|
}
|
|
5535
|
+
|
|
5536
|
+
/** `col as alias` — the Lucid aggregate spelling. */
|
|
5537
|
+
const ALIASED = /^(.*?)\s+as\s+(\S+)$/i;
|
package/src/Transaction.ts
CHANGED
|
@@ -26,6 +26,15 @@ export interface TransactionClient
|
|
|
26
26
|
TransactionQueryBuilders {
|
|
27
27
|
commit(): Promise<void>;
|
|
28
28
|
rollback(): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Whether this transaction has already committed or rolled back (Lucid
|
|
31
|
+
* `trx.isCompleted`).
|
|
32
|
+
*
|
|
33
|
+
* A helper handed a transaction cannot otherwise tell whether it is still
|
|
34
|
+
* usable, and issuing a statement on a finished one fails at the driver with
|
|
35
|
+
* an error that says nothing about the transaction.
|
|
36
|
+
*/
|
|
37
|
+
readonly isCompleted: boolean;
|
|
29
38
|
/**
|
|
30
39
|
* Register a side effect to run AFTER the transaction is durable (Lucid
|
|
31
40
|
* `trx.after('commit' | 'rollback', cb)`). A `commit` hook fires only once the
|
|
@@ -127,17 +136,25 @@ export async function openSavepoint(
|
|
|
127
136
|
const commitHooks: AfterHook[] = [];
|
|
128
137
|
const rollbackHooks: AfterHook[] = [];
|
|
129
138
|
const evt = makeTrxEvents();
|
|
139
|
+
// Flipped by whichever of commit/rollback runs first, so a helper handed
|
|
140
|
+
// this client can tell whether it is still usable (Lucid `isCompleted`).
|
|
141
|
+
let completed = false;
|
|
130
142
|
const base = {
|
|
131
143
|
execute: parent.execute.bind(parent),
|
|
132
144
|
query: parent.query.bind(parent),
|
|
145
|
+
get isCompleted(): boolean {
|
|
146
|
+
return completed;
|
|
147
|
+
},
|
|
133
148
|
async commit(): Promise<void> {
|
|
134
149
|
await parent.execute(`RELEASE SAVEPOINT ${name}`, []);
|
|
150
|
+
completed = true;
|
|
135
151
|
evt.emit("commit"); // synchronous EventEmitter notification (this savepoint)
|
|
136
152
|
for (const hook of commitHooks) parent.after("commit", hook);
|
|
137
153
|
for (const hook of rollbackHooks) parent.after("rollback", hook);
|
|
138
154
|
},
|
|
139
155
|
async rollback(): Promise<void> {
|
|
140
156
|
await parent.execute(`ROLLBACK TO SAVEPOINT ${name}`, []);
|
|
157
|
+
completed = true;
|
|
141
158
|
try {
|
|
142
159
|
await parent.execute(`RELEASE SAVEPOINT ${name}`, []);
|
|
143
160
|
} catch {
|
|
@@ -250,16 +267,22 @@ export async function transaction<T>(
|
|
|
250
267
|
const rollbackHooks: AfterHook[] = [];
|
|
251
268
|
const evt = makeTrxEvents();
|
|
252
269
|
|
|
270
|
+
let completed = false;
|
|
253
271
|
const base = {
|
|
254
272
|
execute: db.execute.bind(db),
|
|
255
273
|
query: db.query.bind(db),
|
|
274
|
+
get isCompleted(): boolean {
|
|
275
|
+
return completed;
|
|
276
|
+
},
|
|
256
277
|
async commit() {
|
|
257
278
|
await db.execute("COMMIT", []);
|
|
279
|
+
completed = true;
|
|
258
280
|
evt.emit("commit"); // synchronous EventEmitter notification
|
|
259
281
|
await runAfterHooks(commitHooks);
|
|
260
282
|
},
|
|
261
283
|
async rollback() {
|
|
262
284
|
await db.execute("ROLLBACK", []);
|
|
285
|
+
completed = true;
|
|
263
286
|
evt.emit("rollback");
|
|
264
287
|
await runAfterHooks(rollbackHooks);
|
|
265
288
|
},
|
|
@@ -291,6 +291,8 @@ export async function createNapiConnection(
|
|
|
291
291
|
const native = await db.begin(isolationLevel);
|
|
292
292
|
// Root (non-nested) transaction: after-hooks fire once the underlying
|
|
293
293
|
// COMMIT / ROLLBACK is durable (Lucid `trx.after(...)`), errors swallowed.
|
|
294
|
+
// Flipped by whichever of commit/rollback runs first (Lucid isCompleted).
|
|
295
|
+
let completed = false;
|
|
294
296
|
const commitHooks: AfterHook[] = [];
|
|
295
297
|
const rollbackHooks: AfterHook[] = [];
|
|
296
298
|
const evt = makeTrxEvents();
|
|
@@ -320,13 +322,18 @@ export async function createNapiConnection(
|
|
|
320
322
|
);
|
|
321
323
|
return JSON.parse(json, napiReviver) as T[];
|
|
322
324
|
},
|
|
325
|
+
get isCompleted(): boolean {
|
|
326
|
+
return completed;
|
|
327
|
+
},
|
|
323
328
|
async commit(): Promise<void> {
|
|
324
329
|
await native.commit();
|
|
330
|
+
completed = true;
|
|
325
331
|
evt.emit("commit"); // synchronous EventEmitter notification (Lucid trx.on)
|
|
326
332
|
await runAfterHooks(commitHooks);
|
|
327
333
|
},
|
|
328
334
|
async rollback(): Promise<void> {
|
|
329
335
|
await native.rollback();
|
|
336
|
+
completed = true;
|
|
330
337
|
evt.emit("rollback");
|
|
331
338
|
await runAfterHooks(rollbackHooks);
|
|
332
339
|
},
|
package/src/decorators/entity.ts
CHANGED
|
@@ -125,8 +125,12 @@ export interface ManyToManyOptions {
|
|
|
125
125
|
pivotTable: string;
|
|
126
126
|
/** Foreign key in the pivot table pointing to THIS entity (default: `${thisTable}_id`). */
|
|
127
127
|
foreignKey?: string;
|
|
128
|
+
/** AdonisJS spelling of {@link foreignKey}. Both are accepted. */
|
|
129
|
+
pivotForeignKey?: string;
|
|
128
130
|
/** Foreign key in the pivot table pointing to the RELATED entity (default: `${relatedTable}_id`). */
|
|
129
131
|
otherKey?: string;
|
|
132
|
+
/** AdonisJS spelling of {@link otherKey}. Both are accepted. */
|
|
133
|
+
pivotRelatedForeignKey?: string;
|
|
130
134
|
/**
|
|
131
135
|
* The column ON THE RELATED model that `otherKey` references (Adonis Lucid
|
|
132
136
|
* `relatedKey`). Defaults to the related model's primary key; override when the
|
|
@@ -302,6 +306,20 @@ function registerDateColumn(
|
|
|
302
306
|
Reflect.defineMetadata(DATE_COLUMNS_KEY, map, ctor);
|
|
303
307
|
}
|
|
304
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Keep the date part of an ISO 8601 string.
|
|
311
|
+
*
|
|
312
|
+
* Lucid's `@column.date()` persists `DateTime.toISODate()` — the date alone —
|
|
313
|
+
* while `@column.dateTime()` persists a full instant. atlas ran both through
|
|
314
|
+
* the same serializer, so a date-only column received `2026-03-10T00:00:00Z`.
|
|
315
|
+
* Postgres coerces that, but the column still means a date, and matching Lucid
|
|
316
|
+
* keeps what lands in the row identical between the two ORMs.
|
|
317
|
+
*/
|
|
318
|
+
export function toISODateOnly(value: string): string {
|
|
319
|
+
const t = value.indexOf("T");
|
|
320
|
+
return t === -1 ? value : value.slice(0, t);
|
|
321
|
+
}
|
|
322
|
+
|
|
305
323
|
/**
|
|
306
324
|
* `@column.date()` — marks a property as a date-only column (YYYY-MM-DD).
|
|
307
325
|
* The raw DB value is hydrated to a JS Date; nulls pass through.
|
|
@@ -519,7 +537,15 @@ export function ManyToMany(
|
|
|
519
537
|
propertyKey: String(propertyKey),
|
|
520
538
|
type: "manyToMany",
|
|
521
539
|
target,
|
|
522
|
-
|
|
540
|
+
// Normalised HERE, once: AdonisJS names these `pivotForeignKey` /
|
|
541
|
+
// `pivotRelatedForeignKey`. Folding them in at the decorator means the
|
|
542
|
+
// half-dozen places that read the pivot keys never learn there are two
|
|
543
|
+
// spellings — and cannot each forget one.
|
|
544
|
+
pivot: {
|
|
545
|
+
...options,
|
|
546
|
+
foreignKey: options.foreignKey ?? options.pivotForeignKey,
|
|
547
|
+
otherKey: options.otherKey ?? options.pivotRelatedForeignKey,
|
|
548
|
+
},
|
|
523
549
|
// `localKey` selects which parent column the pivot FK references
|
|
524
550
|
// (default: the parent PK). Without copying it here the value the type
|
|
525
551
|
// accepts is silently dropped and the pivot always targets the PK.
|
package/src/decorators/hooks.ts
CHANGED
|
@@ -59,6 +59,24 @@ export interface HookArgs {
|
|
|
59
59
|
afterPaginate: Paginator<BaseEntity>;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The event half of a hook kind — what `Model.before(event)` takes. Lucid
|
|
64
|
+
* spells these lower-case (`'save'`, `'create'`) and prefixes them itself.
|
|
65
|
+
*/
|
|
66
|
+
export type HookEvent =
|
|
67
|
+
| "save"
|
|
68
|
+
| "create"
|
|
69
|
+
| "update"
|
|
70
|
+
| "delete"
|
|
71
|
+
| "find"
|
|
72
|
+
| "fetch"
|
|
73
|
+
| "paginate";
|
|
74
|
+
|
|
75
|
+
/** `'save'` → `'Save'`, so `before` + event names the registry key. */
|
|
76
|
+
export function capitalize<E extends string>(event: E): Capitalize<E> {
|
|
77
|
+
return (event.charAt(0).toUpperCase() + event.slice(1)) as Capitalize<E>;
|
|
78
|
+
}
|
|
79
|
+
|
|
62
80
|
/** A hook handler is a static function that receives the kind-specific arg. */
|
|
63
81
|
export type HookHandler<K extends HookKind = HookKind> = (
|
|
64
82
|
arg: HookArgs[K],
|
|
@@ -169,3 +187,52 @@ export async function fireHooks<K extends HookKind>(
|
|
|
169
187
|
await handler(arg);
|
|
170
188
|
}
|
|
171
189
|
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Register a hook at RUNTIME, without a decorator — Lucid's
|
|
193
|
+
* `Model.before(event, handler)` / `Model.after(event, handler)`, which do
|
|
194
|
+
* `this.$hooks.add(\`before:${event}\`, handler)`.
|
|
195
|
+
*
|
|
196
|
+
* The decorators cover the common case (a static method on the entity), but a
|
|
197
|
+
* hook that comes from somewhere else — a plugin, a test, a package wiring
|
|
198
|
+
* itself into an app's models — has no class body to decorate. Lucid supports
|
|
199
|
+
* both; so does this.
|
|
200
|
+
*
|
|
201
|
+
* Handlers land in the SAME per-class registry the decorators write to, so the
|
|
202
|
+
* ordering rule is unchanged: parent classes fire before child classes, and
|
|
203
|
+
* within a class, registration order.
|
|
204
|
+
*/
|
|
205
|
+
export function addHook<K extends HookKind>(
|
|
206
|
+
entityClass: object,
|
|
207
|
+
kind: K,
|
|
208
|
+
handler: HookHandler<K>,
|
|
209
|
+
): void {
|
|
210
|
+
if (!isHookHandler(handler)) {
|
|
211
|
+
throw new Error(`${kind} hook must be a function`);
|
|
212
|
+
}
|
|
213
|
+
const registry = getOwnRegistry(entityClass);
|
|
214
|
+
const list: HookHandler[] = registry[kind] ?? [];
|
|
215
|
+
list.push(handler as HookHandler);
|
|
216
|
+
registry[kind] = list;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Drop a handler registered with {@link addHook} (or a decorator, if you hold
|
|
221
|
+
* the same reference). Returns whether one was removed — a test that adds a
|
|
222
|
+
* hook needs a way to take it back out.
|
|
223
|
+
*/
|
|
224
|
+
export function removeHook<K extends HookKind>(
|
|
225
|
+
entityClass: object,
|
|
226
|
+
kind: K,
|
|
227
|
+
handler: HookHandler<K>,
|
|
228
|
+
): boolean {
|
|
229
|
+
const registry = Reflect.getOwnMetadata(HOOKS_KEY, entityClass) as
|
|
230
|
+
| HookRegistry
|
|
231
|
+
| undefined;
|
|
232
|
+
const list = registry?.[kind];
|
|
233
|
+
if (!list) return false;
|
|
234
|
+
const index = list.indexOf(handler as HookHandler);
|
|
235
|
+
if (index === -1) return false;
|
|
236
|
+
list.splice(index, 1);
|
|
237
|
+
return true;
|
|
238
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -99,7 +99,10 @@ export {
|
|
|
99
99
|
PrimaryKey,
|
|
100
100
|
SoftDeletes,
|
|
101
101
|
} from "./decorators/entity.js";
|
|
102
|
+
// Runtime hook registration — what `BaseEntity.before()/after()` call, exported
|
|
103
|
+
// for a plugin that wires hooks into models it does not own.
|
|
102
104
|
export {
|
|
105
|
+
addHook,
|
|
103
106
|
afterCreate,
|
|
104
107
|
afterDelete,
|
|
105
108
|
afterFetch,
|
|
@@ -114,6 +117,10 @@ export {
|
|
|
114
117
|
beforePaginate,
|
|
115
118
|
beforeSave,
|
|
116
119
|
beforeUpdate,
|
|
120
|
+
type HookEvent,
|
|
121
|
+
type HookHandler,
|
|
122
|
+
type HookKind,
|
|
123
|
+
removeHook,
|
|
117
124
|
} from "./decorators/hooks.js";
|
|
118
125
|
export type { ScopeFn } from "./decorators/scope.js";
|
|
119
126
|
export { scope } from "./decorators/scope.js";
|
|
@@ -173,7 +180,7 @@ export type {
|
|
|
173
180
|
export { MigrationRunner } from "./schema/MigrationRunner.js";
|
|
174
181
|
export type { DefaultValue } from "./schema/raw.js";
|
|
175
182
|
export type { ColumnDefinition, ColumnType } from "./schema/SchemaBuilder.js";
|
|
176
|
-
export { Schema, TableBuilder } from "./schema/SchemaBuilder.js";
|
|
183
|
+
export { ColumnBuilder, Schema, TableBuilder } from "./schema/SchemaBuilder.js";
|
|
177
184
|
export {
|
|
178
185
|
checkSchema,
|
|
179
186
|
formatSchemaFindings,
|
|
@@ -890,6 +890,59 @@ export class DatabaseQueryBuilder<T = Record<string, unknown>> {
|
|
|
890
890
|
return this;
|
|
891
891
|
}
|
|
892
892
|
|
|
893
|
+
// ─── HAVING EXISTS (Lucid/Knex) ───────────────────────────
|
|
894
|
+
// A correlated subquery in HAVING is how you filter GROUPS by something
|
|
895
|
+
// outside the aggregate — "groups that still have an open order", which no
|
|
896
|
+
// combination of `having(count)` can express.
|
|
897
|
+
|
|
898
|
+
/** HAVING EXISTS (subquery). */
|
|
899
|
+
havingExists(sub: SubqueryArg): this {
|
|
900
|
+
return this.#pushHavingExists("and", false, sub);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** Alias of {@link havingExists} — AND is the default. */
|
|
904
|
+
andHavingExists(sub: SubqueryArg): this {
|
|
905
|
+
return this.#pushHavingExists("and", false, sub);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/** OR EXISTS in HAVING. */
|
|
909
|
+
orHavingExists(sub: SubqueryArg): this {
|
|
910
|
+
return this.#pushHavingExists("or", false, sub);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/** HAVING NOT EXISTS (subquery). */
|
|
914
|
+
havingNotExists(sub: SubqueryArg): this {
|
|
915
|
+
return this.#pushHavingExists("and", true, sub);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/** Alias of {@link havingNotExists}. */
|
|
919
|
+
andHavingNotExists(sub: SubqueryArg): this {
|
|
920
|
+
return this.#pushHavingExists("and", true, sub);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/** OR NOT EXISTS in HAVING. */
|
|
924
|
+
orHavingNotExists(sub: SubqueryArg): this {
|
|
925
|
+
return this.#pushHavingExists("or", true, sub);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
#pushHavingExists(
|
|
929
|
+
type: "and" | "or",
|
|
930
|
+
negated: boolean,
|
|
931
|
+
sub: SubqueryArg,
|
|
932
|
+
): this {
|
|
933
|
+
// Compiled to a raw HAVING rather than a new clause kind: the engine
|
|
934
|
+
// already carries raw havings with their bindings, and EXISTS has no
|
|
935
|
+
// operator/value to model.
|
|
936
|
+
const { sql, params } = this.#resolveSub(sub).#compiledNative();
|
|
937
|
+
this.#havings.push({
|
|
938
|
+
kind: "raw",
|
|
939
|
+
sql: `${negated ? "NOT EXISTS" : "EXISTS"} (${sql})`,
|
|
940
|
+
bindings: params,
|
|
941
|
+
type,
|
|
942
|
+
});
|
|
943
|
+
return this;
|
|
944
|
+
}
|
|
945
|
+
|
|
893
946
|
/** `INNER JOIN` — alias of {@link innerJoin} (Lucid/Knex `join`). */
|
|
894
947
|
join(table: string, left: string, right: string): this;
|
|
895
948
|
join(table: string, left: string, operator: string, right: string): this;
|
|
@@ -1980,6 +2033,47 @@ export class DatabaseQueryBuilder<T = Record<string, unknown>> {
|
|
|
1980
2033
|
return this;
|
|
1981
2034
|
}
|
|
1982
2035
|
|
|
2036
|
+
// ─── Clearing clauses (Lucid/Knex `clear*`) ───────────────
|
|
2037
|
+
// A builder handed to a helper that added a filter, an order or a slice can
|
|
2038
|
+
// be reset clause by clause instead of rebuilt from scratch — which is what
|
|
2039
|
+
// a shared scope or a reusable base query needs.
|
|
2040
|
+
|
|
2041
|
+
/** Drop every selected column, back to `*`. */
|
|
2042
|
+
clearSelect(): this {
|
|
2043
|
+
this.#selects = [];
|
|
2044
|
+
return this;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
/** Drop every WHERE, including the raw and grouped ones. */
|
|
2048
|
+
clearWhere(): this {
|
|
2049
|
+
this.#wheres = [];
|
|
2050
|
+
return this;
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
/** Drop every ORDER BY. */
|
|
2054
|
+
clearOrder(): this {
|
|
2055
|
+
this.#orderBys = [];
|
|
2056
|
+
return this;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
/** Drop every HAVING. */
|
|
2060
|
+
clearHaving(): this {
|
|
2061
|
+
this.#havings = [];
|
|
2062
|
+
return this;
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
/** Drop the LIMIT. */
|
|
2066
|
+
clearLimit(): this {
|
|
2067
|
+
this.#limit = undefined;
|
|
2068
|
+
return this;
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
/** Drop the OFFSET. */
|
|
2072
|
+
clearOffset(): this {
|
|
2073
|
+
this.#offset = undefined;
|
|
2074
|
+
return this;
|
|
2075
|
+
}
|
|
2076
|
+
|
|
1983
2077
|
/** The table, qualified with a schema when {@link withSchema} was used. */
|
|
1984
2078
|
#qualifiedTable(): string {
|
|
1985
2079
|
return this.#schema ? `${this.#schema}.${this.#table}` : this.#table;
|
|
@@ -2672,19 +2766,36 @@ export class DatabaseQueryBuilder<T = Record<string, unknown>> {
|
|
|
2672
2766
|
},
|
|
2673
2767
|
this.#dialect,
|
|
2674
2768
|
);
|
|
2769
|
+
// A flat `SELECT COUNT(*) … GROUP BY x` returns one row PER GROUP, each
|
|
2770
|
+
// holding that group's size — so `rows[0]` would be the first group's
|
|
2771
|
+
// size, not the number of rows. Wrap the grouped query and count ITS
|
|
2772
|
+
// rows, which is what ModelQuery.paginate already does.
|
|
2773
|
+
const countSql =
|
|
2774
|
+
this.#groupBys.length > 0
|
|
2775
|
+
? `SELECT COUNT(*) AS aggregate FROM (${this.#compiledNative().sql}) AS __paginate_count`
|
|
2776
|
+
: countCompiled.statements[0];
|
|
2777
|
+
const countParams =
|
|
2778
|
+
this.#groupBys.length > 0
|
|
2779
|
+
? this.#compiledNative().params
|
|
2780
|
+
: countCompiled.params;
|
|
2675
2781
|
const countRows = await this.#exec.query<{
|
|
2676
2782
|
aggregate: number | string | null;
|
|
2677
|
-
}>(
|
|
2678
|
-
countCompiled.statements[0],
|
|
2679
|
-
countCompiled.params,
|
|
2680
|
-
this.#queryMeta("paginate"),
|
|
2681
|
-
);
|
|
2783
|
+
}>(countSql, countParams, this.#queryMeta("paginate"));
|
|
2682
2784
|
const total = Number(countRows[0]?.aggregate ?? 0);
|
|
2683
2785
|
|
|
2786
|
+
// Restore the slice afterwards: `paginate()` is a read, and a builder
|
|
2787
|
+
// left carrying a LIMIT silently truncated the next query run off it.
|
|
2788
|
+
const previousLimit = this.#limit;
|
|
2789
|
+
const previousOffset = this.#offset;
|
|
2684
2790
|
this.#limit = pp;
|
|
2685
2791
|
this.#offset = (p - 1) * pp;
|
|
2686
|
-
|
|
2687
|
-
|
|
2792
|
+
try {
|
|
2793
|
+
const items = await this.exec();
|
|
2794
|
+
return new Paginator<T>(items, { total, perPage: pp, currentPage: p });
|
|
2795
|
+
} finally {
|
|
2796
|
+
this.#limit = previousLimit;
|
|
2797
|
+
this.#offset = previousOffset;
|
|
2798
|
+
}
|
|
2688
2799
|
}
|
|
2689
2800
|
|
|
2690
2801
|
/** Thenable, so `await db.from('users').where(...)` resolves to the rows. */
|
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
* The classes were split into one-class-per-file:
|
|
5
5
|
* - {@link Schema} → ./Schema.ts
|
|
6
6
|
* - {@link TableBuilder} → ./TableBuilder.ts
|
|
7
|
+
* - {@link ColumnBuilder} → ./TableBuilder.ts
|
|
7
8
|
* - shared types → ./types.ts
|
|
8
9
|
*
|
|
9
10
|
* @implements FR34
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
export { Schema } from "./Schema.js";
|
|
13
|
-
export { TableBuilder } from "./TableBuilder.js";
|
|
14
|
+
export { ColumnBuilder, TableBuilder } from "./TableBuilder.js";
|
|
14
15
|
export type { ColumnDefinition, ColumnType, IndexDefinition } from "./types.js";
|