metal-orm 1.1.9 → 1.1.11

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 (77) hide show
  1. package/README.md +769 -764
  2. package/dist/index.cjs +2255 -284
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +559 -39
  5. package/dist/index.d.ts +559 -39
  6. package/dist/index.js +2227 -284
  7. package/dist/index.js.map +1 -1
  8. package/package.json +17 -12
  9. package/scripts/generate-entities/render.mjs +21 -12
  10. package/scripts/generate-entities/schema.mjs +87 -73
  11. package/scripts/generate-entities/tree-detection.mjs +67 -61
  12. package/src/bulk/bulk-context.ts +83 -0
  13. package/src/bulk/bulk-delete-executor.ts +87 -0
  14. package/src/bulk/bulk-executor.base.ts +73 -0
  15. package/src/bulk/bulk-insert-executor.ts +74 -0
  16. package/src/bulk/bulk-types.ts +70 -0
  17. package/src/bulk/bulk-update-executor.ts +192 -0
  18. package/src/bulk/bulk-upsert-executor.ts +93 -0
  19. package/src/bulk/bulk-utils.ts +91 -0
  20. package/src/bulk/index.ts +18 -0
  21. package/src/codegen/typescript.ts +30 -21
  22. package/src/core/ast/expression-builders.ts +107 -10
  23. package/src/core/ast/expression-nodes.ts +52 -22
  24. package/src/core/ast/expression-visitor.ts +23 -13
  25. package/src/core/ddl/introspect/mysql.ts +113 -36
  26. package/src/core/dialect/abstract.ts +30 -17
  27. package/src/core/dialect/mysql/index.ts +20 -5
  28. package/src/core/execution/db-executor.ts +96 -64
  29. package/src/core/execution/executors/better-sqlite3-executor.ts +94 -0
  30. package/src/core/execution/executors/mssql-executor.ts +66 -34
  31. package/src/core/execution/executors/mysql-executor.ts +98 -66
  32. package/src/core/execution/executors/postgres-executor.ts +33 -11
  33. package/src/core/execution/executors/sqlite-executor.ts +86 -30
  34. package/src/decorators/bootstrap.ts +482 -398
  35. package/src/decorators/column-decorator.ts +87 -96
  36. package/src/decorators/decorator-metadata.ts +100 -24
  37. package/src/decorators/entity.ts +27 -24
  38. package/src/decorators/relations.ts +231 -149
  39. package/src/decorators/transformers/transformer-decorators.ts +26 -29
  40. package/src/decorators/validators/country-validators-decorators.ts +9 -15
  41. package/src/dto/apply-filter.ts +568 -551
  42. package/src/index.ts +16 -9
  43. package/src/orm/entity-hydration.ts +116 -72
  44. package/src/orm/entity-metadata.ts +347 -301
  45. package/src/orm/entity-relations.ts +264 -207
  46. package/src/orm/entity.ts +199 -199
  47. package/src/orm/execute.ts +13 -13
  48. package/src/orm/lazy-batch/morph-many.ts +70 -0
  49. package/src/orm/lazy-batch/morph-one.ts +69 -0
  50. package/src/orm/lazy-batch/morph-to.ts +59 -0
  51. package/src/orm/lazy-batch.ts +4 -1
  52. package/src/orm/orm-session.ts +170 -104
  53. package/src/orm/pooled-executor-factory.ts +99 -58
  54. package/src/orm/query-logger.ts +49 -40
  55. package/src/orm/relation-change-processor.ts +198 -96
  56. package/src/orm/relations/belongs-to.ts +143 -143
  57. package/src/orm/relations/has-many.ts +204 -204
  58. package/src/orm/relations/has-one.ts +174 -174
  59. package/src/orm/relations/many-to-many.ts +288 -288
  60. package/src/orm/relations/morph-many.ts +156 -0
  61. package/src/orm/relations/morph-one.ts +151 -0
  62. package/src/orm/relations/morph-to.ts +162 -0
  63. package/src/orm/save-graph.ts +116 -1
  64. package/src/query-builder/expression-table-mapper.ts +5 -0
  65. package/src/query-builder/hydration-manager.ts +345 -345
  66. package/src/query-builder/hydration-planner.ts +178 -148
  67. package/src/query-builder/relation-conditions.ts +171 -151
  68. package/src/query-builder/relation-cte-builder.ts +5 -1
  69. package/src/query-builder/relation-filter-utils.ts +9 -6
  70. package/src/query-builder/relation-include-strategies.ts +44 -2
  71. package/src/query-builder/relation-join-strategies.ts +8 -1
  72. package/src/query-builder/relation-service.ts +250 -241
  73. package/src/query-builder/select/select-operations.ts +110 -105
  74. package/src/query-builder/update-include.ts +4 -0
  75. package/src/schema/relation.ts +296 -188
  76. package/src/schema/types.ts +138 -123
  77. package/src/tree/tree-decorator.ts +127 -137
