@apisr/drizzle-model 0.0.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/DISCLAIMER.md +5 -0
  2. package/TODO.md +8 -61
  3. package/package.json +2 -1
  4. package/src/core/dialect.ts +81 -0
  5. package/src/core/index.ts +24 -0
  6. package/src/core/query/error.ts +15 -0
  7. package/src/core/query/joins.ts +596 -0
  8. package/src/core/query/projection.ts +136 -0
  9. package/src/core/query/where.ts +449 -0
  10. package/src/core/result.ts +297 -0
  11. package/src/core/runtime.ts +612 -0
  12. package/src/core/transform.ts +119 -0
  13. package/src/model/builder.ts +40 -6
  14. package/src/model/config.ts +9 -9
  15. package/src/model/format.ts +20 -8
  16. package/src/model/methods/exclude.ts +1 -7
  17. package/src/model/methods/return.ts +11 -11
  18. package/src/model/methods/select.ts +2 -8
  19. package/src/model/model.ts +10 -16
  20. package/src/model/query/error.ts +1 -0
  21. package/src/model/result.ts +134 -21
  22. package/src/types.ts +38 -0
  23. package/tests/base/count.test.ts +47 -0
  24. package/tests/base/delete.test.ts +90 -0
  25. package/tests/base/find.test.ts +209 -0
  26. package/tests/base/insert.test.ts +152 -0
  27. package/tests/base/safe.test.ts +91 -0
  28. package/tests/base/update.test.ts +88 -0
  29. package/tests/base/upsert.test.ts +121 -0
  30. package/tests/base.ts +21 -0
  31. package/tests/snippets/x-1.ts +22 -0
  32. package/src/model/core/joins.ts +0 -364
  33. package/src/model/core/projection.ts +0 -61
  34. package/src/model/core/runtime.ts +0 -330
  35. package/src/model/core/thenable.ts +0 -94
  36. package/src/model/core/transform.ts +0 -65
  37. package/src/model/core/where.ts +0 -249
  38. package/src/model/core/with.ts +0 -28
  39. package/tests/builder-v2-mysql.type-test.ts +0 -51
  40. package/tests/builder-v2.type-test.ts +0 -336
  41. package/tests/builder.test.ts +0 -63
  42. package/tests/find.test.ts +0 -166
  43. package/tests/insert.test.ts +0 -247
