@vibeorm/runtime 1.3.0 → 2.0.0-alpha.2

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 (44) hide show
  1. package/README.md +50 -107
  2. package/dist/adapter.d.ts +124 -0
  3. package/dist/adapter.d.ts.map +1 -0
  4. package/dist/client.d.ts +152 -0
  5. package/dist/client.d.ts.map +1 -0
  6. package/dist/codecs.d.ts +170 -0
  7. package/dist/codecs.d.ts.map +1 -0
  8. package/dist/computed.d.ts +43 -0
  9. package/dist/computed.d.ts.map +1 -0
  10. package/dist/extensions.d.ts +102 -0
  11. package/dist/extensions.d.ts.map +1 -0
  12. package/dist/index.d.ts +29 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +6625 -0
  15. package/dist/index.js.map +21 -0
  16. package/dist/model-meta.d.ts +156 -0
  17. package/dist/model-meta.d.ts.map +1 -0
  18. package/dist/nested-writes.d.ts +100 -0
  19. package/dist/nested-writes.d.ts.map +1 -0
  20. package/dist/query-builder.d.ts +250 -0
  21. package/dist/query-builder.d.ts.map +1 -0
  22. package/dist/relation-loader.d.ts +75 -0
  23. package/dist/relation-loader.d.ts.map +1 -0
  24. package/dist/relation-plan.d.ts +103 -0
  25. package/dist/relation-plan.d.ts.map +1 -0
  26. package/dist/render-cache.d.ts +48 -0
  27. package/dist/render-cache.d.ts.map +1 -0
  28. package/dist/views.d.ts +97 -0
  29. package/dist/views.d.ts.map +1 -0
  30. package/package.json +31 -26
  31. package/src/adapter.ts +0 -146
  32. package/src/client.ts +0 -2172
  33. package/src/coerce.ts +0 -184
  34. package/src/count-loader.ts +0 -152
  35. package/src/errors.ts +0 -492
  36. package/src/id-generators.ts +0 -151
  37. package/src/index.ts +0 -55
  38. package/src/lateral-join-builder.ts +0 -1053
  39. package/src/query-builder.ts +0 -1832
  40. package/src/relation-loader.ts +0 -534
  41. package/src/retry.ts +0 -183
  42. package/src/types.ts +0 -317
  43. package/src/view.ts +0 -629
  44. package/src/where-builder.ts +0 -772