@@ -1,245 +1,247 @@
1
- /**
2
- * Runtime filter application - converts JSON filter objects to query builder conditions.
3
- */
4
-
5
- import type { TableDef } from '../schema/table.js';
6
- import type { ColumnDef } from '../schema/column-types.js';
7
- import { SelectQueryBuilder } from '../query-builder/select.js';
8
- import type { WhereInput, FilterValue, StringFilter, RelationFilter } from './filter-types.js';
9
- import type { EntityConstructor } from '../orm/entity-metadata.js';
10
- import { getTableDefFromEntity } from '../decorators/bootstrap.js';
11
- import {
12
- eq,
13
- neq,
14
- gt,
15
- gte,
16
- lt,
17
- lte,
18
- like,
19
- inList,
20
- notInList,
21
- and,
22
- exists,
23
- notExists,
24
- type ColumnNode,
25
- type ExpressionNode
26
- } from '../core/ast/expression.js';
27
- import { findPrimaryKey } from '../query-builder/hydration-planner.js';
28
- import { RelationKinds, type RelationDef, type BelongsToManyRelation } from '../schema/relation.js';
29
- import type { SelectQueryNode, TableSourceNode } from '../core/ast/query.js';
30
- import type { JoinNode } from '../core/ast/join.js';
31
- import { createJoinNode } from '../core/ast/join-node.js';
32
- import { JOIN_KINDS } from '../core/sql/sql.js';
33
- import { buildRelationCorrelation } from '../query-builder/relation-conditions.js';
34
- import { updateInclude } from '../query-builder/update-include.js';
35
-
36
- /**
37
- * Options for applyFilter to control relation filtering behavior.
38
- */
39
- export interface ApplyFilterOptions<TTable extends TableDef> {
40
- relations?: {
41
- [K in keyof TTable['relations']]?: boolean | {
42
- relations?: ApplyFilterOptions<TTable['relations'][K]['target']>['relations'];
43
- };
44
- };
45
- }
46
-
47
- /**
48
- * Builds an expression node from a single field filter.
49
- */
50
- function buildFieldExpression(
51
- column: ColumnDef,
52
- filter: FilterValue
53
- ): ExpressionNode | null {
54
- const expressions: ExpressionNode[] = [];
55
-
56
- // String filters
57
- if ('contains' in filter && filter.contains !== undefined) {
58
- const pattern = `%${escapePattern(filter.contains)}%`;
59
- const mode = (filter as StringFilter).mode;
60
- if (mode === 'insensitive') {
61
- expressions.push(caseInsensitiveLike(column, pattern));
62
- } else {
63
- expressions.push(like(column, pattern));
64
- }
65
- }
66
-
67
- if ('startsWith' in filter && filter.startsWith !== undefined) {
68
- const pattern = `${escapePattern(filter.startsWith)}%`;
69
- const mode = (filter as StringFilter).mode;
70
- if (mode === 'insensitive') {
71
- expressions.push(caseInsensitiveLike(column, pattern));
72
- } else {
73
- expressions.push(like(column, pattern));
74
- }
75
- }
76
-
77
- if ('endsWith' in filter && filter.endsWith !== undefined) {
78
- const pattern = `%${escapePattern(filter.endsWith)}`;
79
- const mode = (filter as StringFilter).mode;
80
- if (mode === 'insensitive') {
81
- expressions.push(caseInsensitiveLike(column, pattern));
82
- } else {
83
- expressions.push(like(column, pattern));
84
- }
85
- }
86
-
87
- // Common filters (equals, not, in, notIn)
88
- if ('equals' in filter && filter.equals !== undefined) {
89
- expressions.push(eq(column, filter.equals as string | number | boolean));
90
- }
91
-
92
- if ('not' in filter && filter.not !== undefined) {
93
- expressions.push(neq(column, filter.not as string | number | boolean));
94
- }
95
-
96
- if ('in' in filter && filter.in !== undefined) {
97
- expressions.push(inList(column, filter.in as (string | number)[]));
98
- }
99
-
100
- if ('notIn' in filter && filter.notIn !== undefined) {
101
- expressions.push(notInList(column, filter.notIn as (string | number)[]));
102
- }
103
-
104
- // Comparison filters (lt, lte, gt, gte)
105
- if ('lt' in filter && filter.lt !== undefined) {
106
- expressions.push(lt(column, filter.lt as string | number));
107
- }
108
-
109
- if ('lte' in filter && filter.lte !== undefined) {
110
- expressions.push(lte(column, filter.lte as string | number));
111
- }
112
-
113
- if ('gt' in filter && filter.gt !== undefined) {
114
- expressions.push(gt(column, filter.gt as string | number));
115
- }
116
-
117
- if ('gte' in filter && filter.gte !== undefined) {
118
- expressions.push(gte(column, filter.gte as string | number));
119
- }
120
-
121
- if (expressions.length === 0) {
122
- return null;
123
- }
124
-
125
- if (expressions.length === 1) {
126
- return expressions[0];
127
- }
128
-
129
- return and(...expressions);
130
- }
131
-
132
- /**
133
- * Escapes special LIKE pattern characters.
134
- */
135
- function escapePattern(value: string): string {
136
- return value.replace(/[%_\\]/g, '\\$&');
137
- }
138
-
139
- /**
140
- * Creates a case-insensitive LIKE expression using LOWER().
141
- * This is portable across all SQL dialects.
142
- */
143
- function caseInsensitiveLike(column: ColumnDef, pattern: string): ExpressionNode {
144
- return {
145
- type: 'BinaryExpression',
146
- left: {
147
- type: 'Function',
148
- name: 'LOWER',
149
- args: [{ type: 'Column', table: column.table!, name: column.name }]
150
- },
151
- operator: 'LIKE',
152
- right: { type: 'Literal', value: pattern.toLowerCase() }
153
- } as unknown as ExpressionNode;
154
- }
155
-
156
- /**
157
- * Applies a filter object to a SelectQueryBuilder.
158
- * All conditions are AND-ed together.
159
- *
160
- * @param qb - The query builder to apply filters to
161
- * @param tableOrEntity - The table definition or entity constructor (used to resolve column references)
162
- * @param where - The filter object from: API request
163
- * @param options - Options to control relation filtering behavior
164
- * @returns The query builder with filters applied
165
- *
166
- * @example
167
- * ```ts
168
- * // In a controller - using Entity class
169
- * @Get()
170
- * async list(@Query() where?: UserFilter): Promise<UserResponse[]> {
171
- * let query = selectFromEntity(User);
172
- * query = applyFilter(query, User, where);
173
- * return query.execute(db);
174
- * }
175
- *
176
- * // Using TableDef directly
177
- * @Get()
178
- * async list(@Query() where?: UserFilter): Promise<UserResponse[]> {
179
- * let query = selectFrom(usersTable);
180
- * query = applyFilter(query, usersTable, where);
181
- * return query.execute(db);
182
- * }
183
- *
184
- * // Request: { "name": { "contains": "john" }, "email": { "endsWith": "@gmail.com" } }
185
- * // SQL: WHERE name LIKE '%john%' AND email LIKE '%@gmail.com'
186
- * ```
187
- */
188
- export function applyFilter<T, TTable extends TableDef>(
189
- qb: SelectQueryBuilder<T, TTable>,
190
- tableOrEntity: TTable | EntityConstructor,
191
- where?: WhereInput<TTable | EntityConstructor> | null,
192
- _options?: ApplyFilterOptions<TTable>
193
- ): SelectQueryBuilder<T, TTable> {
194
- if (!where) {
195
- return qb;
196
- }
197
-
198
- const table = (isEntityConstructor(tableOrEntity)
199
- ? getTableDefFromEntity(tableOrEntity)
200
- : tableOrEntity) as TTable;
201
-
202
- if (!table) {
203
- return qb;
204
- }
205
-
206
- const expressions: ExpressionNode[] = [];
207
-
208
- for (const [fieldName, fieldFilter] of Object.entries(where)) {
209
- if (fieldFilter === undefined || fieldFilter === null) {
210
- continue;
211
- }
212
-
213
- const column = table.columns[fieldName as keyof typeof table.columns];
214
- if (column) {
215
- const expr = buildFieldExpression(column, fieldFilter as FilterValue);
216
- if (expr) {
217
- expressions.push(expr);
218
- }
219
- } else if (table.relations && fieldName in table.relations) {
220
- const relationFilter = fieldFilter as RelationFilter;
221
- const relationName = fieldName as keyof TTable['relations'] & string;
222
- qb = applyRelationFilter(qb, table, relationName, relationFilter);
223
- }
224
- }
225
-
226
- if (expressions.length === 0) {
227
- return qb;
228
- }
229
-
230
- if (expressions.length === 1) {
231
- return qb.where(expressions[0]);
232
- }
233
-
234
- return qb.where(and(...expressions));
235
- }
236
-
237
- function applyRelationFilter<T, TTable extends TableDef>(
238
- qb: SelectQueryBuilder<T, TTable>,
239
- table: TTable,
240
- relationName: keyof TTable['relations'] & string,
241
- filter: RelationFilter
242
- ): SelectQueryBuilder<T, TTable> {
1
+ /**
2
+ * Runtime filter application - converts JSON filter objects to query builder conditions.
3
+ */
4
+
5
+ import type { TableDef } from '../schema/table.js';
6
+ import type { ColumnDef } from '../schema/column-types.js';
7
+ import { SelectQueryBuilder } from '../query-builder/select.js';
8
+ import type { WhereInput, FilterValue, StringFilter, RelationFilter } from './filter-types.js';
9
+ import type { EntityConstructor } from '../orm/entity-metadata.js';
10
+ import { getTableDefFromEntity } from '../decorators/bootstrap.js';
11
+ import {
12
+ eq,
13
+ neq,
14
+ gt,
15
+ gte,
16
+ lt,
17
+ lte,
18
+ like,
19
+ inList,
20
+ notInList,
21
+ and,
22
+ exists,
23
+ notExists,
24
+ type ColumnNode,
25
+ type ExpressionNode
26
+ } from '../core/ast/expression.js';
27
+ import { findPrimaryKey } from '../query-builder/hydration-planner.js';
28
+ import { RelationKinds, type RelationDef, type BelongsToManyRelation, isSingleTargetRelation } from '../schema/relation.js';
29
+ import type { SelectQueryNode, TableSourceNode } from '../core/ast/query.js';
30
+ import type { JoinNode } from '../core/ast/join.js';
31
+ import { createJoinNode } from '../core/ast/join-node.js';
32
+ import { JOIN_KINDS } from '../core/sql/sql.js';
33
+ import { buildRelationCorrelation } from '../query-builder/relation-conditions.js';
34
+ import { updateInclude } from '../query-builder/update-include.js';
35
+
36
+ /**
37
+ * Options for applyFilter to control relation filtering behavior.
38
+ */
39
+ export interface ApplyFilterOptions<TTable extends TableDef> {
40
+ relations?: {
41
+ [K in keyof TTable['relations']]?: boolean | {
42
+ relations?: TTable['relations'][K] extends { target: infer TTarget extends TableDef }
43
+ ? ApplyFilterOptions<TTarget>['relations']
44
+ : never;
45
+ };
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Builds an expression node from a single field filter.
51
+ */
52
+ function buildFieldExpression(
53
+ column: ColumnDef,
54
+ filter: FilterValue
55
+ ): ExpressionNode | null {
56
+ const expressions: ExpressionNode[] = [];
57
+
58
+ // String filters
59
+ if ('contains' in filter && filter.contains !== undefined) {
60
+ const pattern = `%${escapePattern(filter.contains)}%`;
61
+ const mode = (filter as StringFilter).mode;
62
+ if (mode === 'insensitive') {
63
+ expressions.push(caseInsensitiveLike(column, pattern));
64
+ } else {
65
+ expressions.push(like(column, pattern));
66
+ }
67
+ }
68
+
69
+ if ('startsWith' in filter && filter.startsWith !== undefined) {
70
+ const pattern = `${escapePattern(filter.startsWith)}%`;
71
+ const mode = (filter as StringFilter).mode;
72
+ if (mode === 'insensitive') {
73
+ expressions.push(caseInsensitiveLike(column, pattern));
74
+ } else {
75
+ expressions.push(like(column, pattern));
76
+ }
77
+ }
78
+
79
+ if ('endsWith' in filter && filter.endsWith !== undefined) {
80
+ const pattern = `%${escapePattern(filter.endsWith)}`;
81
+ const mode = (filter as StringFilter).mode;
82
+ if (mode === 'insensitive') {
83
+ expressions.push(caseInsensitiveLike(column, pattern));
84
+ } else {
85
+ expressions.push(like(column, pattern));
86
+ }
87
+ }
88
+
89
+ // Common filters (equals, not, in, notIn)
90
+ if ('equals' in filter && filter.equals !== undefined) {
91
+ expressions.push(eq(column, filter.equals as string | number | boolean));
92
+ }
93
+
94
+ if ('not' in filter && filter.not !== undefined) {
95
+ expressions.push(neq(column, filter.not as string | number | boolean));
96
+ }
97
+
98
+ if ('in' in filter && filter.in !== undefined) {
99
+ expressions.push(inList(column, filter.in as (string | number)[]));
100
+ }
101
+
102
+ if ('notIn' in filter && filter.notIn !== undefined) {
103
+ expressions.push(notInList(column, filter.notIn as (string | number)[]));
104
+ }
105
+
106
+ // Comparison filters (lt, lte, gt, gte)
107
+ if ('lt' in filter && filter.lt !== undefined) {
108
+ expressions.push(lt(column, filter.lt as string | number));
109
+ }
110
+
111
+ if ('lte' in filter && filter.lte !== undefined) {
112
+ expressions.push(lte(column, filter.lte as string | number));
113
+ }
114
+
115
+ if ('gt' in filter && filter.gt !== undefined) {
116
+ expressions.push(gt(column, filter.gt as string | number));
117
+ }
118
+
119
+ if ('gte' in filter && filter.gte !== undefined) {
120
+ expressions.push(gte(column, filter.gte as string | number));
121
+ }
122
+
123
+ if (expressions.length === 0) {
124
+ return null;
125
+ }
126
+
127
+ if (expressions.length === 1) {
128
+ return expressions[0];
129
+ }
130
+
131
+ return and(...expressions);
132
+ }
133
+
134
+ /**
135
+ * Escapes special LIKE pattern characters.
136
+ */
137
+ function escapePattern(value: string): string {
138
+ return value.replace(/[%_\\]/g, '\\$&');
139
+ }
140
+
141
+ /**
142
+ * Creates a case-insensitive LIKE expression using LOWER().
143
+ * This is portable across all SQL dialects.
144
+ */
145
+ function caseInsensitiveLike(column: ColumnDef, pattern: string): ExpressionNode {
146
+ return {
147
+ type: 'BinaryExpression',
148
+ left: {
149
+ type: 'Function',
150
+ name: 'LOWER',
151
+ args: [{ type: 'Column', table: column.table!, name: column.name }]
152
+ },
153
+ operator: 'LIKE',
154
+ right: { type: 'Literal', value: pattern.toLowerCase() }
155
+ } as unknown as ExpressionNode;
156
+ }
157
+
158
+ /**
159
+ * Applies a filter object to a SelectQueryBuilder.
160
+ * All conditions are AND-ed together.
161
+ *
162
+ * @param qb - The query builder to apply filters to
163
+ * @param tableOrEntity - The table definition or entity constructor (used to resolve column references)
164
+ * @param where - The filter object from: API request
165
+ * @param options - Options to control relation filtering behavior
166
+ * @returns The query builder with filters applied
167
+ *
168
+ * @example
169
+ * ```ts
170
+ * // In a controller - using Entity class
171
+ * @Get()
172
+ * async list(@Query() where?: UserFilter): Promise<UserResponse[]> {
173
+ * let query = selectFromEntity(User);
174
+ * query = applyFilter(query, User, where);
175
+ * return query.execute(db);
176
+ * }
177
+ *
178
+ * // Using TableDef directly
179
+ * @Get()
180
+ * async list(@Query() where?: UserFilter): Promise<UserResponse[]> {
181
+ * let query = selectFrom(usersTable);
182
+ * query = applyFilter(query, usersTable, where);
183
+ * return query.execute(db);
184
+ * }
185
+ *
186
+ * // Request: { "name": { "contains": "john" }, "email": { "endsWith": "@gmail.com" } }
187
+ * // SQL: WHERE name LIKE '%john%' AND email LIKE '%@gmail.com'
188
+ * ```
189
+ */
190
+ export function applyFilter<T, TTable extends TableDef>(
191
+ qb: SelectQueryBuilder<T, TTable>,
192
+ tableOrEntity: TTable | EntityConstructor,
193
+ where?: WhereInput<TTable | EntityConstructor> | null,
194
+ _options?: ApplyFilterOptions<TTable>
195
+ ): SelectQueryBuilder<T, TTable> {
196
+ if (!where) {
197
+ return qb;
198
+ }
199
+
200
+ const table = (isEntityConstructor(tableOrEntity)
201
+ ? getTableDefFromEntity(tableOrEntity)
202
+ : tableOrEntity) as TTable;
203
+
204
+ if (!table) {
205
+ return qb;
206
+ }
207
+
208
+ const expressions: ExpressionNode[] = [];
209
+
210
+ for (const [fieldName, fieldFilter] of Object.entries(where)) {
211
+ if (fieldFilter === undefined || fieldFilter === null) {
212
+ continue;
213
+ }
214
+
215
+ const column = table.columns[fieldName as keyof typeof table.columns];
216
+ if (column) {
217
+ const expr = buildFieldExpression(column, fieldFilter as FilterValue);
218
+ if (expr) {
219
+ expressions.push(expr);
220
+ }
221
+ } else if (table.relations && fieldName in table.relations) {
222
+ const relationFilter = fieldFilter as RelationFilter;
223
+ const relationName = fieldName as keyof TTable['relations'] & string;
224
+ qb = applyRelationFilter(qb, table, relationName, relationFilter);
225
+ }
226
+ }
227
+
228
+ if (expressions.length === 0) {
229
+ return qb;
230
+ }
231
+
232
+ if (expressions.length === 1) {
233
+ return qb.where(expressions[0]);
234
+ }
235
+
236
+ return qb.where(and(...expressions));
237
+ }
238
+
239
+ function applyRelationFilter<T, TTable extends TableDef>(
240
+ qb: SelectQueryBuilder<T, TTable>,
241
+ table: TTable,
242
+ relationName: keyof TTable['relations'] & string,
243
+ filter: RelationFilter
244
+ ): SelectQueryBuilder<T, TTable> {
243
245
  const relation = table.relations[relationName];
244
246
  if (!relation) {
245
247
  return qb;
@@ -252,139 +254,150 @@ function applyRelationFilter<T, TTable extends TableDef>(
252
254
  );
253
255
  }
254
256
 
257
+ if (!isSingleTargetRelation(relation)) {
258
+ return qb;
259
+ }
260
+
255
261
  if (filter.some) {
256
262
  const predicate = buildFilterExpression(relation.target, filter.some);
257
263
  if (predicate) {
258
- qb = updateInclude(qb, relationName, opts => ({
259
- ...opts,
260
- joinKind: JOIN_KINDS.INNER,
261
- filter: opts.filter ? and(opts.filter, predicate) : predicate
262
- }));
263
- const pk = findPrimaryKey(table);
264
- const pkColumn = table.columns[pk];
265
- qb = pkColumn
266
- ? qb.distinct(pkColumn)
267
- : qb.distinct({ type: 'Column', table: table.name, name: pk } as ColumnNode);
268
- }
269
- }
270
-
271
- if (filter.none) {
272
- const predicate = buildFilterExpression(relation.target, filter.none);
273
- if (predicate) {
274
- qb = qb.whereHasNot(relationName, (subQb) => subQb.where(predicate));
275
- }
276
- }
277
-
278
- if (filter.every) {
279
- const predicate = buildFilterExpression(relation.target, filter.every);
280
- if (predicate) {
281
- const pk = findPrimaryKey(table);
282
- qb = qb.joinRelation(relationName);
283
- qb = qb.groupBy({ type: 'Column', table: table.name, name: pk }) as typeof qb;
284
- qb = qb.having(buildEveryHavingClause(relationName, predicate, relation.target)) as typeof qb;
285
- }
286
- }
287
-
288
- if (filter.isEmpty !== undefined) {
289
- if (filter.isEmpty) {
290
- qb = qb.whereHasNot(relationName);
291
- } else {
292
- qb = qb.whereHas(relationName);
293
- }
294
- }
295
-
296
- if (filter.isNotEmpty !== undefined) {
297
- if (filter.isNotEmpty) {
298
- qb = qb.whereHas(relationName);
299
- } else {
300
- qb = qb.whereHasNot(relationName);
301
- }
302
- }
303
-
304
- return qb;
305
- }
306
-
307
- type RelationSubqueryBase = {
308
- from: TableSourceNode;
309
- joins: JoinNode[];
310
- correlation: ExpressionNode;
311
- groupByColumn: ColumnNode;
312
- targetTable: TableDef;
313
- targetTableName: string;
314
- };
315
-
316
- const buildRelationSubqueryBase = (
317
- table: TableDef,
318
- relation: RelationDef
319
- ): RelationSubqueryBase => {
320
- const target = relation.target;
321
-
322
- if (relation.type === RelationKinds.BelongsToMany) {
323
- const many = relation as BelongsToManyRelation;
324
- const localKey = many.localKey || findPrimaryKey(table);
325
- const targetKey = many.targetKey || findPrimaryKey(target);
326
- const pivot = many.pivotTable;
327
-
328
- const from: TableSourceNode = {
329
- type: 'Table',
330
- name: pivot.name,
331
- schema: pivot.schema
332
- };
333
-
334
- const joins: JoinNode[] = [
335
- createJoinNode(
336
- JOIN_KINDS.INNER,
337
- { type: 'Table', name: target.name, schema: target.schema },
338
- eq(
339
- { type: 'Column', table: target.name, name: targetKey },
340
- { type: 'Column', table: pivot.name, name: many.pivotForeignKeyToTarget }
341
- )
342
- )
343
- ];
344
-
345
- const correlation = eq(
346
- { type: 'Column', table: pivot.name, name: many.pivotForeignKeyToRoot },
347
- { type: 'Column', table: table.name, name: localKey }
348
- );
349
-
350
- const groupByColumn: ColumnNode = {
351
- type: 'Column',
352
- table: pivot.name,
353
- name: many.pivotForeignKeyToRoot
354
- };
355
-
356
- return {
357
- from,
358
- joins,
359
- correlation,
360
- groupByColumn,
361
- targetTable: target,
362
- targetTableName: target.name
363
- };
364
- }
365
-
366
- const from: TableSourceNode = {
367
- type: 'Table',
368
- name: target.name,
369
- schema: target.schema
370
- };
371
-
372
- const correlation = buildRelationCorrelation(table, relation);
373
- const groupByColumnName =
374
- relation.type === RelationKinds.BelongsTo
375
- ? (relation.localKey || findPrimaryKey(target))
376
- : relation.foreignKey;
377
-
378
- return {
379
- from,
380
- joins: [],
381
- correlation,
382
- groupByColumn: { type: 'Column', table: target.name, name: groupByColumnName },
383
- targetTable: target,
384
- targetTableName: target.name
385
- };
386
- };
387
-
264
+ qb = updateInclude(qb, relationName, opts => ({
265
+ ...opts,
266
+ joinKind: JOIN_KINDS.INNER,
267
+ filter: opts.filter ? and(opts.filter, predicate) : predicate
268
+ }));
269
+ const pk = findPrimaryKey(table);
270
+ const pkColumn = table.columns[pk];
271
+ qb = pkColumn
272
+ ? qb.distinct(pkColumn)
273
+ : qb.distinct({ type: 'Column', table: table.name, name: pk } as ColumnNode);
274
+ }
275
+ }
276
+
277
+ if (filter.none) {
278
+ const predicate = buildFilterExpression(relation.target, filter.none);
279
+ if (predicate) {
280
+ qb = qb.whereHasNot(relationName, (subQb) => subQb.where(predicate));
281
+ }
282
+ }
283
+
284
+ if (filter.every) {
285
+ const predicate = buildFilterExpression(relation.target, filter.every);
286
+ if (predicate) {
287
+ const pk = findPrimaryKey(table);
288
+ qb = qb.joinRelation(relationName);
289
+ qb = qb.groupBy({ type: 'Column', table: table.name, name: pk }) as typeof qb;
290
+ qb = qb.having(buildEveryHavingClause(relationName, predicate, relation.target)) as typeof qb;
291
+ }
292
+ }
293
+
294
+ if (filter.isEmpty !== undefined) {
295
+ if (filter.isEmpty) {
296
+ qb = qb.whereHasNot(relationName);
297
+ } else {
298
+ qb = qb.whereHas(relationName);
299
+ }
300
+ }
301
+
302
+ if (filter.isNotEmpty !== undefined) {
303
+ if (filter.isNotEmpty) {
304
+ qb = qb.whereHas(relationName);
305
+ } else {
306
+ qb = qb.whereHasNot(relationName);
307
+ }
308
+ }
309
+
310
+ return qb;
311
+ }
312
+
313
+ type RelationSubqueryBase = {
314
+ from: TableSourceNode;
315
+ joins: JoinNode[];
316
+ correlation: ExpressionNode;
317
+ groupByColumn: ColumnNode;
318
+ targetTable: TableDef;
319
+ targetTableName: string;
320
+ };
321
+
322
+ const buildRelationSubqueryBase = (
323
+ table: TableDef,
324
+ relation: RelationDef
325
+ ): RelationSubqueryBase => {
326
+ if (!isSingleTargetRelation(relation)) {
327
+ throw new Error('MorphTo relations do not support subquery-based filtering');
328
+ }
329
+ const target = relation.target;
330
+
331
+ if (relation.type === RelationKinds.BelongsToMany) {
332
+ const many = relation as BelongsToManyRelation;
333
+ const localKey = many.localKey || findPrimaryKey(table);
334
+ const targetKey = many.targetKey || findPrimaryKey(target);
335
+ const pivot = many.pivotTable;
336
+
337
+ const from: TableSourceNode = {
338
+ type: 'Table',
339
+ name: pivot.name,
340
+ schema: pivot.schema
341
+ };
342
+
343
+ const joins: JoinNode[] = [
344
+ createJoinNode(
345
+ JOIN_KINDS.INNER,
346
+ { type: 'Table', name: target.name, schema: target.schema },
347
+ eq(
348
+ { type: 'Column', table: target.name, name: targetKey },
349
+ { type: 'Column', table: pivot.name, name: many.pivotForeignKeyToTarget }
350
+ )
351
+ )
352
+ ];
353
+
354
+ const correlation = eq(
355
+ { type: 'Column', table: pivot.name, name: many.pivotForeignKeyToRoot },
356
+ { type: 'Column', table: table.name, name: localKey }
357
+ );
358
+
359
+ const groupByColumn: ColumnNode = {
360
+ type: 'Column',
361
+ table: pivot.name,
362
+ name: many.pivotForeignKeyToRoot
363
+ };
364
+
365
+ return {
366
+ from,
367
+ joins,
368
+ correlation,
369
+ groupByColumn,
370
+ targetTable: target,
371
+ targetTableName: target.name
372
+ };
373
+ }
374
+
375
+ const from: TableSourceNode = {
376
+ type: 'Table',
377
+ name: target.name,
378
+ schema: target.schema
379
+ };
380
+
381
+ const correlation = buildRelationCorrelation(table, relation);
382
+ let groupByColumnName: string;
383
+ if (relation.type === RelationKinds.BelongsTo) {
384
+ groupByColumnName = relation.localKey || findPrimaryKey(target);
385
+ } else if (relation.type === RelationKinds.MorphOne || relation.type === RelationKinds.MorphMany) {
386
+ groupByColumnName = relation.idField;
387
+ } else {
388
+ groupByColumnName = relation.foreignKey;
389
+ }
390
+
391
+ return {
392
+ from,
393
+ joins: [],
394
+ correlation,
395
+ groupByColumn: { type: 'Column', table: target.name, name: groupByColumnName },
396
+ targetTable: target,
397
+ targetTableName: target.name
398
+ };
399
+ };
400
+
388
401
  function buildRelationFilterExpression(
389
402
  table: TableDef,
390
403
  relationName: string,
@@ -401,112 +414,116 @@ function buildRelationFilterExpression(
401
414
  + '"some", "none", "every", "isEmpty", or "isNotEmpty".'
402
415
  );
403
416
  }
