@vibeorm/runtime 1.0.2 → 1.1.1

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.
@@ -18,6 +18,7 @@ import type {
18
18
  import { getModelByNameMap, getScalarFieldMap, PgArray } from "./types.ts";
19
19
  import { buildRelationQuery, buildManyToManyQuery, buildSelectQuery } from "./query-builder.ts";
20
20
  import type { RelationSqlQuery } from "./query-builder.ts";
21
+ import { buildWhereClause } from "./where-builder.ts";
21
22
 
22
23
  type SqlExecutor = (params: {
23
24
  text: string;
@@ -41,8 +42,9 @@ export async function loadRelations(params: {
41
42
  args: Record<string, unknown>;
42
43
  executor: SqlExecutor;
43
44
  profilingCtx?: ProfilingContext;
45
+ defaultOrderByPk?: boolean;
44
46
  }): Promise<Record<string, unknown>[]> {
45
- const { parentRecords, parentModelMeta, allModelsMeta, args, executor, profilingCtx } =
47
+ const { parentRecords, parentModelMeta, allModelsMeta, args, executor, profilingCtx, defaultOrderByPk = false } =
46
48
  params;
47
49
 
48
50
  if (parentRecords.length === 0) return parentRecords;
@@ -86,6 +88,7 @@ export async function loadRelations(params: {
86
88
  executor,
87
89
  pkField,
88
90
  profilingCtx,
91
+ defaultOrderByPk,
89
92
  });
90
93
  } else if (relationMeta.isForeignKey) {
91
94
  await loadToOneWithFk({
@@ -110,6 +113,7 @@ export async function loadRelations(params: {
110
113
  executor,
111
114
  pkField,
112
115
  profilingCtx,
116
+ defaultOrderByPk,
113
117
  });
114
118
  }
115
119
  })
@@ -173,6 +177,7 @@ async function loadToManyRelation(params: {
173
177
  executor: SqlExecutor;
174
178
  pkField: string;
175
179
  profilingCtx?: ProfilingContext;
180
+ defaultOrderByPk?: boolean;
176
181
  }): Promise<void> {
177
182
  const {
178
183
  parentRecords,
@@ -185,6 +190,7 @@ async function loadToManyRelation(params: {
185
190
  executor,
186
191
  pkField,
187
192
  profilingCtx,
193
+ defaultOrderByPk = false,
188
194
  } = params;
189
195
 
190
196
  // Choose builder based on relation type
@@ -198,6 +204,7 @@ async function loadToManyRelation(params: {
198
204
  parentIds,
199
205
  args: nestedArgs,
200
206
  allModelsMeta,
207
+ defaultOrderByPk,
201
208
  })
202
209
  : buildRelationQuery({
203
210
  parentModelMeta,
@@ -206,6 +213,7 @@ async function loadToManyRelation(params: {
206
213
  parentIds,
207
214
  args: nestedArgs,
208
215
  allModelsMeta,
216
+ defaultOrderByPk,
209
217
  });
210
218
 
211
219
  const t0 = profilingCtx ? performance.now() : 0;
@@ -296,18 +304,38 @@ async function loadToOneWithFk(params: {
296
304
  .map((c) => `${table}."${c.dbName}" AS "${c.name}"`)
297
305
  .join(", ");
298
306
 
299
- const text = `SELECT ${columnsSql}, ${table}."${refDbName}" AS "__vibeorm_pk" FROM ${table} WHERE ${table}."${refDbName}" = ANY($1)`;
300
-
301
- const t0 = profilingCtx ? performance.now() : 0;
302
- const rows = await executor({ text, values: [new PgArray(fkValues)] });
303
- if (profilingCtx) {
304
- profilingCtx.relationProfiles.push({
305
- relation: relationMeta.name,
306
- sqlExecMs: performance.now() - t0,
307
- rowCount: rows.length,
308
- sql: text,
309
- });
307
+ // Build the base WHERE: pk = ANY($1) for batched loading
308
+ const allValues: unknown[] = [new PgArray(fkValues)];
309
+ let paramIdx = 1;
310
+ let whereSql = `${table}."${refDbName}" = ANY($${paramIdx})`;
311
+
312
+ // Apply nested where filters (e.g., include: { author: { where: { isActive: true } } })
313
+ if (nestedArgs.where) {
314
+ const subWhere = buildWhereClause({
315
+ where: nestedArgs.where as Record<string, unknown>,
316
+ modelMeta: relatedModelMeta,
317
+ allModelsMeta,
318
+ paramOffset: paramIdx,
319
+ });
320
+ if (subWhere.sql) {
321
+ whereSql += ` AND (${subWhere.sql})`;
322
+ allValues.push(...subWhere.values);
323
+ paramIdx += subWhere.values.length;
310
324
  }
325
+ }
326
+
327
+ const text = `SELECT ${columnsSql}, ${table}."${refDbName}" AS "__vibeorm_pk" FROM ${table} WHERE ${whereSql}`;
328
+
329
+ const t0 = profilingCtx ? performance.now() : 0;
330
+ const rows = await executor({ text, values: allValues });
331
+ if (profilingCtx) {
332
+ profilingCtx.relationProfiles.push({
333
+ relation: relationMeta.name,
334
+ sqlExecMs: performance.now() - t0,
335
+ rowCount: rows.length,
336
+ sql: text,
337
+ });
338
+ }
311
339
 
312
340
  // Index by PK
313
341
  const byPk = new Map<unknown, Record<string, unknown>>();
@@ -337,6 +365,7 @@ async function loadToOneWithoutFk(params: {
337
365
  executor: SqlExecutor;
338
366
  pkField: string;
339
367
  profilingCtx?: ProfilingContext;
368
+ defaultOrderByPk?: boolean;
340
369
  }): Promise<void> {
341
370
  const {
342
371
  parentRecords,
@@ -349,6 +378,7 @@ async function loadToOneWithoutFk(params: {
349
378
  executor,
350
379
  pkField,
351
380
  profilingCtx,
381
+ defaultOrderByPk = false,
352
382
  } = params;
353
383
 
354
384
  const query = buildRelationQuery({
@@ -358,6 +388,7 @@ async function loadToOneWithoutFk(params: {
358
388
  parentIds,
359
389
  args: nestedArgs,
360
390
  allModelsMeta,
391
+ defaultOrderByPk,
361
392
  });
362
393
 
363
394
  const t0 = profilingCtx ? performance.now() : 0;
@@ -389,12 +420,12 @@ async function loadToOneWithoutFk(params: {
389
420
 
390
421
  // ─── Helpers ──────────────────────────────────────────────────────
391
422
 
392
- type RelationToLoad = {
423
+ export type RelationToLoad = {
393
424
  relationMeta: RelationFieldMeta;
394
425
  nestedArgs: Record<string, unknown>;
395
426
  };
396
427
 
397
- function resolveRelationsToLoad(params: {
428
+ export function resolveRelationsToLoad(params: {
398
429
  parentModelMeta: ModelMeta;
399
430
  args: Record<string, unknown>;
400
431
  }): RelationToLoad[] {
@@ -436,7 +467,7 @@ function resolveRelationsToLoad(params: {
436
467
  return result;
437
468
  }
438
469
 
439
- function hasNestedRelations(params: {
470
+ export function hasNestedRelations(params: {
440
471
  nestedArgs: Record<string, unknown>;
441
472
  }): boolean {
442
473
  const { nestedArgs } = params;
@@ -449,7 +480,7 @@ function hasNestedRelations(params: {
449
480
  function resolveSelectColumnsForRelation(params: {
450
481
  modelMeta: ModelMeta;
451
482
  args: Record<string, unknown>;
452
- }): { name: string; dbName: string; autoIncluded?: boolean }[] {
483
+ }): { name: string; dbName: string }[] {
453
484
  const { modelMeta, args } = params;
454
485
  const select = args.select as Record<string, boolean | object> | undefined;
455
486
 
@@ -468,18 +499,21 @@ function resolveSelectColumnsForRelation(params: {
468
499
  .map((f) => ({ name: f.name, dbName: f.dbName }));
469
500
 
470
501
  // Check if select has nested relations (object values referencing relation fields)
502
+ const relationNameSet = new Set(modelMeta.relationFields.map((r) => r.name));
471
503
  const hasNested = Object.entries(select).some(([key, val]) => {
472
504
  if (typeof val !== "object" || val === null) return false;
473
- return modelMeta.relationFields.some((r) => r.name === key);
505
+ return relationNameSet.has(key);
474
506
  });
475
507
 
476
508
  if (hasNested) {
477
509
  // Auto-include PK fields if not already selected
510
+ const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
511
+ const selectedNames = new Set(columns.map((c) => c.name));
478
512
  for (const pkName of modelMeta.primaryKey) {
479
- if (!columns.some((c) => c.name === pkName)) {
480
- const sf = modelMeta.scalarFields.find((f) => f.name === pkName);
513
+ if (!selectedNames.has(pkName)) {
514
+ const sf = sfMap.get(pkName);
481
515
  if (sf) {
482
- columns.push({ name: sf.name, dbName: sf.dbName, autoIncluded: true });
516
+ columns.push({ name: sf.name, dbName: sf.dbName });
483
517
  }
484
518
  }
485
519
  }
package/src/types.ts CHANGED
@@ -27,6 +27,9 @@ export type VibeClientOptions = {
27
27
  * PlanetScale, Supabase pooler) but disables parallel workers on standard
28
28
  * PostgreSQL. Use this if count queries are consistently slow on your
29
29
  * remote database.
30
+ *
31
+ * This can be overridden per operation with:
32
+ * `db.model.count({ where: {...}, countStrategy: "subquery" })`.
30
33
  */
31
34
  countStrategy?: "direct" | "subquery";
32
35
  /**
@@ -62,6 +65,28 @@ export type VibeClientOptions = {
62
65
  * ```
63
66
  */
64
67
  idGenerator?: (params: { model: string; field: string; defaultKind: string }) => string;
68
+ /**
69
+ * Add primary key as a default ORDER BY fallback when no explicit orderBy is given.
70
+ * Applies to: findMany, findFirst, relation loading (query + lateral join strategies).
71
+ * Does NOT affect: findUnique, create, update, delete, count, aggregate, groupBy.
72
+ *
73
+ * When enabled, queries without an explicit `orderBy` will append
74
+ * `ORDER BY "Table"."pk" ASC` to produce deterministic result ordering.
75
+ *
76
+ * @default false
77
+ */
78
+ defaultOrderByPk?: boolean;
79
+ /**
80
+ * Add primary key as a tie-breaker in DISTINCT ON queries when the user
81
+ * doesn't provide a secondary orderBy beyond the distinct columns.
82
+ * This ensures deterministic row selection per distinct group.
83
+ *
84
+ * When disabled, DISTINCT ON returns an arbitrary row per group
85
+ * (standard PostgreSQL behaviour).
86
+ *
87
+ * @default true
88
+ */
89
+ distinctOrderByPk?: boolean;
65
90
  /**
66
91
  * Enable per-query profiling with detailed timing breakdowns.
67
92
  * - false (default): no profiling — zero runtime overhead
@@ -145,6 +145,9 @@ export function buildWhereClause(params: {
145
145
  }
146
146
  continue;
147
147
  }
148
+
149
+ const modelLabel = modelMeta.name ?? modelMeta.dbName;
150
+ throw new Error(`Unknown filter field "${key}" for model "${modelLabel}"`);
148
151
  }
149
152
 
150
153
  const sql = conditions.length > 0 ? conditions.join(" AND ") : "";
@@ -209,6 +212,7 @@ function buildRelationFilter(params: {
209
212
  // To-many: some / none / every / shorthand
210
213
  return buildToManyRelationFilter({
211
214
  filter,
215
+ relationName: relationField.name,
212
216
  relatedModelMeta,
213
217
  relatedTable,
214
218
  relatedFrom,
@@ -221,6 +225,7 @@ function buildRelationFilter(params: {
221
225
  // To-one: is / isNot / shorthand (treated as "is")
222
226
  return buildToOneRelationFilter({
223
227
  filter,
228
+ relationName: relationField.name,
224
229
  parentModelMeta,
225
230
  relationField,
226
231
  relatedModelMeta,
@@ -237,6 +242,7 @@ function buildRelationFilter(params: {
237
242
 
238
243
  function buildToManyRelationFilter(params: {
239
244
  filter: Record<string, unknown>;
245
+ relationName: string;
240
246
  relatedModelMeta: ModelMeta;
241
247
  relatedTable: string;
242
248
  relatedFrom: string;
@@ -245,7 +251,7 @@ function buildToManyRelationFilter(params: {
245
251
  paramIdx: number;
246
252
  ctx: WhereContext;
247
253
  }): WhereClause {
248
- const { filter, relatedModelMeta, relatedTable, relatedFrom, joinCondition, allModelsMeta, paramIdx, ctx } = params;
254
+ const { filter, relationName, relatedModelMeta, relatedTable, relatedFrom, joinCondition, allModelsMeta, paramIdx, ctx } = params;
249
255
 
250
256
  // For self-referential relations, create a modelMeta copy with the alias as dbName
251
257
  // so nested buildWhereClause generates column refs using the alias
@@ -311,6 +317,10 @@ function buildToManyRelationFilter(params: {
311
317
  );
312
318
  break; // We consumed the entire filter object
313
319
  }
320
+
321
+ throw new Error(
322
+ `Unsupported relation filter key "${op}" on list relation "${relationName}"`
323
+ );
314
324
  }
315
325
  }
316
326
 
@@ -322,6 +332,7 @@ function buildToManyRelationFilter(params: {
322
332
 
323
333
  function buildToOneRelationFilter(params: {
324
334
  filter: Record<string, unknown>;
335
+ relationName: string;
325
336
  parentModelMeta: ModelMeta;
326
337
  relationField: RelationFieldMeta;
327
338
  relatedModelMeta: ModelMeta;
@@ -334,7 +345,7 @@ function buildToOneRelationFilter(params: {
334
345
  ctx: WhereContext;
335
346
  }): WhereClause {
336
347
  const {
337
- filter, parentModelMeta, relationField, relatedModelMeta,
348
+ filter, relationName, parentModelMeta, relationField, relatedModelMeta,
338
349
  parentTable, relatedTable, relatedFrom, joinCondition, allModelsMeta, paramIdx, ctx,
339
350
  } = params;
340
351
 
@@ -426,6 +437,10 @@ function buildToOneRelationFilter(params: {
426
437
  );
427
438
  break; // Consumed the whole filter
428
439
  }
440
+
441
+ throw new Error(
442
+ `Unsupported relation filter key "${op}" on relation "${relationName}"`
443
+ );
429
444
  }
430
445
  }
431
446
 
@@ -488,6 +503,20 @@ function resolveJoinCondition(params: {
488
503
  return `${relatedAlias}."${fkGuess}" = ${parentAlias}."${pkDbName}"`;
489
504
  }
490
505
 
506
+ // ─── LIKE Escape Helper ───────────────────────────────────────────
507
+
508
+ /**
509
+ * Escape SQL LIKE/ILIKE wildcard characters (% and _) in a user-supplied
510
+ * string so they are treated as literal characters. Uses backslash as the
511
+ * escape character — the generated LIKE clause MUST include `ESCAPE '\'`.
512
+ */
513
+ function escapeLikePattern(params: { value: string }): string {
514
+ return params.value
515
+ .replace(/\\/g, "\\\\")
516
+ .replace(/%/g, "\\%")
517
+ .replace(/_/g, "\\_");
518
+ }
519
+
491
520
  // ─── Scalar Filter Compiler ───────────────────────────────────────
492
521
 
493
522
  function buildScalarFilter(params: {
@@ -603,22 +632,25 @@ function buildScalarFilter(params: {
603
632
  case "contains": {
604
633
  idx++;
605
634
  const mode = filter.mode === "insensitive" ? "ILIKE" : "LIKE";
606
- conditions.push(`${col} ${mode} $${idx}`);
607
- values.push(`%${operand}%`);
635
+ const escaped = escapeLikePattern({ value: operand as string });
636
+ conditions.push(`${col} ${mode} $${idx} ESCAPE '\\'`);
637
+ values.push(`%${escaped}%`);
608
638
  break;
609
639
  }
610
640
  case "startsWith": {
611
641
  idx++;
612
642
  const mode = filter.mode === "insensitive" ? "ILIKE" : "LIKE";
613
- conditions.push(`${col} ${mode} $${idx}`);
614
- values.push(`${operand}%`);
643
+ const escaped = escapeLikePattern({ value: operand as string });
644
+ conditions.push(`${col} ${mode} $${idx} ESCAPE '\\'`);
645
+ values.push(`${escaped}%`);
615
646
  break;
616
647
  }
617
648
  case "endsWith": {
618
649
  idx++;
619
650
  const mode = filter.mode === "insensitive" ? "ILIKE" : "LIKE";
620
- conditions.push(`${col} ${mode} $${idx}`);
621
- values.push(`%${operand}`);
651
+ const escaped = escapeLikePattern({ value: operand as string });
652
+ conditions.push(`${col} ${mode} $${idx} ESCAPE '\\'`);
653
+ values.push(`%${escaped}`);
622
654
  break;
623
655
  }
624
656
  case "mode":
@@ -633,40 +665,43 @@ function buildScalarFilter(params: {
633
665
  case "string_contains": {
634
666
  const path = filter.path as string[] | undefined;
635
667
  const jsonCol = path && path.length > 0
636
- ? `(${col}${path.map((p) => `->>'${p}'`).join("")})`
668
+ ? `(${col}${path.map((p) => `->>` + `'${p.replace(/'/g, "''")}'`).join("")})`
637
669
  : `(${col}#>>'{}')`;
638
670
  idx++;
639
671
  const jmode = filter.mode === "insensitive" ? "ILIKE" : "LIKE";
640
- conditions.push(`${jsonCol} ${jmode} $${idx}`);
641
- values.push(`%${operand}%`);
672
+ const escaped = escapeLikePattern({ value: operand as string });
673
+ conditions.push(`${jsonCol} ${jmode} $${idx} ESCAPE '\\'`);
674
+ values.push(`%${escaped}%`);
642
675
  break;
643
676
  }
644
677
  case "string_starts_with": {
645
678
  const path = filter.path as string[] | undefined;
646
679
  const jsonCol = path && path.length > 0
647
- ? `(${col}${path.map((p) => `->>'${p}'`).join("")})`
680
+ ? `(${col}${path.map((p) => `->>` + `'${p.replace(/'/g, "''")}'`).join("")})`
648
681
  : `(${col}#>>'{}')`;
649
682
  idx++;
650
683
  const jmode = filter.mode === "insensitive" ? "ILIKE" : "LIKE";
651
- conditions.push(`${jsonCol} ${jmode} $${idx}`);
652
- values.push(`${operand}%`);
684
+ const escaped = escapeLikePattern({ value: operand as string });
685
+ conditions.push(`${jsonCol} ${jmode} $${idx} ESCAPE '\\'`);
686
+ values.push(`${escaped}%`);
653
687
  break;
654
688
  }
655
689
  case "string_ends_with": {
656
690
  const path = filter.path as string[] | undefined;
657
691
  const jsonCol = path && path.length > 0
658
- ? `(${col}${path.map((p) => `->>'${p}'`).join("")})`
692
+ ? `(${col}${path.map((p) => `->>` + `'${p.replace(/'/g, "''")}'`).join("")})`
659
693
  : `(${col}#>>'{}')`;
660
694
  idx++;
661
695
  const jmode = filter.mode === "insensitive" ? "ILIKE" : "LIKE";
662
- conditions.push(`${jsonCol} ${jmode} $${idx}`);
663
- values.push(`%${operand}`);
696
+ const escaped = escapeLikePattern({ value: operand as string });
697
+ conditions.push(`${jsonCol} ${jmode} $${idx} ESCAPE '\\'`);
698
+ values.push(`%${escaped}`);
664
699
  break;
665
700
  }
666
701
  case "array_contains": {
667
702
  const path = filter.path as string[] | undefined;
668
703
  const target = path && path.length > 0
669
- ? `${col}${path.map((p) => `->'${p}'`).join("")}`
704
+ ? `${col}${path.map((p) => `->'${p.replace(/'/g, "''")}'`).join("")}`
670
705
  : col;
671
706
  idx++;
672
707
  conditions.push(`${target} @> $${idx}::jsonb`);
@@ -676,7 +711,7 @@ function buildScalarFilter(params: {
676
711
  case "array_starts_with": {
677
712
  const path = filter.path as string[] | undefined;
678
713
  const target = path && path.length > 0
679
- ? `${col}${path.map((p) => `->'${p}'`).join("")}`
714
+ ? `${col}${path.map((p) => `->'${p.replace(/'/g, "''")}'`).join("")}`
680
715
  : col;
681
716
  idx++;
682
717
  conditions.push(`${target}->0 = $${idx}::jsonb`);
@@ -686,7 +721,7 @@ function buildScalarFilter(params: {
686
721
  case "array_ends_with": {
687
722
  const path = filter.path as string[] | undefined;
688
723
  const target = path && path.length > 0
689
- ? `${col}${path.map((p) => `->'${p}'`).join("")}`
724
+ ? `${col}${path.map((p) => `->'${p.replace(/'/g, "''")}'`).join("")}`
690
725
  : col;
691
726
  idx++;
692
727
  conditions.push(`${target}->-1 = $${idx}::jsonb`);
@@ -723,7 +758,7 @@ function buildScalarFilter(params: {
723
758
  }
724
759
 
725
760
  default:
726
- break;
761
+ throw new Error(`Unsupported scalar filter operator "${op}" for ${col}`);
727
762
  }
728
763
  }
729
764