@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.
- package/README.md +19 -0
- package/package.json +1 -1
- package/src/adapter.ts +33 -1
- package/src/client.ts +177 -90
- package/src/errors.ts +427 -6
- package/src/index.ts +15 -1
- package/src/lateral-join-builder.ts +157 -81
- package/src/query-builder.ts +573 -194
- package/src/relation-loader.ts +54 -20
- package/src/types.ts +25 -0
- package/src/where-builder.ts +56 -21
package/src/relation-loader.ts
CHANGED
|
@@ -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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
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
|
|
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 (!
|
|
480
|
-
const sf =
|
|
513
|
+
if (!selectedNames.has(pkName)) {
|
|
514
|
+
const sf = sfMap.get(pkName);
|
|
481
515
|
if (sf) {
|
|
482
|
-
columns.push({ name: sf.name, dbName: sf.dbName
|
|
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
|
package/src/where-builder.ts
CHANGED
|
@@ -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
|
-
|
|
607
|
-
|
|
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
|
-
|
|
614
|
-
|
|
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
|
-
|
|
621
|
-
|
|
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) =>
|
|
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
|
-
|
|
641
|
-
|
|
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) =>
|
|
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
|
-
|
|
652
|
-
|
|
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) =>
|
|
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
|
-
|
|
663
|
-
|
|
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
|
-
|
|
761
|
+
throw new Error(`Unsupported scalar filter operator "${op}" for ${col}`);
|
|
727
762
|
}
|
|
728
763
|
}
|
|
729
764
|
|