404
-
405
- const expressions: ExpressionNode[] = [];
406
- const subqueryBase = buildRelationSubqueryBase(table, relation);
407
-
408
- const buildSubquery = (predicate?: ExpressionNode): SelectQueryNode => {
409
- const where = predicate
410
- ? and(subqueryBase.correlation, predicate)
411
- : subqueryBase.correlation;
412
- return {
413
- type: 'SelectQuery',
414
- from: subqueryBase.from,
415
- columns: [],
416
- joins: subqueryBase.joins,
417
- where
418
- };
419
- };
420
-
421
- if (filter.some) {
422
- const predicate = buildFilterExpression(relation.target, filter.some);
423
- if (predicate) {
424
- expressions.push(exists(buildSubquery(predicate)));
425
- }
426
- }
427
-
428
- if (filter.none) {
429
- const predicate = buildFilterExpression(relation.target, filter.none);
430
- if (predicate) {
431
- expressions.push(notExists(buildSubquery(predicate)));
432
- }
433
- }
434
-
435
- if (filter.every) {
436
- const predicate = buildFilterExpression(relation.target, filter.every);
437
- if (predicate) {
438
- const subquery: SelectQueryNode = {
439
- type: 'SelectQuery',
440
- from: subqueryBase.from,
441
- columns: [],
442
- joins: subqueryBase.joins,
443
- where: subqueryBase.correlation,
444
- groupBy: [subqueryBase.groupByColumn],
445
- having: buildEveryHavingClause(subqueryBase.targetTableName, predicate, subqueryBase.targetTable)
446
- };
447
- expressions.push(exists(subquery));
448
- }
449
- }
450
-
451
- if (filter.isEmpty !== undefined) {
452
- const subquery = buildSubquery();
453
- expressions.push(filter.isEmpty ? notExists(subquery) : exists(subquery));
454
- }
455
-
456
- if (filter.isNotEmpty !== undefined) {
457
- const subquery = buildSubquery();
458
- expressions.push(filter.isNotEmpty ? exists(subquery) : notExists(subquery));
459
- }
460
-
461
- if (expressions.length === 0) {
462
- return null;
463
- }
464
-
465
- if (expressions.length === 1) {
466
- return expressions[0];
467
- }
468
-
469
- return and(...expressions);
470
- }
471
-
472
- function buildEveryHavingClause(
473
- relationName: string,
474
- predicate: ExpressionNode,
475
- targetTable: TableDef
476
- ): ExpressionNode {
477
- const pk = findPrimaryKey(targetTable);
478
-
479
- const whenClause = {
480
- when: predicate,
481
- then: { type: 'Literal' as const, value: 1 }
482
- };
483
-
484
- const caseExpr = {
485
- type: 'CaseExpression' as const,
486
- conditions: [whenClause],
487
- else: { type: 'Literal' as const, value: null }
488
- };
489
-
490
- const countMatching = {
491
- type: 'Function' as const,
492
- name: 'COUNT',
493
- args: [caseExpr]
494
- };
495
-
496
- const countAll = {
497
- type: 'Function' as const,
498
- name: 'COUNT',
499
- args: [{ type: 'Column' as const, table: relationName, name: pk }]
500
- };
501
-
502
- return {
503
- type: 'BinaryExpression' as const,
504
- operator: '=',
505
- left: countAll,
506
- right: countMatching
507
- };
508
- }
509
-
417
+
418
+ const expressions: ExpressionNode[] = [];
419
+ const subqueryBase = buildRelationSubqueryBase(table, relation);
420
+
421
+ const buildSubquery = (predicate?: ExpressionNode): SelectQueryNode => {
422
+ const where = predicate
423
+ ? and(subqueryBase.correlation, predicate)
424
+ : subqueryBase.correlation;
425
+ return {
426
+ type: 'SelectQuery',
427
+ from: subqueryBase.from,
428
+ columns: [],
429
+ joins: subqueryBase.joins,
430
+ where
431
+ };
432
+ };
433
+
434
+ if (!isSingleTargetRelation(relation)) {
435
+ return null;
436
+ }
437
+
438
+ if (filter.some) {
439
+ const predicate = buildFilterExpression(relation.target, filter.some);
440
+ if (predicate) {
441
+ expressions.push(exists(buildSubquery(predicate)));
442
+ }
443
+ }
444
+
445
+ if (filter.none) {
446
+ const predicate = buildFilterExpression(relation.target, filter.none);
447
+ if (predicate) {
448
+ expressions.push(notExists(buildSubquery(predicate)));
449
+ }
450
+ }
451
+
452
+ if (filter.every) {
453
+ const predicate = buildFilterExpression(relation.target, filter.every);
454
+ if (predicate) {
455
+ const subquery: SelectQueryNode = {
456
+ type: 'SelectQuery',
457
+ from: subqueryBase.from,
458
+ columns: [],
459
+ joins: subqueryBase.joins,
460
+ where: subqueryBase.correlation,
461
+ groupBy: [subqueryBase.groupByColumn],
462
+ having: buildEveryHavingClause(subqueryBase.targetTableName, predicate, subqueryBase.targetTable)
463
+ };
464
+ expressions.push(exists(subquery));
465
+ }
466
+ }
467
+
468
+ if (filter.isEmpty !== undefined) {
469
+ const subquery = buildSubquery();
470
+ expressions.push(filter.isEmpty ? notExists(subquery) : exists(subquery));
471
+ }
472
+
473
+ if (filter.isNotEmpty !== undefined) {
474
+ const subquery = buildSubquery();
475
+ expressions.push(filter.isNotEmpty ? exists(subquery) : notExists(subquery));
476
+ }
477
+
478
+ if (expressions.length === 0) {
479
+ return null;
480
+ }
481
+
482
+ if (expressions.length === 1) {
483
+ return expressions[0];
484
+ }
485
+
486
+ return and(...expressions);
487
+ }
488
+
489
+ function buildEveryHavingClause(
490
+ relationName: string,
491
+ predicate: ExpressionNode,
492
+ targetTable: TableDef
493
+ ): ExpressionNode {
494
+ const pk = findPrimaryKey(targetTable);
495
+
496
+ const whenClause = {
497
+ when: predicate,
498
+ then: { type: 'Literal' as const, value: 1 }
499
+ };
500
+
501
+ const caseExpr = {
502
+ type: 'CaseExpression' as const,
503
+ conditions: [whenClause],
504
+ else: { type: 'Literal' as const, value: null }
505
+ };
506
+
507
+ const countMatching = {
508
+ type: 'Function' as const,
509
+ name: 'COUNT',
510
+ args: [caseExpr]
511
+ };
512
+
513
+ const countAll = {
514
+ type: 'Function' as const,
515
+ name: 'COUNT',
516
+ args: [{ type: 'Column' as const, table: relationName, name: pk }]
517
+ };
518
+
519
+ return {
520
+ type: 'BinaryExpression' as const,
521
+ operator: '=',
522
+ left: countAll,
523
+ right: countMatching
524
+ };
525
+ }
526
+
510
527
  function isEntityConstructor(value: unknown): value is EntityConstructor {
511
528
  return typeof value === 'function' && value.prototype?.constructor === value;
512
529
  }
