@vibeorm/runtime 1.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.
- package/README.md +74 -0
- package/package.json +47 -0
- package/src/adapter.ts +114 -0
- package/src/client.ts +2055 -0
- package/src/errors.ts +39 -0
- package/src/id-generators.ts +151 -0
- package/src/index.ts +36 -0
- package/src/lateral-join-builder.ts +759 -0
- package/src/query-builder.ts +1417 -0
- package/src/relation-loader.ts +489 -0
- package/src/types.ts +290 -0
- package/src/where-builder.ts +737 -0
|
@@ -0,0 +1,737 @@
|
|
|
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
|
+
|
|
150
|
+
const sql = conditions.length > 0 ? conditions.join(" AND ") : "";
|
|
151
|
+
return { sql, values };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ─── Relation Filter Compiler ─────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Compile a relation filter into EXISTS / NOT EXISTS subqueries.
|
|
158
|
+
*
|
|
159
|
+
* To-many (isList): { some: {...}, none: {...}, every: {...} }
|
|
160
|
+
* some → EXISTS (SELECT 1 FROM related WHERE fk = pk AND <nested>)
|
|
161
|
+
* none → NOT EXISTS (SELECT 1 FROM related WHERE fk = pk AND <nested>)
|
|
162
|
+
* every → NOT EXISTS (SELECT 1 FROM related WHERE fk = pk AND NOT (<nested>))
|
|
163
|
+
*
|
|
164
|
+
* To-one (!isList): { is: {...} | null, isNot: {...} | null }
|
|
165
|
+
* is: null → parent.fk IS NULL (FK side) or NOT EXISTS (related side)
|
|
166
|
+
* is: {...} → EXISTS (SELECT 1 FROM related WHERE join AND <nested>)
|
|
167
|
+
* isNot: null → parent.fk IS NOT NULL (FK side) or EXISTS (related side)
|
|
168
|
+
* isNot: {...}→ NOT EXISTS (SELECT 1 FROM related WHERE join AND <nested>)
|
|
169
|
+
*
|
|
170
|
+
* Also supports shorthand: { relationName: { field: value } } → same as { some: ... } for lists
|
|
171
|
+
*/
|
|
172
|
+
function buildRelationFilter(params: {
|
|
173
|
+
parentModelMeta: ModelMeta;
|
|
174
|
+
relationField: RelationFieldMeta;
|
|
175
|
+
filter: Record<string, unknown>;
|
|
176
|
+
allModelsMeta: ModelMetaMap;
|
|
177
|
+
paramIdx: number;
|
|
178
|
+
ctx: WhereContext;
|
|
179
|
+
}): WhereClause {
|
|
180
|
+
const { parentModelMeta, relationField, filter, allModelsMeta, paramIdx, ctx } = params;
|
|
181
|
+
|
|
182
|
+
// Resolve related model metadata
|
|
183
|
+
const modelMap = getModelByNameMap({ allModelsMeta });
|
|
184
|
+
const relatedModelMeta = modelMap.get(relationField.relatedModel);
|
|
185
|
+
if (!relatedModelMeta) return { sql: "", values: [] };
|
|
186
|
+
|
|
187
|
+
const parentTable = `"${parentModelMeta.dbName}"`;
|
|
188
|
+
// Self-referential relations need a unique alias to avoid ambiguity
|
|
189
|
+
const isSelfRef = parentModelMeta.dbName === relatedModelMeta.dbName;
|
|
190
|
+
const relatedTable = isSelfRef
|
|
191
|
+
? `"__sr_${relatedModelMeta.dbName}_${ctx.subqueryAliasCounter++}"`
|
|
192
|
+
: `"${relatedModelMeta.dbName}"`;
|
|
193
|
+
// For self-referential subqueries, we need FROM "Table" AS "alias"
|
|
194
|
+
const relatedFrom = isSelfRef
|
|
195
|
+
? `"${relatedModelMeta.dbName}" ${relatedTable}`
|
|
196
|
+
: relatedTable;
|
|
197
|
+
|
|
198
|
+
// Resolve the join condition between parent and related
|
|
199
|
+
const joinCondition = resolveJoinCondition({
|
|
200
|
+
parentModelMeta,
|
|
201
|
+
relationField,
|
|
202
|
+
relatedModelMeta,
|
|
203
|
+
parentAlias: parentTable,
|
|
204
|
+
relatedAlias: relatedTable,
|
|
205
|
+
});
|
|
206
|
+
if (!joinCondition) return { sql: "", values: [] };
|
|
207
|
+
|
|
208
|
+
if (relationField.isList) {
|
|
209
|
+
// To-many: some / none / every / shorthand
|
|
210
|
+
return buildToManyRelationFilter({
|
|
211
|
+
filter,
|
|
212
|
+
relatedModelMeta,
|
|
213
|
+
relatedTable,
|
|
214
|
+
relatedFrom,
|
|
215
|
+
joinCondition,
|
|
216
|
+
allModelsMeta,
|
|
217
|
+
paramIdx,
|
|
218
|
+
ctx,
|
|
219
|
+
});
|
|
220
|
+
} else {
|
|
221
|
+
// To-one: is / isNot / shorthand (treated as "is")
|
|
222
|
+
return buildToOneRelationFilter({
|
|
223
|
+
filter,
|
|
224
|
+
parentModelMeta,
|
|
225
|
+
relationField,
|
|
226
|
+
relatedModelMeta,
|
|
227
|
+
parentTable,
|
|
228
|
+
relatedTable,
|
|
229
|
+
relatedFrom,
|
|
230
|
+
joinCondition,
|
|
231
|
+
allModelsMeta,
|
|
232
|
+
paramIdx,
|
|
233
|
+
ctx,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function buildToManyRelationFilter(params: {
|
|
239
|
+
filter: Record<string, unknown>;
|
|
240
|
+
relatedModelMeta: ModelMeta;
|
|
241
|
+
relatedTable: string;
|
|
242
|
+
relatedFrom: string;
|
|
243
|
+
joinCondition: string;
|
|
244
|
+
allModelsMeta: ModelMetaMap;
|
|
245
|
+
paramIdx: number;
|
|
246
|
+
ctx: WhereContext;
|
|
247
|
+
}): WhereClause {
|
|
248
|
+
const { filter, relatedModelMeta, relatedTable, relatedFrom, joinCondition, allModelsMeta, paramIdx, ctx } = params;
|
|
249
|
+
|
|
250
|
+
// For self-referential relations, create a modelMeta copy with the alias as dbName
|
|
251
|
+
// so nested buildWhereClause generates column refs using the alias
|
|
252
|
+
const nestedModelMeta = relatedFrom !== relatedTable
|
|
253
|
+
? { ...relatedModelMeta, dbName: relatedTable.replace(/^"|"$/g, "") }
|
|
254
|
+
: relatedModelMeta;
|
|
255
|
+
|
|
256
|
+
const conditions: string[] = [];
|
|
257
|
+
const values: unknown[] = [];
|
|
258
|
+
let idx = paramIdx;
|
|
259
|
+
|
|
260
|
+
for (const [op, nestedWhere] of Object.entries(filter)) {
|
|
261
|
+
if (nestedWhere === undefined) continue;
|
|
262
|
+
|
|
263
|
+
if (op === "some" || op === "every" || op === "none") {
|
|
264
|
+
const nested = buildWhereClause({
|
|
265
|
+
where: nestedWhere as Record<string, unknown>,
|
|
266
|
+
modelMeta: nestedModelMeta,
|
|
267
|
+
allModelsMeta,
|
|
268
|
+
paramOffset: idx,
|
|
269
|
+
_ctx: ctx,
|
|
270
|
+
});
|
|
271
|
+
idx += nested.values.length;
|
|
272
|
+
values.push(...nested.values);
|
|
273
|
+
|
|
274
|
+
const nestedCondition = nested.sql ? ` AND ${nested.sql}` : "";
|
|
275
|
+
|
|
276
|
+
if (op === "some") {
|
|
277
|
+
conditions.push(
|
|
278
|
+
`EXISTS (SELECT 1 FROM ${relatedFrom} WHERE ${joinCondition}${nestedCondition})`
|
|
279
|
+
);
|
|
280
|
+
} else if (op === "none") {
|
|
281
|
+
conditions.push(
|
|
282
|
+
`NOT EXISTS (SELECT 1 FROM ${relatedFrom} WHERE ${joinCondition}${nestedCondition})`
|
|
283
|
+
);
|
|
284
|
+
} else {
|
|
285
|
+
// every: NOT EXISTS (... WHERE join AND NOT (nested))
|
|
286
|
+
// If no nested filter, every is trivially true (vacuous truth)
|
|
287
|
+
if (nested.sql) {
|
|
288
|
+
conditions.push(
|
|
289
|
+
`NOT EXISTS (SELECT 1 FROM ${relatedFrom} WHERE ${joinCondition} AND NOT (${nested.sql}))`
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
} else {
|
|
294
|
+
// Shorthand: treat { field: value } as { some: { field: value } }
|
|
295
|
+
// Only process if this looks like a scalar field on the related model
|
|
296
|
+
const isRelatedScalar = relatedModelMeta.scalarFields.some((f) => f.name === op);
|
|
297
|
+
if (isRelatedScalar) {
|
|
298
|
+
// Re-process the whole filter as { some: filter }
|
|
299
|
+
const nested = buildWhereClause({
|
|
300
|
+
where: filter,
|
|
301
|
+
modelMeta: nestedModelMeta,
|
|
302
|
+
allModelsMeta,
|
|
303
|
+
paramOffset: idx,
|
|
304
|
+
_ctx: ctx,
|
|
305
|
+
});
|
|
306
|
+
idx += nested.values.length;
|
|
307
|
+
values.push(...nested.values);
|
|
308
|
+
const nestedCondition = nested.sql ? ` AND ${nested.sql}` : "";
|
|
309
|
+
conditions.push(
|
|
310
|
+
`EXISTS (SELECT 1 FROM ${relatedFrom} WHERE ${joinCondition}${nestedCondition})`
|
|
311
|
+
);
|
|
312
|
+
break; // We consumed the entire filter object
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
sql: conditions.length > 0 ? conditions.join(" AND ") : "",
|
|
319
|
+
values,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function buildToOneRelationFilter(params: {
|
|
324
|
+
filter: Record<string, unknown>;
|
|
325
|
+
parentModelMeta: ModelMeta;
|
|
326
|
+
relationField: RelationFieldMeta;
|
|
327
|
+
relatedModelMeta: ModelMeta;
|
|
328
|
+
parentTable: string;
|
|
329
|
+
relatedTable: string;
|
|
330
|
+
relatedFrom: string;
|
|
331
|
+
joinCondition: string;
|
|
332
|
+
allModelsMeta: ModelMetaMap;
|
|
333
|
+
paramIdx: number;
|
|
334
|
+
ctx: WhereContext;
|
|
335
|
+
}): WhereClause {
|
|
336
|
+
const {
|
|
337
|
+
filter, parentModelMeta, relationField, relatedModelMeta,
|
|
338
|
+
parentTable, relatedTable, relatedFrom, joinCondition, allModelsMeta, paramIdx, ctx,
|
|
339
|
+
} = params;
|
|
340
|
+
|
|
341
|
+
const parentSfMap = getScalarFieldMap({ scalarFields: parentModelMeta.scalarFields });
|
|
342
|
+
// For self-referential relations, create a modelMeta copy with the alias as dbName
|
|
343
|
+
const nestedModelMeta = relatedFrom !== relatedTable
|
|
344
|
+
? { ...relatedModelMeta, dbName: relatedTable.replace(/^"|"$/g, "") }
|
|
345
|
+
: relatedModelMeta;
|
|
346
|
+
|
|
347
|
+
const conditions: string[] = [];
|
|
348
|
+
const values: unknown[] = [];
|
|
349
|
+
let idx = paramIdx;
|
|
350
|
+
|
|
351
|
+
for (const [op, nestedWhere] of Object.entries(filter)) {
|
|
352
|
+
if (nestedWhere === undefined) continue;
|
|
353
|
+
|
|
354
|
+
if (op === "is") {
|
|
355
|
+
if (nestedWhere === null) {
|
|
356
|
+
// is: null → FK IS NULL or NOT EXISTS
|
|
357
|
+
if (relationField.isForeignKey && relationField.fields.length > 0) {
|
|
358
|
+
const fkField = relationField.fields[0]!;
|
|
359
|
+
const fkScalar = parentSfMap.get(fkField);
|
|
360
|
+
const fkDbName = fkScalar?.dbName ?? fkField;
|
|
361
|
+
conditions.push(`${parentTable}."${fkDbName}" IS NULL`);
|
|
362
|
+
} else {
|
|
363
|
+
conditions.push(
|
|
364
|
+
`NOT EXISTS (SELECT 1 FROM ${relatedFrom} WHERE ${joinCondition})`
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
} else {
|
|
368
|
+
const nested = buildWhereClause({
|
|
369
|
+
where: nestedWhere as Record<string, unknown>,
|
|
370
|
+
modelMeta: nestedModelMeta,
|
|
371
|
+
allModelsMeta,
|
|
372
|
+
paramOffset: idx,
|
|
373
|
+
_ctx: ctx,
|
|
374
|
+
});
|
|
375
|
+
idx += nested.values.length;
|
|
376
|
+
values.push(...nested.values);
|
|
377
|
+
const nestedCondition = nested.sql ? ` AND ${nested.sql}` : "";
|
|
378
|
+
conditions.push(
|
|
379
|
+
`EXISTS (SELECT 1 FROM ${relatedFrom} WHERE ${joinCondition}${nestedCondition})`
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
} else if (op === "isNot") {
|
|
383
|
+
if (nestedWhere === null) {
|
|
384
|
+
// isNot: null → FK IS NOT NULL or EXISTS
|
|
385
|
+
if (relationField.isForeignKey && relationField.fields.length > 0) {
|
|
386
|
+
const fkField = relationField.fields[0]!;
|
|
387
|
+
const fkScalar = parentSfMap.get(fkField);
|
|
388
|
+
const fkDbName = fkScalar?.dbName ?? fkField;
|
|
389
|
+
conditions.push(`${parentTable}."${fkDbName}" IS NOT NULL`);
|
|
390
|
+
} else {
|
|
391
|
+
conditions.push(
|
|
392
|
+
`EXISTS (SELECT 1 FROM ${relatedFrom} WHERE ${joinCondition})`
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
} else {
|
|
396
|
+
const nested = buildWhereClause({
|
|
397
|
+
where: nestedWhere as Record<string, unknown>,
|
|
398
|
+
modelMeta: nestedModelMeta,
|
|
399
|
+
allModelsMeta,
|
|
400
|
+
paramOffset: idx,
|
|
401
|
+
_ctx: ctx,
|
|
402
|
+
});
|
|
403
|
+
idx += nested.values.length;
|
|
404
|
+
values.push(...nested.values);
|
|
405
|
+
const nestedCondition = nested.sql ? ` AND ${nested.sql}` : "";
|
|
406
|
+
conditions.push(
|
|
407
|
+
`NOT EXISTS (SELECT 1 FROM ${relatedFrom} WHERE ${joinCondition}${nestedCondition})`
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
} else {
|
|
411
|
+
// Shorthand: treat as { is: filter }
|
|
412
|
+
const isRelatedScalar = relatedModelMeta.scalarFields.some((f) => f.name === op);
|
|
413
|
+
if (isRelatedScalar) {
|
|
414
|
+
const nested = buildWhereClause({
|
|
415
|
+
where: filter,
|
|
416
|
+
modelMeta: nestedModelMeta,
|
|
417
|
+
allModelsMeta,
|
|
418
|
+
paramOffset: idx,
|
|
419
|
+
_ctx: ctx,
|
|
420
|
+
});
|
|
421
|
+
idx += nested.values.length;
|
|
422
|
+
values.push(...nested.values);
|
|
423
|
+
const nestedCondition = nested.sql ? ` AND ${nested.sql}` : "";
|
|
424
|
+
conditions.push(
|
|
425
|
+
`EXISTS (SELECT 1 FROM ${relatedFrom} WHERE ${joinCondition}${nestedCondition})`
|
|
426
|
+
);
|
|
427
|
+
break; // Consumed the whole filter
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
sql: conditions.length > 0 ? conditions.join(" AND ") : "",
|
|
434
|
+
values,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Resolve the SQL join condition between parent and related model.
|
|
440
|
+
* Returns a string like: "related"."fkCol" = "parent"."pkCol"
|
|
441
|
+
*/
|
|
442
|
+
function resolveJoinCondition(params: {
|
|
443
|
+
parentModelMeta: ModelMeta;
|
|
444
|
+
relationField: RelationFieldMeta;
|
|
445
|
+
relatedModelMeta: ModelMeta;
|
|
446
|
+
parentAlias: string;
|
|
447
|
+
relatedAlias: string;
|
|
448
|
+
}): string | null {
|
|
449
|
+
const { parentModelMeta, relationField, relatedModelMeta, parentAlias, relatedAlias } = params;
|
|
450
|
+
const parentSfMap = getScalarFieldMap({ scalarFields: parentModelMeta.scalarFields });
|
|
451
|
+
const relatedSfMap = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
|
|
452
|
+
|
|
453
|
+
if (relationField.isForeignKey && relationField.fields.length > 0) {
|
|
454
|
+
// Parent holds the FK (e.g., Post.authorId → User.id)
|
|
455
|
+
// Join: related.pk = parent.fk
|
|
456
|
+
const fkField = relationField.fields[0]!;
|
|
457
|
+
const refField = relationField.references[0]!;
|
|
458
|
+
const fkScalar = parentSfMap.get(fkField);
|
|
459
|
+
const refScalar = relatedSfMap.get(refField);
|
|
460
|
+
const fkDbName = fkScalar?.dbName ?? fkField;
|
|
461
|
+
const refDbName = refScalar?.dbName ?? refField;
|
|
462
|
+
return `${relatedAlias}."${refDbName}" = ${parentAlias}."${fkDbName}"`;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Related model holds the FK — find the reverse relation
|
|
466
|
+
// Use relationName for disambiguation (critical for self-referential models)
|
|
467
|
+
const parentRelationName = (relationField as { relationName?: string }).relationName;
|
|
468
|
+
const reverseRelation = relatedModelMeta.relationFields.find(
|
|
469
|
+
(r) => r.relatedModel === parentModelMeta.name && r.isForeignKey && r.fields.length > 0 &&
|
|
470
|
+
(!parentRelationName || (r as { relationName?: string }).relationName === parentRelationName)
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
if (reverseRelation) {
|
|
474
|
+
const fkField = reverseRelation.fields[0]!;
|
|
475
|
+
const refField = reverseRelation.references[0]!;
|
|
476
|
+
const fkScalar = relatedSfMap.get(fkField);
|
|
477
|
+
const refScalar = parentSfMap.get(refField);
|
|
478
|
+
const fkDbName = fkScalar?.dbName ?? fkField;
|
|
479
|
+
const refDbName = refScalar?.dbName ?? refField;
|
|
480
|
+
return `${relatedAlias}."${fkDbName}" = ${parentAlias}."${refDbName}"`;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Fallback convention
|
|
484
|
+
const pkField = parentModelMeta.primaryKey?.[0] ?? "id";
|
|
485
|
+
const pkScalar = parentSfMap.get(pkField);
|
|
486
|
+
const pkDbName = pkScalar?.dbName ?? pkField;
|
|
487
|
+
const fkGuess = `${parentModelMeta.name!.charAt(0).toLowerCase()}${parentModelMeta.name!.slice(1)}Id`;
|
|
488
|
+
return `${relatedAlias}."${fkGuess}" = ${parentAlias}."${pkDbName}"`;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ─── Scalar Filter Compiler ───────────────────────────────────────
|
|
492
|
+
|
|
493
|
+
function buildScalarFilter(params: {
|
|
494
|
+
col: string;
|
|
495
|
+
value: unknown;
|
|
496
|
+
paramIdx: number;
|
|
497
|
+
}): WhereClause {
|
|
498
|
+
const { col, value, paramIdx } = params;
|
|
499
|
+
let idx = paramIdx;
|
|
500
|
+
|
|
501
|
+
// Direct value comparison (shorthand): where: { email: "test@example.com" }
|
|
502
|
+
if (
|
|
503
|
+
value === null ||
|
|
504
|
+
typeof value === "string" ||
|
|
505
|
+
typeof value === "number" ||
|
|
506
|
+
typeof value === "boolean" ||
|
|
507
|
+
typeof value === "bigint" ||
|
|
508
|
+
value instanceof Date
|
|
509
|
+
) {
|
|
510
|
+
if (value === null) {
|
|
511
|
+
return { sql: `${col} IS NULL`, values: [] };
|
|
512
|
+
}
|
|
513
|
+
idx++;
|
|
514
|
+
return { sql: `${col} = $${idx}`, values: [value] };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Filter object: where: { email: { contains: "test" } }
|
|
518
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
519
|
+
const filter = value as Record<string, unknown>;
|
|
520
|
+
const conditions: string[] = [];
|
|
521
|
+
const values: unknown[] = [];
|
|
522
|
+
|
|
523
|
+
for (const [op, operand] of Object.entries(filter)) {
|
|
524
|
+
if (operand === undefined) continue;
|
|
525
|
+
|
|
526
|
+
switch (op) {
|
|
527
|
+
case "equals": {
|
|
528
|
+
if (operand === null) {
|
|
529
|
+
conditions.push(`${col} IS NULL`);
|
|
530
|
+
} else if (filter.mode === "insensitive") {
|
|
531
|
+
idx++;
|
|
532
|
+
conditions.push(`LOWER(${col}) = LOWER($${idx})`);
|
|
533
|
+
values.push(operand);
|
|
534
|
+
} else {
|
|
535
|
+
idx++;
|
|
536
|
+
conditions.push(`${col} = $${idx}`);
|
|
537
|
+
values.push(operand);
|
|
538
|
+
}
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
541
|
+
case "not": {
|
|
542
|
+
if (operand === null) {
|
|
543
|
+
conditions.push(`${col} IS NOT NULL`);
|
|
544
|
+
} else if (typeof operand === "object" && operand !== null) {
|
|
545
|
+
// Nested filter
|
|
546
|
+
const sub = buildScalarFilter({ col, value: operand, paramIdx: idx });
|
|
547
|
+
conditions.push(`NOT (${sub.sql})`);
|
|
548
|
+
values.push(...sub.values);
|
|
549
|
+
idx += sub.values.length;
|
|
550
|
+
} else {
|
|
551
|
+
idx++;
|
|
552
|
+
conditions.push(`${col} != $${idx}`);
|
|
553
|
+
values.push(operand);
|
|
554
|
+
}
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
case "in": {
|
|
558
|
+
const arr = operand as unknown[];
|
|
559
|
+
if (arr.length === 0) {
|
|
560
|
+
conditions.push("FALSE");
|
|
561
|
+
} else {
|
|
562
|
+
idx++;
|
|
563
|
+
conditions.push(`${col} = ANY($${idx})`);
|
|
564
|
+
values.push(new PgArray(arr));
|
|
565
|
+
}
|
|
566
|
+
break;
|
|
567
|
+
}
|
|
568
|
+
case "notIn": {
|
|
569
|
+
const arr = operand as unknown[];
|
|
570
|
+
if (arr.length === 0) {
|
|
571
|
+
// NOT IN empty array = always true, skip
|
|
572
|
+
} else {
|
|
573
|
+
idx++;
|
|
574
|
+
conditions.push(`${col} <> ALL($${idx})`);
|
|
575
|
+
values.push(new PgArray(arr));
|
|
576
|
+
}
|
|
577
|
+
break;
|
|
578
|
+
}
|
|
579
|
+
case "lt": {
|
|
580
|
+
idx++;
|
|
581
|
+
conditions.push(`${col} < $${idx}`);
|
|
582
|
+
values.push(operand);
|
|
583
|
+
break;
|
|
584
|
+
}
|
|
585
|
+
case "lte": {
|
|
586
|
+
idx++;
|
|
587
|
+
conditions.push(`${col} <= $${idx}`);
|
|
588
|
+
values.push(operand);
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
case "gt": {
|
|
592
|
+
idx++;
|
|
593
|
+
conditions.push(`${col} > $${idx}`);
|
|
594
|
+
values.push(operand);
|
|
595
|
+
break;
|
|
596
|
+
}
|
|
597
|
+
case "gte": {
|
|
598
|
+
idx++;
|
|
599
|
+
conditions.push(`${col} >= $${idx}`);
|
|
600
|
+
values.push(operand);
|
|
601
|
+
break;
|
|
602
|
+
}
|
|
603
|
+
case "contains": {
|
|
604
|
+
idx++;
|
|
605
|
+
const mode = filter.mode === "insensitive" ? "ILIKE" : "LIKE";
|
|
606
|
+
conditions.push(`${col} ${mode} $${idx}`);
|
|
607
|
+
values.push(`%${operand}%`);
|
|
608
|
+
break;
|
|
609
|
+
}
|
|
610
|
+
case "startsWith": {
|
|
611
|
+
idx++;
|
|
612
|
+
const mode = filter.mode === "insensitive" ? "ILIKE" : "LIKE";
|
|
613
|
+
conditions.push(`${col} ${mode} $${idx}`);
|
|
614
|
+
values.push(`${operand}%`);
|
|
615
|
+
break;
|
|
616
|
+
}
|
|
617
|
+
case "endsWith": {
|
|
618
|
+
idx++;
|
|
619
|
+
const mode = filter.mode === "insensitive" ? "ILIKE" : "LIKE";
|
|
620
|
+
conditions.push(`${col} ${mode} $${idx}`);
|
|
621
|
+
values.push(`%${operand}`);
|
|
622
|
+
break;
|
|
623
|
+
}
|
|
624
|
+
case "mode":
|
|
625
|
+
// Handled by contains/startsWith/endsWith
|
|
626
|
+
break;
|
|
627
|
+
|
|
628
|
+
// ─── JSON Filter Operators (PostgreSQL JSONB) ──────────
|
|
629
|
+
case "path": {
|
|
630
|
+
// path is consumed by other JSON operators, not standalone
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
633
|
+
case "string_contains": {
|
|
634
|
+
const path = filter.path as string[] | undefined;
|
|
635
|
+
const jsonCol = path && path.length > 0
|
|
636
|
+
? `(${col}${path.map((p) => `->>'${p}'`).join("")})`
|
|
637
|
+
: `(${col}#>>'{}')`;
|
|
638
|
+
idx++;
|
|
639
|
+
const jmode = filter.mode === "insensitive" ? "ILIKE" : "LIKE";
|
|
640
|
+
conditions.push(`${jsonCol} ${jmode} $${idx}`);
|
|
641
|
+
values.push(`%${operand}%`);
|
|
642
|
+
break;
|
|
643
|
+
}
|
|
644
|
+
case "string_starts_with": {
|
|
645
|
+
const path = filter.path as string[] | undefined;
|
|
646
|
+
const jsonCol = path && path.length > 0
|
|
647
|
+
? `(${col}${path.map((p) => `->>'${p}'`).join("")})`
|
|
648
|
+
: `(${col}#>>'{}')`;
|
|
649
|
+
idx++;
|
|
650
|
+
const jmode = filter.mode === "insensitive" ? "ILIKE" : "LIKE";
|
|
651
|
+
conditions.push(`${jsonCol} ${jmode} $${idx}`);
|
|
652
|
+
values.push(`${operand}%`);
|
|
653
|
+
break;
|
|
654
|
+
}
|
|
655
|
+
case "string_ends_with": {
|
|
656
|
+
const path = filter.path as string[] | undefined;
|
|
657
|
+
const jsonCol = path && path.length > 0
|
|
658
|
+
? `(${col}${path.map((p) => `->>'${p}'`).join("")})`
|
|
659
|
+
: `(${col}#>>'{}')`;
|
|
660
|
+
idx++;
|
|
661
|
+
const jmode = filter.mode === "insensitive" ? "ILIKE" : "LIKE";
|
|
662
|
+
conditions.push(`${jsonCol} ${jmode} $${idx}`);
|
|
663
|
+
values.push(`%${operand}`);
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
666
|
+
case "array_contains": {
|
|
667
|
+
const path = filter.path as string[] | undefined;
|
|
668
|
+
const target = path && path.length > 0
|
|
669
|
+
? `${col}${path.map((p) => `->'${p}'`).join("")}`
|
|
670
|
+
: col;
|
|
671
|
+
idx++;
|
|
672
|
+
conditions.push(`${target} @> $${idx}::jsonb`);
|
|
673
|
+
values.push(JSON.stringify(operand));
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
case "array_starts_with": {
|
|
677
|
+
const path = filter.path as string[] | undefined;
|
|
678
|
+
const target = path && path.length > 0
|
|
679
|
+
? `${col}${path.map((p) => `->'${p}'`).join("")}`
|
|
680
|
+
: col;
|
|
681
|
+
idx++;
|
|
682
|
+
conditions.push(`${target}->0 = $${idx}::jsonb`);
|
|
683
|
+
values.push(JSON.stringify(operand));
|
|
684
|
+
break;
|
|
685
|
+
}
|
|
686
|
+
case "array_ends_with": {
|
|
687
|
+
const path = filter.path as string[] | undefined;
|
|
688
|
+
const target = path && path.length > 0
|
|
689
|
+
? `${col}${path.map((p) => `->'${p}'`).join("")}`
|
|
690
|
+
: col;
|
|
691
|
+
idx++;
|
|
692
|
+
conditions.push(`${target}->-1 = $${idx}::jsonb`);
|
|
693
|
+
values.push(JSON.stringify(operand));
|
|
694
|
+
break;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// ─── Scalar List (Array) Filter Operators ──────────────
|
|
698
|
+
case "has": {
|
|
699
|
+
idx++;
|
|
700
|
+
conditions.push(`$${idx} = ANY(${col})`);
|
|
701
|
+
values.push(operand);
|
|
702
|
+
break;
|
|
703
|
+
}
|
|
704
|
+
case "hasEvery": {
|
|
705
|
+
idx++;
|
|
706
|
+
conditions.push(`${col} @> $${idx}`);
|
|
707
|
+
values.push(operand);
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
case "hasSome": {
|
|
711
|
+
idx++;
|
|
712
|
+
conditions.push(`${col} && $${idx}`);
|
|
713
|
+
values.push(operand);
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
716
|
+
case "isEmpty": {
|
|
717
|
+
if (operand === true) {
|
|
718
|
+
conditions.push(`(array_length(${col}, 1) IS NULL)`);
|
|
719
|
+
} else {
|
|
720
|
+
conditions.push(`(array_length(${col}, 1) IS NOT NULL)`);
|
|
721
|
+
}
|
|
722
|
+
break;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
default:
|
|
726
|
+
break;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
return {
|
|
731
|
+
sql: conditions.length > 0 ? conditions.join(" AND ") : "TRUE",
|
|
732
|
+
values,
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
return { sql: "", values: [] };
|
|
737
|
+
}
|