@@ -0,0 +1,612 @@
1
+ import type { ModelDialect } from "../model/dialect.ts";
2
+ import type { ModelOptions } from "../model/options.ts";
3
+ import { DialectHelper } from "./dialect.ts";
4
+ import { JoinExecutor } from "./query/joins.ts";
5
+ import { ProjectionBuilder } from "./query/projection.ts";
6
+ import { WhereCompiler } from "./query/where.ts";
7
+ import {
8
+ type MutateKind,
9
+ MutateResult,
10
+ type MutateState,
11
+ QueryResult,
12
+ type QueryState,
13
+ } from "./result.ts";
14
+ import { ResultTransformer } from "./transform.ts";
15
+
16
+ /** Generic record type used throughout the runtime. */
17
+ type AnyRecord = Record<string, unknown>;
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Configuration
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /**
24
+ * Configuration required to construct a {@link ModelRuntime}.
25
+ *
26
+ * Carries all the static metadata about the table, schema, relations,
27
+ * dialect and user-defined options.
28
+ */
29
+ export interface ModelRuntimeConfig {
30
+ /** The Drizzle database instance. */
31
+ db: unknown;
32
+ /** The SQL dialect of the database. */
33
+ dialect: ModelDialect;
34
+ /** User-defined model options (format, methods, where, …). */
35
+ options: ModelOptions<never, never, never, never>;
36
+ /** The Drizzle relations metadata map. */
37
+ relations: Record<string, AnyRecord>;
38
+ /** The full schema map (`{ tableName: drizzleTable }`). */
39
+ schema: Record<string, AnyRecord>;
40
+ /** The name of the table this model represents. */
41
+ tableName: string;
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // ModelRuntime
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * The runtime implementation behind every model instance.
50
+ *
51
+ * Exposes query methods (`findMany`, `findFirst`), mutation methods
52
+ * (`insert`, `update`, `delete`, `upsert`), and lifecycle helpers
53
+ * (`where`, `extend`, `db`, `include`).
54
+ *
55
+ * Internally delegates to specialised helpers:
56
+ * - {@link WhereCompiler} — compiles where clauses.
57
+ * - {@link ProjectionBuilder} — builds column projections.
58
+ * - {@link JoinExecutor} — executes relation joins.
59
+ * - {@link ResultTransformer} — applies post-query transforms.
60
+ *
61
+ * Each call to `.where()` returns a **new** runtime with the additional
62
+ * filter applied, keeping the original immutable.
63
+ */
64
+ export class ModelRuntime {
65
+ /** Static configuration for this model. */
66
+ private readonly config: ModelRuntimeConfig;
67
+
68
+ /** The current where filter applied via `.where()`. */
69
+ private readonly currentWhere: unknown;
70
+
71
+ // Shared helpers (stateless, safe to reuse)
72
+ private readonly whereCompiler: WhereCompiler;
73
+ private readonly projection: ProjectionBuilder;
74
+ private readonly joinExecutor: JoinExecutor;
75
+ private readonly transformer: ResultTransformer;
76
+ private readonly dialectHelper: DialectHelper;
77
+
78
+ constructor(config: ModelRuntimeConfig, currentWhere?: unknown) {
79
+ this.config = config;
80
+ this.currentWhere = currentWhere;
81
+
82
+ this.dialectHelper = new DialectHelper(config.dialect);
83
+ this.whereCompiler = new WhereCompiler();
84
+ this.projection = new ProjectionBuilder();
85
+ this.joinExecutor = new JoinExecutor(this.dialectHelper);
86
+ this.transformer = new ResultTransformer();
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Public: identity & meta
91
+ // ---------------------------------------------------------------------------
92
+
93
+ /** Model discriminator tag. */
94
+ get $model(): "model" {
95
+ return "model";
96
+ }
97
+
98
+ /** The name of the table this model is bound to. */
99
+ get $modelName(): string {
100
+ return this.config.tableName;
101
+ }
102
+
103
+ /** The user-defined format function, if any. */
104
+ get $format(): unknown {
105
+ return this.config.options.format;
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Public: filtering
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /**
113
+ * Returns a new runtime with an additional where filter.
114
+ *
115
+ * The new filter is AND-ed with any existing model-level where clause
116
+ * at execution time.
117
+ *
118
+ * @param value - A where clause (object, SQL, or model reference).
119
+ * @returns A new {@link ModelRuntime} with the filter applied.
120
+ */
121
+ where(value: unknown): ModelRuntime {
122
+ return new ModelRuntime(this.config, value);
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Public: lifecycle
127
+ // ---------------------------------------------------------------------------
128
+
129
+ /**
130
+ * Returns the `.with()` value as-is.
131
+ *
132
+ * This is a pass-through used at the type level to allow
133
+ * `model.include({ posts: true })` syntax.
134
+ *
135
+ * @param value - The relation include descriptor.
136
+ * @returns The same value, unchanged.
137
+ */
138
+ include(value: unknown): unknown {
139
+ return value;
140
+ }
141
+
142
+ /**
143
+ * Creates a new runtime with merged options.
144
+ *
145
+ * Useful for extending a base model with additional format functions,
146
+ * methods, or default where clauses.
147
+ *
148
+ * @param nextOptions - Partial options to merge.
149
+ * @returns A new {@link ModelRuntime} with the merged configuration.
150
+ */
151
+ extend(
152
+ nextOptions: Partial<ModelOptions<never, never, never, never>>
153
+ ): ModelRuntime {
154
+ return new ModelRuntime({
155
+ ...this.config,
156
+ options: {
157
+ ...this.config.options,
158
+ ...nextOptions,
159
+ methods: {
160
+ ...(nextOptions.methods ?? {}),
161
+ ...(this.config.options.methods ?? {}),
162
+ },
163
+ format: nextOptions.format ?? this.config.options.format,
164
+ } as ModelOptions<never, never, never, never>,
165
+ });
166
+ }
167
+
168
+ /**
169
+ * Creates a new runtime bound to a different database instance.
170
+ *
171
+ * @param db - The new Drizzle database instance.
172
+ * @returns A new {@link ModelRuntime} using the given database.
173
+ */
174
+ db(db: unknown): ModelRuntime {
175
+ return new ModelRuntime({ ...this.config, db });
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Public: queries
180
+ // ---------------------------------------------------------------------------
181
+
182
+ /**
183
+ * Returns a thenable that resolves to an array of matching rows.
184
+ *
185
+ * Supports chaining: `.select()`, `.exclude()`, `.with()`, `.raw()`.
186
+ *
187
+ * @returns A {@link QueryResult} that can be awaited or further chained.
188
+ */
189
+ findMany(): QueryResult<unknown> {
190
+ const runner = async (qState: QueryState): Promise<unknown> => {
191
+ const table = this.getTable();
192
+ const whereSql = this.buildEffectiveWhere(table);
193
+
194
+ let result: unknown;
195
+
196
+ if (qState.with) {
197
+ result = await this.joinExecutor.execute({
198
+ db: this.config.db,
199
+ schema: this.config.schema,
200
+ relations: this.config.relations,
201
+ baseTableName: this.config.tableName,
202
+ baseTable: table,
203
+ whereSql,
204
+ withValue: qState.with as AnyRecord,
205
+ limitOne: false,
206
+ });
207
+ } else {
208
+ const { selectMap } = this.projection.build(
209
+ table,
210
+ qState.select,
211
+ qState.exclude
212
+ );
213
+ const db = this.config.db as AnyRecord;
214
+ let query = (db.select as (m: AnyRecord) => AnyRecord)(selectMap);
215
+ query = (
216
+ query as AnyRecord & { from: (t: AnyRecord) => AnyRecord }
217
+ ).from(table);
218
+
219
+ if (whereSql) {
220
+ query = (
221
+ query as AnyRecord & { where: (w: unknown) => AnyRecord }
222
+ ).where(whereSql);
223
+ }
224
+
225
+ result = await (query as unknown as PromiseLike<unknown>);
226
+ }
227
+
228
+ return this.applyPostQueryTransforms(result, qState);
229
+ };
230
+
231
+ return new QueryResult({} as QueryState, runner);
232
+ }
233
+
234
+ /**
235
+ * Returns a thenable that resolves to the first matching row (or `undefined`).
236
+ *
237
+ * Supports chaining: `.select()`, `.exclude()`, `.with()`, `.raw()`.
238
+ *
239
+ * @returns A {@link QueryResult} that can be awaited or further chained.
240
+ */
241
+ findFirst(): QueryResult<unknown> {
242
+ const runner = async (qState: QueryState): Promise<unknown> => {
243
+ const table = this.getTable();
244
+ const whereSql = this.buildEffectiveWhere(table);
245
+
246
+ let result: unknown;
247
+
248
+ if (qState.with) {
249
+ result = await this.joinExecutor.execute({
250
+ db: this.config.db,
251
+ schema: this.config.schema,
252
+ relations: this.config.relations,
253
+ baseTableName: this.config.tableName,
254
+ baseTable: table,
255
+ whereSql,
256
+ withValue: qState.with as AnyRecord,
257
+ limitOne: true,
258
+ });
259
+ } else {
260
+ const { selectMap } = this.projection.build(
261
+ table,
262
+ qState.select,
263
+ qState.exclude
264
+ );
265
+ const db = this.config.db as AnyRecord;
266
+ let query = (db.select as (m: AnyRecord) => AnyRecord)(selectMap);
267
+ query = (
268
+ query as AnyRecord & { from: (t: AnyRecord) => AnyRecord }
269
+ ).from(table);
270
+
271
+ if (whereSql) {
272
+ query = (
273
+ query as AnyRecord & { where: (w: unknown) => AnyRecord }
274
+ ).where(whereSql);
275
+ }
276
+
277
+ query = (
278
+ query as AnyRecord & { limit: (n: number) => AnyRecord }
279
+ ).limit(1);
280
+ const rows = (await (query as unknown as PromiseLike<
281
+ unknown[]
282
+ >)) as unknown[];
283
+ result = rows[0];
284
+ }
285
+
286
+ return this.applyPostQueryTransforms(result, qState);
287
+ };
288
+
289
+ return new QueryResult({} as QueryState, runner);
290
+ }
291
+
292
+ /**
293
+ * Returns a promise that resolves to the number of matching rows.
294
+ *
295
+ * Respects the effective where clause (model-level + `.where()`).
296
+ *
297
+ * @returns A `Promise<number>` with the row count.
298
+ */
299
+ async count(): Promise<number> {
300
+ const table = this.getTable();
301
+ const whereSql = this.buildEffectiveWhere(table);
302
+
303
+ const db = this.config.db as AnyRecord;
304
+ const { count: countFn } = await import("drizzle-orm");
305
+
306
+ let query = (db.select as (m: AnyRecord) => AnyRecord)({
307
+ count: countFn(),
308
+ });
309
+ query = (query as AnyRecord & { from: (t: AnyRecord) => AnyRecord }).from(
310
+ table
311
+ );
312
+
313
+ if (whereSql) {
314
+ query = (query as AnyRecord & { where: (w: unknown) => AnyRecord }).where(
315
+ whereSql
316
+ );
317
+ }
318
+
319
+ const rows = (await (query as unknown as PromiseLike<
320
+ AnyRecord[]
321
+ >)) as AnyRecord[];
322
+ return (rows[0]?.count as number) ?? 0;
323
+ }
324
+
325
+ // ---------------------------------------------------------------------------
326
+ // Public: mutations
327
+ // ---------------------------------------------------------------------------
328
+
329
+ /**
330
+ * Inserts one or more rows into the table.
331
+ *
332
+ * @param value - The row(s) to insert.
333
+ * @returns A {@link MutateResult} that can be awaited or chained with `.return()`.
334
+ */
335
+ insert(value: unknown): MutateResult<unknown> {
336
+ const runner = async (mState: MutateState): Promise<unknown> => {
337
+ const table = this.getTable();
338
+ const db = this.config.db as AnyRecord;
339
+ const query = (db.insert as (t: AnyRecord) => AnyRecord)(table);
340
+ const withValues = (
341
+ query as AnyRecord & { values: (v: unknown) => unknown }
342
+ ).values(mState.value);
343
+
344
+ let result = await this.execReturning(withValues, mState);
345
+
346
+ if (
347
+ !(mState.hasReturn || Array.isArray(mState.value)) &&
348
+ Array.isArray(result)
349
+ ) {
350
+ result = (result as unknown[])[0];
351
+ }
352
+
353
+ return this.applyPostMutateTransforms(result, mState);
354
+ };
355
+
356
+ return new MutateResult({ kind: "insert" as MutateKind, value }, runner);
357
+ }
358
+
359
+ /**
360
+ * Updates rows matching the current where clause.
361
+ *
362
+ * @param value - The partial row data to set.
363
+ * @returns A {@link MutateResult} that can be awaited or chained with `.return()`.
364
+ */
365
+ update(value: unknown): MutateResult<unknown> {
366
+ const runner = async (mState: MutateState): Promise<unknown> => {
367
+ const table = this.getTable();
368
+ const whereSql = this.buildEffectiveWhere(table);
369
+ const db = this.config.db as AnyRecord;
370
+
371
+ let query: unknown = (db.update as (t: AnyRecord) => AnyRecord)(table);
372
+ query = (query as AnyRecord & { set: (v: unknown) => unknown }).set(
373
+ mState.value
374
+ );
375
+
376
+ if (whereSql) {
377
+ query = (query as AnyRecord & { where: (w: unknown) => unknown }).where(
378
+ whereSql
379
+ );
380
+ }
381
+
382
+ const result = await this.execReturning(query, mState);
383
+ return this.applyPostMutateTransforms(result, mState);
384
+ };
385
+
386
+ return new MutateResult({ kind: "update" as MutateKind, value }, runner);
387
+ }
388
+
389
+ /**
390
+ * Deletes rows matching the current where clause.
391
+ *
392
+ * @returns A {@link MutateResult} that can be awaited or chained with `.return()`.
393
+ */
394
+ delete(): MutateResult<unknown> {
395
+ const runner = async (mState: MutateState): Promise<unknown> => {
396
+ const table = this.getTable();
397
+ const whereSql = this.buildEffectiveWhere(table);
398
+ const db = this.config.db as AnyRecord;
399
+
400
+ let query: unknown = (db.delete as (t: AnyRecord) => unknown)(table);
401
+
402
+ if (whereSql) {
403
+ query = (query as AnyRecord & { where: (w: unknown) => unknown }).where(
404
+ whereSql
405
+ );
406
+ }
407
+
408
+ const result = await this.execReturning(query, mState);
409
+ return this.applyPostMutateTransforms(result, mState);
410
+ };
411
+
412
+ return new MutateResult({ kind: "delete" as MutateKind }, runner);
413
+ }
414
+
415
+ /**
416
+ * Inserts a row, or updates it when a conflict is detected.
417
+ *
418
+ * The `value` must contain `insert`, `update`, and optionally `target`.
419
+ *
420
+ * @param value - The upsert descriptor.
421
+ * @returns A {@link MutateResult} that can be awaited or chained with `.return()`.
422
+ */
423
+ upsert(value: unknown): MutateResult<unknown> {
424
+ const runner = async (mState: MutateState): Promise<unknown> => {
425
+ const table = this.getTable();
426
+ const upsertValue = mState.value as AnyRecord;
427
+
428
+ const insertValues = upsertValue.insert;
429
+ const updateCfg = upsertValue.update;
430
+ const target = this.normalizeUpsertTarget(table, upsertValue.target);
431
+
432
+ let updateSet = updateCfg;
433
+ if (typeof updateCfg === "function") {
434
+ updateSet = (updateCfg as (ctx: AnyRecord) => unknown)({
435
+ excluded: (field: string) => (table as AnyRecord)[field],
436
+ inserted: (field: string) => (table as AnyRecord)[field],
437
+ });
438
+ }
439
+
440
+ const db = this.config.db as AnyRecord;
441
+ let query: unknown = (db.insert as (t: AnyRecord) => AnyRecord)(table);
442
+ query = (query as AnyRecord & { values: (v: unknown) => unknown }).values(
443
+ insertValues
444
+ );
445
+
446
+ const queryRecord = query as AnyRecord;
447
+ if (typeof queryRecord.onConflictDoUpdate === "function") {
448
+ query = (queryRecord.onConflictDoUpdate as (cfg: AnyRecord) => unknown)(
449
+ {
450
+ target,
451
+ set: updateSet,
452
+ }
453
+ );
454
+ }
455
+
456
+ const result = await this.execReturning(query, mState);
457
+ return this.applyPostMutateTransforms(result, mState);
458
+ };
459
+
460
+ return new MutateResult({ kind: "upsert" as MutateKind, value }, runner);
461
+ }
462
+
463
+ // ---------------------------------------------------------------------------
464
+ // Public: method attachment
465
+ // ---------------------------------------------------------------------------
466
+
467
+ /**
468
+ * Attaches user-defined methods from the model options to a target object.
469
+ *
470
+ * Each method is bound to `target` so that `this` inside the method
471
+ * refers to the model-like object.
472
+ *
473
+ * @param target - The object to attach methods to.
474
+ */
475
+ attachMethods(target: AnyRecord): void {
476
+ const methods = this.config.options.methods;
477
+ if (!methods) {
478
+ return;
479
+ }
480
+
481
+ for (const [key, fn] of Object.entries(methods)) {
482
+ if (typeof fn === "function") {
483
+ target[key] = (fn as (...args: unknown[]) => unknown).bind(target);
484
+ }
485
+ }
486
+ }
487
+
488
+ // ---------------------------------------------------------------------------
489
+ // Private: helpers
490
+ // ---------------------------------------------------------------------------
491
+
492
+ /**
493
+ * Applies `returnFirst` and `omit` transforms to a mutation result.
494
+ */
495
+ private applyPostMutateTransforms(
496
+ result: unknown,
497
+ mState: MutateState
498
+ ): unknown {
499
+ let out = result;
500
+
501
+ if (mState.returnFirst && Array.isArray(out)) {
502
+ out = (out as unknown[])[0];
503
+ }
504
+
505
+ if (mState.omit) {
506
+ out = this.transformer.applyExclude(out, mState.omit);
507
+ }
508
+
509
+ return out;
510
+ }
511
+
512
+ /**
513
+ * Retrieves the Drizzle table object for the configured table name.
514
+ */
515
+ private getTable(): AnyRecord {
516
+ return this.config.schema[this.config.tableName] as AnyRecord;
517
+ }
518
+
519
+ /**
520
+ * Builds the effective where clause by merging the model-level where
521
+ * (from options) with the call-level where (from `.where()`).
522
+ */
523
+ private buildEffectiveWhere(table: AnyRecord): unknown {
524
+ return this.whereCompiler.compileEffective(
525
+ table,
526
+ this.config.options.where,
527
+ this.currentWhere
528
+ );
529
+ }
530
+
531
+ /**
532
+ * Applies select, exclude, and format transforms to the query result.
533
+ */
534
+ private applyPostQueryTransforms(
535
+ result: unknown,
536
+ qState: QueryState
537
+ ): unknown {
538
+ let out = result;
539
+
540
+ if (qState.select) {
541
+ out = this.transformer.applySelect(out, qState.select);
542
+ }
543
+ if (qState.exclude) {
544
+ out = this.transformer.applyExclude(out, qState.exclude);
545
+ }
546
+ if (!qState.raw) {
547
+ out = this.transformer.applyFormat(
548
+ out,
549
+ this.config.options.format as ((row: AnyRecord) => unknown) | undefined
550
+ );
551
+ }
552
+
553
+ return out;
554
+ }
555
+
556
+ /**
557
+ * Executes the returning clause for a mutation query.
558
+ *
559
+ * Handles the dialect-specific differences:
560
+ * - Standard `.returning()` (PostgreSQL, SQLite).
561
+ * - `.$returningId()` (MySQL, SingleStore, CockroachDB).
562
+ *
563
+ * @param query - The built mutation query.
564
+ * @param mState - The mutation state (may contain `returnSelect`).
565
+ * @returns The mutation result.
566
+ */
567
+ private async execReturning(
568
+ query: unknown,
569
+ mState: MutateState
570
+ ): Promise<unknown> {
571
+ const queryRecord = query as AnyRecord;
572
+
573
+ if (typeof queryRecord.returning === "function") {
574
+ return mState.returnSelect
575
+ ? await (
576
+ queryRecord.returning as (sel: AnyRecord) => PromiseLike<unknown>
577
+ )(mState.returnSelect)
578
+ : await (queryRecord.returning as () => PromiseLike<unknown>)();
579
+ }
580
+
581
+ if (
582
+ this.dialectHelper.isReturningIdOnly() &&
583
+ typeof queryRecord.$returningId === "function"
584
+ ) {
585
+ return await (queryRecord.$returningId as () => PromiseLike<unknown>)();
586
+ }
587
+
588
+ return await (query as PromiseLike<unknown>);
589
+ }
590
+
591
+ /**
592
+ * Normalizes the upsert conflict target.
593
+ *
594
+ * Converts string column names to their Drizzle column references,
595
+ * handling both single values and arrays.
596
+ */
597
+ private normalizeUpsertTarget(table: AnyRecord, target: unknown): unknown {
598
+ if (!target) {
599
+ return target;
600
+ }
601
+
602
+ if (typeof target === "string") {
603
+ return table[target] ?? target;
604
+ }
605
+
606
+ if (Array.isArray(target)) {
607
+ return target.map((t) => (typeof t === "string" ? (table[t] ?? t) : t));
608
+ }
609
+
610
+ return target;
611
+ }
612
+ }