@@ -520,76 +537,76 @@ function hasRelationFilterOperator(filter: RelationFilter): boolean {
520
537
  || filter.isNotEmpty !== undefined
521
538
  );
522
539
  }
523
-
524
- /**
525
- * Builds an expression tree from a filter object without applying it.
526
- * Useful for combining with other conditions.
527
- *
528
- * @param tableOrEntity - The table definition or entity constructor
529
- * @param where - The filter object
530
- * @returns An expression node or null if no filters
531
- *
532
- * @example
533
- * ```ts
534
- * // Using Entity class
535
- * const filterExpr = buildFilterExpression(User, { name: { contains: "john" } });
536
- *
537
- * // Using TableDef directly
538
- * const filterExpr = buildFilterExpression(usersTable, { name: { contains: "john" } });
539
- *
540
- * if (filterExpr) {
541
- * qb = qb.where(and(filterExpr, eq(users.columns.active, true)));
542
- * }
543
- * ```
544
- */
545
- export function buildFilterExpression(
546
- tableOrEntity: TableDef | EntityConstructor,
547
- where?: WhereInput<TableDef | EntityConstructor> | null
548
- ): ExpressionNode | null {
549
- if (!where) {
550
- return null;
551
- }
552
-
553
- const table = isEntityConstructor(tableOrEntity)
554
- ? getTableDefFromEntity(tableOrEntity)
555
- : tableOrEntity;
556
-
557
- if (!table) {
558
- return null;
559
- }
560
-
561
- const expressions: ExpressionNode[] = [];
562
-
563
- for (const [fieldName, fieldFilter] of Object.entries(where)) {
564
- if (fieldFilter === undefined || fieldFilter === null) {
565
- continue;
566
- }
567
-
568
- const column = table.columns[fieldName as keyof typeof table.columns];
569
- if (column) {
570
- const expr = buildFieldExpression(column, fieldFilter as FilterValue);
571
- if (expr) {
572
- expressions.push(expr);
573
- }
574
- } else if (table.relations && fieldName in table.relations) {
575
- const relationExpr = buildRelationFilterExpression(
576
- table,
577
- fieldName,
578
- fieldFilter as RelationFilter
579
- );
580
- if (relationExpr) {
581
- expressions.push(relationExpr);
582
- }
583
- }
584
- }
585
-
586
- if (expressions.length === 0) {
587
- return null;
588
- }
589
-
590
- if (expressions.length === 1) {
591
- return expressions[0];
592
- }
593
-
594
- return and(...expressions);
595
- }
540
+
541
+ /**
542
+ * Builds an expression tree from a filter object without applying it.
543
+ * Useful for combining with other conditions.
544
+ *
545
+ * @param tableOrEntity - The table definition or entity constructor
546
+ * @param where - The filter object
547
+ * @returns An expression node or null if no filters
548
+ *
549
+ * @example
550
+ * ```ts
551
+ * // Using Entity class
552
+ * const filterExpr = buildFilterExpression(User, { name: { contains: "john" } });
553
+ *
554
+ * // Using TableDef directly
555
+ * const filterExpr = buildFilterExpression(usersTable, { name: { contains: "john" } });
556
+ *
557
+ * if (filterExpr) {
558
+ * qb = qb.where(and(filterExpr, eq(users.columns.active, true)));
559
+ * }
560
+ * ```
561
+ */
562
+ export function buildFilterExpression(
563
+ tableOrEntity: TableDef | EntityConstructor,
564
+ where?: WhereInput<TableDef | EntityConstructor> | null
565
+ ): ExpressionNode | null {
566
+ if (!where) {
567
+ return null;
568
+ }
569
+
570
+ const table = isEntityConstructor(tableOrEntity)
571
+ ? getTableDefFromEntity(tableOrEntity)
572
+ : tableOrEntity;
573
+
574
+ if (!table) {
575
+ return null;
576
+ }
577
+
578
+ const expressions: ExpressionNode[] = [];
579
+
580
+ for (const [fieldName, fieldFilter] of Object.entries(where)) {
581
+ if (fieldFilter === undefined || fieldFilter === null) {
582
+ continue;
583
+ }
584
+
585
+ const column = table.columns[fieldName as keyof typeof table.columns];
586
+ if (column) {
587
+ const expr = buildFieldExpression(column, fieldFilter as FilterValue);
588
+ if (expr) {
589
+ expressions.push(expr);
590
+ }
591
+ } else if (table.relations && fieldName in table.relations) {
592
+ const relationExpr = buildRelationFilterExpression(
593
+ table,
594
+ fieldName,
595
+ fieldFilter as RelationFilter
596
+ );
597
+ if (relationExpr) {
598
+ expressions.push(relationExpr);
599
+ }
600
+ }
601
+ }
602
+
603
+ if (expressions.length === 0) {
604
+ return null;
605
+ }
606
+
607
+ if (expressions.length === 1) {
608
+ return expressions[0];
609
+ }
610
+
611
+ return and(...expressions);
612
+ }