@@ -1,772 +0,0 @@
1
- /**
2
- * Compiles a Prisma-style WhereInput object into parameterized SQL WHERE clauses.
3
- */
4
-
5
- import type { ModelMeta, ModelMetaMap, ScalarFieldMeta, RelationFieldMeta } from "./types.ts";
6
- import { getScalarFieldMap, getModelByNameMap, PgArray } from "./types.ts";
7
-
8
- type WhereClause = {
9
- sql: string;
10
- values: unknown[];
11
- };
12
-
13
- /** Per-query context for deterministic subquery aliasing. */
14
- type WhereContext = {
15
- subqueryAliasCounter: number;
16
- };
17
-
18
- /**
19
- * Build a WHERE clause from a Prisma-style where input.
20
- * Returns { sql, values } where sql uses $N placeholders.
21
- */
22
- export function buildWhereClause(params: {
23
- where: Record<string, unknown> | undefined;
24
- modelMeta: { readonly name?: string; readonly dbName: string; readonly scalarFields: readonly ScalarFieldMeta[]; readonly relationFields?: readonly RelationFieldMeta[]; readonly primaryKey?: readonly string[] };
25
- allModelsMeta: ModelMetaMap;
26
- paramOffset?: number;
27
- /** @internal Used for recursive calls to maintain deterministic alias numbering. */
28
- _ctx?: WhereContext;
29
- }): WhereClause {
30
- const { where, modelMeta, allModelsMeta, paramOffset = 0 } = params;
31
- // Create a fresh context for top-level calls; reuse existing context for recursive calls.
32
- // This ensures identical WHERE structures always produce the same SQL text.
33
- const ctx = params._ctx ?? { subqueryAliasCounter: 0 };
34
-
35
- if (!where || Object.keys(where).length === 0) {
36
- return { sql: "", values: [] };
37
- }
38
-
39
- const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
40
- const conditions: string[] = [];
41
- const values: unknown[] = [];
42
- let paramIdx = paramOffset;
43
-
44
- for (const [key, value] of Object.entries(where)) {
45
- if (value === undefined) continue;
46
-
47
- // Logical combinators
48
- if (key === "AND") {
49
- const items = Array.isArray(value) ? value : [value];
50
- const subClauses = items.map((item: Record<string, unknown>) => {
51
- const sub = buildWhereClause({
52
- where: item,
53
- modelMeta,
54
- allModelsMeta,
55
- paramOffset: paramIdx,
56
- _ctx: ctx,
57
- });
58
- paramIdx += sub.values.length;
59
- values.push(...sub.values);
60
- return sub.sql;
61
- });
62
- const filtered = subClauses.filter(Boolean);
63
- if (filtered.length > 0) {
64
- conditions.push(`(${filtered.join(" AND ")})`);
65
- }
66
- continue;
67
- }
68
-
69
- if (key === "OR") {
70
- const items = value as Record<string, unknown>[];
71
- const subClauses = items.map((item) => {
72
- const sub = buildWhereClause({
73
- where: item,
74
- modelMeta,
75
- allModelsMeta,
76
- paramOffset: paramIdx,
77
- _ctx: ctx,
78
- });
79
- paramIdx += sub.values.length;
80
- values.push(...sub.values);
81
- return sub.sql;
82
- });
83
- const filtered = subClauses.filter(Boolean);
84
- if (filtered.length > 0) {
85
- conditions.push(`(${filtered.join(" OR ")})`);
86
- }
87
- continue;
88
- }
89
-
90
- if (key === "NOT") {
91
- const items = Array.isArray(value) ? value : [value];
92
- const subClauses = items.map((item: Record<string, unknown>) => {
93
- const sub = buildWhereClause({
94
- where: item,
95
- modelMeta,
96
- allModelsMeta,
97
- paramOffset: paramIdx,
98
- _ctx: ctx,
99
- });
100
- paramIdx += sub.values.length;
101
- values.push(...sub.values);
102
- return sub.sql;
103
- });
104
- const filtered = subClauses.filter(Boolean);
105
- if (filtered.length > 0) {
106
- // Each element in the NOT array is individually negated:
107
- // NOT: [A, B] → NOT(A) AND NOT(B) (Prisma semantics)
108
- const negated = filtered.map((clause) => `NOT (${clause})`);
109
- conditions.push(negated.join(" AND "));
110
- }
111
- continue;
112
- }
113
-
114
- // Find if this is a scalar field
115
- const scalarField = sfMap.get(key);
116
- if (scalarField) {
117
- const col = `"${modelMeta.dbName}"."${scalarField.dbName}"`;
118
- const result = buildScalarFilter({
119
- col,
120
- value,
121
- paramIdx,
122
- });
123
- conditions.push(result.sql);
124
- values.push(...result.values);
125
- paramIdx += result.values.length;
126
- continue;
127
- }
128
-
129
- // Check if it's a relation filter (some/every/none/is/isNot)
130
- const relationFields = modelMeta.relationFields ?? [];
131
- const relationField = relationFields.find((r) => r.name === key);
132
- if (relationField && typeof value === "object" && value !== null) {
133
- const result = buildRelationFilter({
134
- parentModelMeta: modelMeta as ModelMeta,
135
- relationField,
136
- filter: value as Record<string, unknown>,
137
- allModelsMeta,
138
- paramIdx,
139
- ctx,
140
- });
141
- if (result.sql) {
142
- conditions.push(result.sql);
143
- values.push(...result.values);
144
- paramIdx += result.values.length;
145
- }
146
- continue;
147
- }
148
-
149
- const modelLabel = modelMeta.name ?? modelMeta.dbName;
150
- throw new Error(`Unknown filter field "${key}" for model "${modelLabel}"`);
151
- }
152
-
153
- const sql = conditions.length > 0 ? conditions.join(" AND ") : "";
154
- return { sql, values };
155
- }
156
-
157
- // ─── Relation Filter Compiler ─────────────────────────────────────
158
-
159
- /**
160
- * Compile a relation filter into EXISTS / NOT EXISTS subqueries.
161
- *
162
- * To-many (isList): { some: {...}, none: {...}, every: {...} }
163
- * some → EXISTS (SELECT 1 FROM related WHERE fk = pk AND <nested>)
164
- * none → NOT EXISTS (SELECT 1 FROM related WHERE fk = pk AND <nested>)
165
- * every → NOT EXISTS (SELECT 1 FROM related WHERE fk = pk AND NOT (<nested>))
166
- *
167
- * To-one (!isList): { is: {...} | null, isNot: {...} | null }
168
- * is: null → parent.fk IS NULL (FK side) or NOT EXISTS (related side)
169
- * is: {...} → EXISTS (SELECT 1 FROM related WHERE join AND <nested>)
170
- * isNot: null → parent.fk IS NOT NULL (FK side) or EXISTS (related side)
171
- * isNot: {...}→ NOT EXISTS (SELECT 1 FROM related WHERE join AND <nested>)
172
- *
173
- * Also supports shorthand: { relationName: { field: value } } → same as { some: ... } for lists
174
- */
175
- function buildRelationFilter(params: {
176
- parentModelMeta: ModelMeta;
177
- relationField: RelationFieldMeta;
178
- filter: Record<string, unknown>;
179
- allModelsMeta: ModelMetaMap;
180
- paramIdx: number;
181
- ctx: WhereContext;
182
- }): WhereClause {
183
- const { parentModelMeta, relationField, filter, allModelsMeta, paramIdx, ctx } = params;
184
-
185
- // Resolve related model metadata
186
- const modelMap = getModelByNameMap({ allModelsMeta });
187
- const relatedModelMeta = modelMap.get(relationField.relatedModel);
188
- if (!relatedModelMeta) return { sql: "", values: [] };
189
-
190
- const parentTable = `"${parentModelMeta.dbName}"`;
191
- // Self-referential relations need a unique alias to avoid ambiguity
192
- const isSelfRef = parentModelMeta.dbName === relatedModelMeta.dbName;
193
- const relatedTable = isSelfRef
194
- ? `"__sr_${relatedModelMeta.dbName}_${ctx.subqueryAliasCounter++}"`
195
- : `"${relatedModelMeta.dbName}"`;
196
- // For self-referential subqueries, we need FROM "Table" AS "alias"
197
- const relatedFrom = isSelfRef
198
- ? `"${relatedModelMeta.dbName}" ${relatedTable}`
199
- : relatedTable;
200
-
201
- // Resolve the join condition between parent and related
202
- const joinCondition = resolveJoinCondition({
203
- parentModelMeta,
204
- relationField,
205
- relatedModelMeta,
206
- parentAlias: parentTable,
207
- relatedAlias: relatedTable,
208
- });
209
- if (!joinCondition) return { sql: "", values: [] };
210
-
211
- if (relationField.isList) {
212
- // To-many: some / none / every / shorthand
213
- return buildToManyRelationFilter({
214
- filter,
215
- relationName: relationField.name,
216
- relatedModelMeta,
217
- relatedTable,
218
- relatedFrom,
219
- joinCondition,
220
- allModelsMeta,
221
- paramIdx,
222
- ctx,
223
- });
224
- } else {
225
- // To-one: is / isNot / shorthand (treated as "is")
226
- return buildToOneRelationFilter({
227
- filter,
228
- relationName: relationField.name,
229
- parentModelMeta,
230
- relationField,
231
- relatedModelMeta,
232
- parentTable,
233
- relatedTable,
234
- relatedFrom,
235
- joinCondition,
236
- allModelsMeta,
237
- paramIdx,
238
- ctx,
239
- });
240
- }
241
- }
242
-
243
- function buildToManyRelationFilter(params: {
244
- filter: Record<string, unknown>;
245
- relationName: string;
246
- relatedModelMeta: ModelMeta;
247
- relatedTable: string;
248
- relatedFrom: string;
249
- joinCondition: string;
250
- allModelsMeta: ModelMetaMap;
251
- paramIdx: number;
252
- ctx: WhereContext;
253
- }): WhereClause {
254
- const { filter, relationName, relatedModelMeta, relatedTable, relatedFrom, joinCondition, allModelsMeta, paramIdx, ctx } = params;
255
-
256
- // For self-referential relations, create a modelMeta copy with the alias as dbName
257
- // so nested buildWhereClause generates column refs using the alias
258
- const nestedModelMeta = relatedFrom !== relatedTable
259
- ? { ...relatedModelMeta, dbName: relatedTable.replace(/^"|"$/g, "") }
260
- : relatedModelMeta;
261
-
262
- const conditions: string[] = [];
263
- const values: unknown[] = [];
264
- let idx = paramIdx;
265
-
266
- for (const [op, nestedWhere] of Object.entries(filter)) {
267
- if (nestedWhere === undefined) continue;
268
-
269
- if (op === "some" || op === "every" || op === "none") {
270
- const nested = buildWhereClause({
271
- where: nestedWhere as Record<string, unknown>,
272
- modelMeta: nestedModelMeta,
273
- allModelsMeta,
274
- paramOffset: idx,
275
- _ctx: ctx,
276
- });
277
- idx += nested.values.length;
278
- values.push(...nested.values);
279
-
280
- const nestedCondition = nested.sql ? ` AND ${nested.sql}` : "";
281
-
282
- if (op === "some") {
283
- conditions.push(
284
- `EXISTS (SELECT 1 FROM ${relatedFrom} WHERE ${joinCondition}${nestedCondition})`
285
- );
286
- } else if (op === "none") {
287
- conditions.push(
288
- `NOT EXISTS (SELECT 1 FROM ${relatedFrom} WHERE ${joinCondition}${nestedCondition})`
289
- );
290
- } else {
291
- // every: NOT EXISTS (... WHERE join AND NOT (nested))
292
- // If no nested filter, every is trivially true (vacuous truth)
293
- if (nested.sql) {
294
- conditions.push(
295
- `NOT EXISTS (SELECT 1 FROM ${relatedFrom} WHERE ${joinCondition} AND NOT (${nested.sql}))`
296
- );
297
- }
298
- }
299
- } else {
300
- // Shorthand: treat { field: value } as { some: { field: value } }
301
- // Only process if this looks like a scalar field on the related model
302
- const isRelatedScalar = relatedModelMeta.scalarFields.some((f) => f.name === op);
303
- if (isRelatedScalar) {
304
- // Re-process the whole filter as { some: filter }
305
- const nested = buildWhereClause({
306
- where: filter,
307
- modelMeta: nestedModelMeta,
308
- allModelsMeta,
309
- paramOffset: idx,
310
- _ctx: ctx,
311
- });
312
- idx += nested.values.length;
313
- values.push(...nested.values);
314
- const nestedCondition = nested.sql ? ` AND ${nested.sql}` : "";
315
- conditions.push(
316
- `EXISTS (SELECT 1 FROM ${relatedFrom} WHERE ${joinCondition}${nestedCondition})`
317
- );
318
- break; // We consumed the entire filter object
319
- }
320
-
321
- throw new Error(
322
- `Unsupported relation filter key "${op}" on list relation "${relationName}"`
323
- );
324
- }
325
- }
326
-
327
- return {
328
- sql: conditions.length > 0 ? conditions.join(" AND ") : "",
329
- values,
330
- };
331
- }
332
-
333
- function buildToOneRelationFilter(params: {
334
- filter: Record<string, unknown>;
335
- relationName: string;
336
- parentModelMeta: ModelMeta;
337
- relationField: RelationFieldMeta;
338
- relatedModelMeta: ModelMeta;
339
- parentTable: string;
340
- relatedTable: string;
341
- relatedFrom: string;
342
- joinCondition: string;
343
- allModelsMeta: ModelMetaMap;
344
- paramIdx: number;
345
- ctx: WhereContext;
346
- }): WhereClause {
347
- const {
348
- filter, relationName, parentModelMeta, relationField, relatedModelMeta,
349
- parentTable, relatedTable, relatedFrom, joinCondition, allModelsMeta, paramIdx, ctx,
350
- } = params;
351
-
352
- const parentSfMap = getScalarFieldMap({ scalarFields: parentModelMeta.scalarFields });
353
- // For self-referential relations, create a modelMeta copy with the alias as dbName
354
- const nestedModelMeta = relatedFrom !== relatedTable
355
- ? { ...relatedModelMeta, dbName: relatedTable.replace(/^"|"$/g, "") }
356
- : relatedModelMeta;
357
-
358
- const conditions: string[] = [];
359
- const values: unknown[] = [];
360
- let idx = paramIdx;
361
-
362
- for (const [op, nestedWhere] of Object.entries(filter)) {
363
- if (nestedWhere === undefined) continue;
364
-
365
- if (op === "is") {
366
- if (nestedWhere === null) {
367
- // is: null → FK IS NULL or NOT EXISTS
368
- if (relationField.isForeignKey && relationField.fields.length > 0) {
369
- const fkField = relationField.fields[0]!;
370
- const fkScalar = parentSfMap.get(fkField);
371
- const fkDbName = fkScalar?.dbName ?? fkField;
372
- conditions.push(`${parentTable}."${fkDbName}" IS NULL`);
373
- } else {
374
- conditions.push(
375
- `NOT EXISTS (SELECT 1 FROM ${relatedFrom} WHERE ${joinCondition})`
376
- );
377
- }
378
- } else {
379
- const nested = buildWhereClause({
380
- where: nestedWhere as Record<string, unknown>,
381
- modelMeta: nestedModelMeta,
382
- allModelsMeta,
383
- paramOffset: idx,
384
- _ctx: ctx,
385
- });
386
- idx += nested.values.length;
387
- values.push(...nested.values);
388
- const nestedCondition = nested.sql ? ` AND ${nested.sql}` : "";
389
- conditions.push(
390
- `EXISTS (SELECT 1 FROM ${relatedFrom} WHERE ${joinCondition}${nestedCondition})`
391
- );
392
- }
393
- } else if (op === "isNot") {
394
- if (nestedWhere === null) {
395
- // isNot: null → FK IS NOT NULL or EXISTS
396
- if (relationField.isForeignKey && relationField.fields.length > 0) {
397
- const fkField = relationField.fields[0]!;
398
- const fkScalar = parentSfMap.get(fkField);
399
- const fkDbName = fkScalar?.dbName ?? fkField;
400
- conditions.push(`${parentTable}."${fkDbName}" IS NOT NULL`);
401
- } else {
402
- conditions.push(
403
- `EXISTS (SELECT 1 FROM ${relatedFrom} WHERE ${joinCondition})`
404
- );
405
- }
406
- } else {
407
- const nested = buildWhereClause({
408
- where: nestedWhere as Record<string, unknown>,
409
- modelMeta: nestedModelMeta,
410
- allModelsMeta,
411
- paramOffset: idx,
412
- _ctx: ctx,
413
- });
414
- idx += nested.values.length;
415
- values.push(...nested.values);
416
- const nestedCondition = nested.sql ? ` AND ${nested.sql}` : "";
417
- conditions.push(
418
- `NOT EXISTS (SELECT 1 FROM ${relatedFrom} WHERE ${joinCondition}${nestedCondition})`
419
- );
420
- }
421
- } else {
422
- // Shorthand: treat as { is: filter }
423
- const isRelatedScalar = relatedModelMeta.scalarFields.some((f) => f.name === op);
424
- if (isRelatedScalar) {
425
- const nested = buildWhereClause({
426
- where: filter,
427
- modelMeta: nestedModelMeta,
428
- allModelsMeta,
429
- paramOffset: idx,
430
- _ctx: ctx,
431
- });
432
- idx += nested.values.length;
433
- values.push(...nested.values);
434
- const nestedCondition = nested.sql ? ` AND ${nested.sql}` : "";
435
- conditions.push(
436
- `EXISTS (SELECT 1 FROM ${relatedFrom} WHERE ${joinCondition}${nestedCondition})`
437
- );
438
- break; // Consumed the whole filter
439
- }
440
-
441
- throw new Error(
442
- `Unsupported relation filter key "${op}" on relation "${relationName}"`
443
- );
444
- }
445
- }
446
-
447
- return {
448
- sql: conditions.length > 0 ? conditions.join(" AND ") : "",
449
- values,
450
- };
451
- }
452
-
453
- /**
454
- * Resolve the SQL join condition between parent and related model.
455
- * Returns a string like: "related"."fkCol" = "parent"."pkCol"
456
- */
457
- function resolveJoinCondition(params: {
458
- parentModelMeta: ModelMeta;
459
- relationField: RelationFieldMeta;
460
- relatedModelMeta: ModelMeta;
461
- parentAlias: string;
462
- relatedAlias: string;
463
- }): string | null {
464
- const { parentModelMeta, relationField, relatedModelMeta, parentAlias, relatedAlias } = params;
465
- const parentSfMap = getScalarFieldMap({ scalarFields: parentModelMeta.scalarFields });
466
- const relatedSfMap = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
467
-
468
- if (relationField.isForeignKey && relationField.fields.length > 0) {
469
- // Parent holds the FK (e.g., Post.authorId → User.id)
470
- // Join: related.pk = parent.fk
471
- const fkField = relationField.fields[0]!;
472
- const refField = relationField.references[0]!;
473
- const fkScalar = parentSfMap.get(fkField);
474
- const refScalar = relatedSfMap.get(refField);
475
- const fkDbName = fkScalar?.dbName ?? fkField;
476
- const refDbName = refScalar?.dbName ?? refField;
477
- return `${relatedAlias}."${refDbName}" = ${parentAlias}."${fkDbName}"`;
478
- }
479
-
480
- // Related model holds the FK — find the reverse relation
481
- // Use relationName for disambiguation (critical for self-referential models)
482
- const parentRelationName = (relationField as { relationName?: string }).relationName;
483
- const reverseRelation = relatedModelMeta.relationFields.find(
484
- (r) => r.relatedModel === parentModelMeta.name && r.isForeignKey && r.fields.length > 0 &&
485
- (!parentRelationName || (r as { relationName?: string }).relationName === parentRelationName)
486
- );
487
-
488
- if (reverseRelation) {
489
- const fkField = reverseRelation.fields[0]!;
490
- const refField = reverseRelation.references[0]!;
491
- const fkScalar = relatedSfMap.get(fkField);
492
- const refScalar = parentSfMap.get(refField);
493
- const fkDbName = fkScalar?.dbName ?? fkField;
494
- const refDbName = refScalar?.dbName ?? refField;
495
- return `${relatedAlias}."${fkDbName}" = ${parentAlias}."${refDbName}"`;
496
- }
497
-
498
- // Fallback convention
499
- const pkField = parentModelMeta.primaryKey?.[0] ?? "id";
500
- const pkScalar = parentSfMap.get(pkField);
501
- const pkDbName = pkScalar?.dbName ?? pkField;
502
- const fkGuess = `${parentModelMeta.name!.charAt(0).toLowerCase()}${parentModelMeta.name!.slice(1)}Id`;
503
- return `${relatedAlias}."${fkGuess}" = ${parentAlias}."${pkDbName}"`;
504
- }
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
-
520
- // ─── Scalar Filter Compiler ───────────────────────────────────────
521
-
522
- function buildScalarFilter(params: {
523
- col: string;
524
- value: unknown;
525
- paramIdx: number;
526
- }): WhereClause {
527
- const { col, value, paramIdx } = params;
528
- let idx = paramIdx;
529
-
530
- // Direct value comparison (shorthand): where: { email: "test@example.com" }
531
- if (
532
- value === null ||
533
- typeof value === "string" ||
534
- typeof value === "number" ||
535
- typeof value === "boolean" ||
536
- typeof value === "bigint" ||
537
- value instanceof Date
538
- ) {
539
- if (value === null) {
540
- return { sql: `${col} IS NULL`, values: [] };
541
- }
542
- idx++;
543
- return { sql: `${col} = $${idx}`, values: [value] };
544
- }
545
-
546
- // Filter object: where: { email: { contains: "test" } }
547
- if (typeof value === "object" && value !== null && !Array.isArray(value)) {
548
- const filter = value as Record<string, unknown>;
549
- const conditions: string[] = [];
550
- const values: unknown[] = [];
551
-
552
- for (const [op, operand] of Object.entries(filter)) {
553
- if (operand === undefined) continue;
554
-
555
- switch (op) {
556
- case "equals": {
557
- if (operand === null) {
558
- conditions.push(`${col} IS NULL`);
559
- } else if (filter.mode === "insensitive") {
560
- idx++;
561
- conditions.push(`LOWER(${col}) = LOWER($${idx})`);
562
- values.push(operand);
563
- } else {
564
- idx++;
565
- conditions.push(`${col} = $${idx}`);
566
- values.push(operand);
567
- }
568
- break;
569
- }
570
- case "not": {
571
- if (operand === null) {
572
- conditions.push(`${col} IS NOT NULL`);
573
- } else if (typeof operand === "object" && operand !== null) {
574
- // Nested filter
575
- const sub = buildScalarFilter({ col, value: operand, paramIdx: idx });
576
- conditions.push(`NOT (${sub.sql})`);
577
- values.push(...sub.values);
578
- idx += sub.values.length;
579
- } else {
580
- idx++;
581
- conditions.push(`${col} != $${idx}`);
582
- values.push(operand);
583
- }
584
- break;
585
- }
586
- case "in": {
587
- const arr = operand as unknown[];
588
- if (arr.length === 0) {
589
- conditions.push("FALSE");
590
- } else {
591
- idx++;
592
- conditions.push(`${col} = ANY($${idx})`);
593
- values.push(new PgArray(arr));
594
- }
595
- break;
596
- }
597
- case "notIn": {
598
- const arr = operand as unknown[];
599
- if (arr.length === 0) {
600
- // NOT IN empty array = always true, skip
601
- } else {
602
- idx++;
603
- conditions.push(`${col} <> ALL($${idx})`);
604
- values.push(new PgArray(arr));
605
- }
606
- break;
607
- }
608
- case "lt": {
609
- idx++;
610
- conditions.push(`${col} < $${idx}`);
611
- values.push(operand);
612
- break;
613
- }
614
- case "lte": {
615
- idx++;
616
- conditions.push(`${col} <= $${idx}`);
617
- values.push(operand);
618
- break;
619
- }
620
- case "gt": {
621
- idx++;
622
- conditions.push(`${col} > $${idx}`);
623
- values.push(operand);
624
- break;
625
- }
626
- case "gte": {
627
- idx++;
628
- conditions.push(`${col} >= $${idx}`);
629
- values.push(operand);
630
- break;
631
- }
632
- case "contains": {
633
- idx++;
634
- const mode = filter.mode === "insensitive" ? "ILIKE" : "LIKE";
635
- const escaped = escapeLikePattern({ value: operand as string });
636
- conditions.push(`${col} ${mode} $${idx} ESCAPE '\\'`);
637
- values.push(`%${escaped}%`);
638
- break;
639
- }
640
- case "startsWith": {
641
- idx++;
642
- const mode = filter.mode === "insensitive" ? "ILIKE" : "LIKE";
643
- const escaped = escapeLikePattern({ value: operand as string });
644
- conditions.push(`${col} ${mode} $${idx} ESCAPE '\\'`);
645
- values.push(`${escaped}%`);
646
- break;
647
- }
648
- case "endsWith": {
649
- idx++;
650
- const mode = filter.mode === "insensitive" ? "ILIKE" : "LIKE";
651
- const escaped = escapeLikePattern({ value: operand as string });
652
- conditions.push(`${col} ${mode} $${idx} ESCAPE '\\'`);
653
- values.push(`%${escaped}`);
654
- break;
655
- }
656
- case "mode":
657
- // Handled by contains/startsWith/endsWith
658
- break;
659
-
660
- // ─── JSON Filter Operators (PostgreSQL JSONB) ──────────
661
- case "path": {
662
- // path is consumed by other JSON operators, not standalone
663
- break;
664
- }
665
- case "string_contains": {
666
- const path = filter.path as string[] | undefined;
667
- const jsonCol = path && path.length > 0
668
- ? `(${col}${path.map((p) => `->>` + `'${p.replace(/'/g, "''")}'`).join("")})`
669
- : `(${col}#>>'{}')`;
670
- idx++;
671
- const jmode = filter.mode === "insensitive" ? "ILIKE" : "LIKE";
672
- const escaped = escapeLikePattern({ value: operand as string });
673
- conditions.push(`${jsonCol} ${jmode} $${idx} ESCAPE '\\'`);
674
- values.push(`%${escaped}%`);
675
- break;
676
- }
677
- case "string_starts_with": {
678
- const path = filter.path as string[] | undefined;
679
- const jsonCol = path && path.length > 0
680
- ? `(${col}${path.map((p) => `->>` + `'${p.replace(/'/g, "''")}'`).join("")})`
681
- : `(${col}#>>'{}')`;
682
- idx++;
683
- const jmode = filter.mode === "insensitive" ? "ILIKE" : "LIKE";
684
- const escaped = escapeLikePattern({ value: operand as string });
685
- conditions.push(`${jsonCol} ${jmode} $${idx} ESCAPE '\\'`);
686
- values.push(`${escaped}%`);
687
- break;
688
- }
689
- case "string_ends_with": {
690
- const path = filter.path as string[] | undefined;
691
- const jsonCol = path && path.length > 0
692
- ? `(${col}${path.map((p) => `->>` + `'${p.replace(/'/g, "''")}'`).join("")})`
693
- : `(${col}#>>'{}')`;
694
- idx++;
695
- const jmode = filter.mode === "insensitive" ? "ILIKE" : "LIKE";
696
- const escaped = escapeLikePattern({ value: operand as string });
697
- conditions.push(`${jsonCol} ${jmode} $${idx} ESCAPE '\\'`);
698
- values.push(`%${escaped}`);
699
- break;
700
- }
701
- case "array_contains": {
702
- const path = filter.path as string[] | undefined;
703
- const target = path && path.length > 0
704
- ? `${col}${path.map((p) => `->'${p.replace(/'/g, "''")}'`).join("")}`
705
- : col;
706
- idx++;
707
- conditions.push(`${target} @> $${idx}::jsonb`);
708
- values.push(JSON.stringify(operand));
709
- break;
710
- }
711
- case "array_starts_with": {
712
- const path = filter.path as string[] | undefined;
713
- const target = path && path.length > 0
714
- ? `${col}${path.map((p) => `->'${p.replace(/'/g, "''")}'`).join("")}`
715
- : col;
716
- idx++;
717
- conditions.push(`${target}->0 = $${idx}::jsonb`);
718
- values.push(JSON.stringify(operand));
719
- break;
720
- }
721
- case "array_ends_with": {
722
- const path = filter.path as string[] | undefined;
723
- const target = path && path.length > 0
724
- ? `${col}${path.map((p) => `->'${p.replace(/'/g, "''")}'`).join("")}`
725
- : col;
726
- idx++;
727
- conditions.push(`${target}->-1 = $${idx}::jsonb`);
728
- values.push(JSON.stringify(operand));
729
- break;
730
- }
731
-
732
- // ─── Scalar List (Array) Filter Operators ──────────────
733
- case "has": {
734
- idx++;
735
- conditions.push(`$${idx} = ANY(${col})`);
736
- values.push(operand);
737
- break;
738
- }
739
- case "hasEvery": {
740
- idx++;
741
- conditions.push(`${col} @> $${idx}`);
742
- values.push(Array.isArray(operand) ? new PgArray(operand) : operand);
743
- break;
744
- }
745
- case "hasSome": {
746
- idx++;
747
- conditions.push(`${col} && $${idx}`);
748
- values.push(Array.isArray(operand) ? new PgArray(operand) : operand);
749
- break;
750
- }
751
- case "isEmpty": {
752
- if (operand === true) {
753
- conditions.push(`(array_length(${col}, 1) IS NULL)`);
754
- } else {
755
- conditions.push(`(array_length(${col}, 1) IS NOT NULL)`);
756
- }
757
- break;
758
- }
759
-
760
- default:
761
- throw new Error(`Unsupported scalar filter operator "${op}" for ${col}`);
762
- }
763
- }
764
-
765
- return {
766
- sql: conditions.length > 0 ? conditions.join(" AND ") : "TRUE",
767
- values,
768
- };
769
- }
770
-
771
- return { sql: "", values: [] };
772
- }