@loomcore/api 0.2.69 → 0.2.70

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 (41) hide show
  1. package/dist/databases/models/classified-filter-key.d.ts +8 -0
  2. package/dist/databases/models/classified-filter-key.js +1 -0
  3. package/dist/databases/models/index.d.ts +2 -0
  4. package/dist/databases/models/index.js +2 -0
  5. package/dist/databases/models/join-filter.interface.d.ts +8 -0
  6. package/dist/databases/models/join-filter.interface.js +1 -0
  7. package/dist/databases/mongo-db/queries/mongo-get-by-id.query.js +11 -4
  8. package/dist/databases/mongo-db/queries/mongo-get.query.js +11 -3
  9. package/dist/databases/mongo-db/utils/build-join-match.util.d.ts +4 -0
  10. package/dist/databases/mongo-db/utils/build-join-match.util.js +192 -0
  11. package/dist/databases/mongo-db/utils/convert-operations-to-pipeline.util.js +15 -5
  12. package/dist/databases/operations/index.d.ts +1 -0
  13. package/dist/databases/operations/index.js +1 -0
  14. package/dist/databases/operations/join-operation.d.ts +4 -0
  15. package/dist/databases/operations/join-operation.js +1 -0
  16. package/dist/databases/postgres/commands/postgres-batch-update.command.js +2 -3
  17. package/dist/databases/postgres/commands/postgres-delete-many.command.js +1 -1
  18. package/dist/databases/postgres/commands/postgres-full-update-by-id.command.js +1 -3
  19. package/dist/databases/postgres/commands/postgres-partial-update-by-id.command.js +1 -3
  20. package/dist/databases/postgres/commands/postgres-update.command.js +4 -7
  21. package/dist/databases/postgres/queries/postgres-find-one.query.js +2 -2
  22. package/dist/databases/postgres/queries/postgres-find.query.js +2 -2
  23. package/dist/databases/postgres/queries/postgres-get-all.query.js +1 -3
  24. package/dist/databases/postgres/queries/postgres-get-by-id.query.js +2 -5
  25. package/dist/databases/postgres/queries/postgres-get.query.js +6 -11
  26. package/dist/databases/postgres/utils/build-count-query.d.ts +6 -1
  27. package/dist/databases/postgres/utils/build-count-query.js +11 -3
  28. package/dist/databases/postgres/utils/build-join-clauses.d.ts +2 -4
  29. package/dist/databases/postgres/utils/build-join-clauses.js +28 -35
  30. package/dist/databases/postgres/utils/build-join-where.d.ts +9 -0
  31. package/dist/databases/postgres/utils/build-join-where.js +136 -0
  32. package/dist/databases/postgres/utils/build-order-by-clause.d.ts +1 -4
  33. package/dist/databases/postgres/utils/build-order-by-clause.js +2 -3
  34. package/dist/databases/postgres/utils/build-select-clause.js +2 -4
  35. package/dist/databases/postgres/utils/build-where-clause.d.ts +2 -1
  36. package/dist/databases/postgres/utils/build-where-clause.js +18 -70
  37. package/dist/databases/utils/is-join-operation.util.d.ts +3 -0
  38. package/dist/databases/utils/is-join-operation.util.js +6 -0
  39. package/dist/databases/utils/resolve-join-filter.d.ts +13 -0
  40. package/dist/databases/utils/resolve-join-filter.js +75 -0
  41. package/package.json +1 -1
@@ -0,0 +1,8 @@
1
+ export type ClassifiedFilterKey = {
2
+ type: 'root';
3
+ } | {
4
+ type: 'join';
5
+ aliases: string[];
6
+ column: string;
7
+ crossesMany: boolean;
8
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -1,5 +1,7 @@
1
+ export * from './classified-filter-key.js';
1
2
  export * from './constants.js';
2
3
  export * from './database.interface.js';
3
4
  export * from './database.js';
4
5
  export * from './delete-result.js';
5
6
  export * from './update-result.js';
7
+ export * from './join-filter.interface.js';
@@ -1,5 +1,7 @@
1
+ export * from './classified-filter-key.js';
1
2
  export * from './constants.js';
2
3
  export * from './database.interface.js';
3
4
  export * from './database.js';
4
5
  export * from './delete-result.js';
5
6
  export * from './update-result.js';
7
+ export * from './join-filter.interface.js';
@@ -0,0 +1,8 @@
1
+ import type { Filter } from '@loomcore/common/models';
2
+ export interface JoinFilter {
3
+ key: string;
4
+ filter: Filter;
5
+ aliases: string[];
6
+ column: string;
7
+ crossesMany: boolean;
8
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,6 +1,8 @@
1
1
  import { entityUtils } from '@loomcore/common/utils';
2
2
  import { BadRequestError } from '../../../errors/index.js';
3
+ import { splitFiltersByType } from '../../utils/resolve-join-filter.js';
3
4
  import NoSqlPipeline from '../models/no-sql-pipeline.js';
5
+ import { buildJoinMatchStage } from '../utils/build-join-match.util.js';
4
6
  export async function getById(db, operations, queryObject, id, pluralResourceName) {
5
7
  if (!entityUtils.isValidObjectId(id)) {
6
8
  throw new BadRequestError('id is not a valid ObjectId');
@@ -8,10 +10,15 @@ export async function getById(db, operations, queryObject, id, pluralResourceNam
8
10
  const collection = db.collection(pluralResourceName);
9
11
  queryObject.filters || (queryObject.filters = {});
10
12
  queryObject.filters._id = { eq: id };
11
- const pipeline = new NoSqlPipeline()
12
- .addMatch(queryObject)
13
- .addOperations(operations)
14
- .build();
13
+ const { rootOptions, joinFilters } = splitFiltersByType(queryObject, operations, pluralResourceName);
14
+ const pipelineBuilder = new NoSqlPipeline()
15
+ .addMatch(rootOptions)
16
+ .addOperations(operations);
17
+ const joinMatch = buildJoinMatchStage(joinFilters, operations);
18
+ if (joinMatch) {
19
+ pipelineBuilder.addStage(joinMatch);
20
+ }
21
+ const pipeline = pipelineBuilder.build();
15
22
  const entity = await collection.aggregate(pipeline).next();
16
23
  return entity;
17
24
  }
@@ -1,10 +1,18 @@
1
+ import { splitFiltersByType } from '../../utils/resolve-join-filter.js';
1
2
  import NoSqlPipeline from '../models/no-sql-pipeline.js';
3
+ import { buildJoinMatchStage } from '../utils/build-join-match.util.js';
2
4
  import { apiUtils } from '../../../utils/api.utils.js';
3
5
  export async function get(db, operations, queryOptions, modelSpec, pluralResourceName) {
4
6
  const collection = db.collection(pluralResourceName);
5
- const pipeline = new NoSqlPipeline()
6
- .addMatch(queryOptions, modelSpec)
7
- .addOperations(operations)
7
+ const { rootOptions, joinFilters } = splitFiltersByType(queryOptions, operations, pluralResourceName);
8
+ const pipelineBuilder = new NoSqlPipeline()
9
+ .addMatch(rootOptions, modelSpec)
10
+ .addOperations(operations);
11
+ const joinMatch = buildJoinMatchStage(joinFilters, operations);
12
+ if (joinMatch) {
13
+ pipelineBuilder.addStage(joinMatch);
14
+ }
15
+ const pipeline = pipelineBuilder
8
16
  .addQueryOptions(queryOptions, true)
9
17
  .build();
10
18
  const cursor = collection.aggregate(pipeline);
@@ -0,0 +1,4 @@
1
+ import { Document } from 'mongodb';
2
+ import type { Operation } from '../../operations/operation.js';
3
+ import { JoinFilter } from '../../models/join-filter.interface.js';
4
+ export declare function buildJoinMatchStage(joinFilters: JoinFilter[], operations: Operation[]): Document | null;
@@ -0,0 +1,192 @@
1
+ import { entityUtils } from '@loomcore/common/utils';
2
+ import { ObjectId } from 'mongodb';
3
+ import { LeftJoinMany } from '../../operations/left-join-many.operation.js';
4
+ import { PROPERTIES_THAT_ARE_NOT_OBJECT_IDS } from '../../models/constants.js';
5
+ import { findJoinOperation } from '../../utils/resolve-join-filter.js';
6
+ function shouldConvertEqToObjectId(column) {
7
+ if (PROPERTIES_THAT_ARE_NOT_OBJECT_IDS.includes(column)) {
8
+ return false;
9
+ }
10
+ return column === '_id' || column.endsWith('Id');
11
+ }
12
+ function shouldConvertInToObjectIds(column) {
13
+ if (PROPERTIES_THAT_ARE_NOT_OBJECT_IDS.includes(column)) {
14
+ return false;
15
+ }
16
+ return column === '_id' || column.endsWith('Id') || column.endsWith('Ids');
17
+ }
18
+ function convertEqValue(column, value) {
19
+ if (shouldConvertEqToObjectId(column) && typeof value === 'string' && entityUtils.isValidObjectId(value)) {
20
+ return new ObjectId(value);
21
+ }
22
+ if (typeof value === 'string' && !isNaN(Number(value))) {
23
+ return Number(value);
24
+ }
25
+ return value;
26
+ }
27
+ function convertInValues(column, values) {
28
+ if (shouldConvertInToObjectIds(column)) {
29
+ const objectIds = values
30
+ .filter(val => typeof val === 'string' && entityUtils.isValidObjectId(val))
31
+ .map(val => new ObjectId(val));
32
+ if (objectIds.length === 0) {
33
+ return { values: [], dropped: true };
34
+ }
35
+ return { values: objectIds, dropped: false };
36
+ }
37
+ return {
38
+ values: values.map(val => typeof val === 'string' && !isNaN(Number(val)) ? Number(val) : val),
39
+ dropped: false,
40
+ };
41
+ }
42
+ function compareExpression(fieldExpr, column, filter) {
43
+ const parts = [];
44
+ if (filter.eq !== undefined) {
45
+ parts.push({ $eq: [fieldExpr, convertEqValue(column, filter.eq)] });
46
+ }
47
+ if (filter.ne !== undefined) {
48
+ parts.push({ $ne: [fieldExpr, convertEqValue(column, filter.ne)] });
49
+ }
50
+ if (filter.in !== undefined && Array.isArray(filter.in)) {
51
+ const converted = convertInValues(column, filter.in);
52
+ if (!converted.dropped) {
53
+ parts.push({ $in: [fieldExpr, converted.values] });
54
+ }
55
+ }
56
+ if (filter.gte !== undefined) {
57
+ parts.push({ $gte: [fieldExpr, filter.gte] });
58
+ }
59
+ if (filter.lte !== undefined) {
60
+ parts.push({ $lte: [fieldExpr, filter.lte] });
61
+ }
62
+ if (filter.gt !== undefined) {
63
+ parts.push({ $gt: [fieldExpr, filter.gt] });
64
+ }
65
+ if (filter.lt !== undefined) {
66
+ parts.push({ $lt: [fieldExpr, filter.lt] });
67
+ }
68
+ if (filter.contains !== undefined) {
69
+ parts.push({
70
+ $cond: [
71
+ { $eq: [{ $type: fieldExpr }, 'string'] },
72
+ { $regexMatch: { input: fieldExpr, regex: filter.contains, options: 'i' } },
73
+ false,
74
+ ],
75
+ });
76
+ }
77
+ if (parts.length === 0) {
78
+ return null;
79
+ }
80
+ if (parts.length === 1) {
81
+ return parts[0];
82
+ }
83
+ return { $and: parts };
84
+ }
85
+ function scalarAssignment(column, filter) {
86
+ const ops = {};
87
+ if (filter.eq !== undefined) {
88
+ ops.$eq = convertEqValue(column, filter.eq);
89
+ }
90
+ if (filter.ne !== undefined) {
91
+ ops.$ne = convertEqValue(column, filter.ne);
92
+ }
93
+ if (filter.in !== undefined && Array.isArray(filter.in)) {
94
+ const converted = convertInValues(column, filter.in);
95
+ if (!converted.dropped) {
96
+ ops.$in = converted.values;
97
+ }
98
+ }
99
+ if (filter.gte !== undefined) {
100
+ ops.$gte = filter.gte;
101
+ }
102
+ if (filter.lte !== undefined) {
103
+ ops.$lte = filter.lte;
104
+ }
105
+ if (filter.gt !== undefined) {
106
+ ops.$gt = filter.gt;
107
+ }
108
+ if (filter.lt !== undefined) {
109
+ ops.$lt = filter.lt;
110
+ }
111
+ if (filter.contains !== undefined) {
112
+ ops.$regex = filter.contains;
113
+ ops.$options = 'i';
114
+ }
115
+ const keys = Object.keys(ops);
116
+ if (keys.length === 0) {
117
+ return undefined;
118
+ }
119
+ if (keys.length === 1 && keys[0] === '$eq') {
120
+ return ops.$eq;
121
+ }
122
+ return ops;
123
+ }
124
+ function anyMatchExpression(joinFilter, operations) {
125
+ const walk = (scope, index) => {
126
+ const alias = joinFilter.aliases[index];
127
+ const operation = findJoinOperation(operations, alias);
128
+ if (!operation) {
129
+ return null;
130
+ }
131
+ const here = scope ? `${scope}.${alias}` : `$_joinData.${alias}`;
132
+ const isLast = index === joinFilter.aliases.length - 1;
133
+ if (operation instanceof LeftJoinMany) {
134
+ const varName = `j${index}`;
135
+ const cond = isLast
136
+ ? compareExpression(`$$${varName}.${joinFilter.column}`, joinFilter.column, joinFilter.filter)
137
+ : walk(`$$${varName}`, index + 1);
138
+ if (!cond) {
139
+ return null;
140
+ }
141
+ return {
142
+ $gt: [
143
+ {
144
+ $size: {
145
+ $filter: {
146
+ input: { $ifNull: [here, []] },
147
+ as: varName,
148
+ cond,
149
+ },
150
+ },
151
+ },
152
+ 0,
153
+ ],
154
+ };
155
+ }
156
+ if (isLast) {
157
+ return compareExpression(`${here}.${joinFilter.column}`, joinFilter.column, joinFilter.filter);
158
+ }
159
+ return walk(here, index + 1);
160
+ };
161
+ return walk('', 0);
162
+ }
163
+ export function buildJoinMatchStage(joinFilters, operations) {
164
+ const match = {};
165
+ const expressions = [];
166
+ for (const joinFilter of joinFilters) {
167
+ if (joinFilter.crossesMany) {
168
+ const expression = anyMatchExpression(joinFilter, operations);
169
+ if (expression) {
170
+ expressions.push(expression);
171
+ }
172
+ continue;
173
+ }
174
+ const assignment = scalarAssignment(joinFilter.column, joinFilter.filter);
175
+ if (assignment !== undefined) {
176
+ match[`_joinData.${joinFilter.aliases.join('.')}.${joinFilter.column}`] = assignment;
177
+ }
178
+ if (joinFilter.filter.ne !== undefined) {
179
+ match[`_joinData.${joinFilter.aliases.join('.')}`] = { $ne: null };
180
+ }
181
+ }
182
+ if (expressions.length === 1) {
183
+ match.$expr = expressions[0];
184
+ }
185
+ else if (expressions.length > 1) {
186
+ match.$expr = { $and: expressions };
187
+ }
188
+ if (Object.keys(match).length === 0) {
189
+ return null;
190
+ }
191
+ return { $match: match };
192
+ }
@@ -13,17 +13,27 @@ function resolveLocalFieldPath(localField, processedOperations) {
13
13
  }
14
14
  return localField;
15
15
  }
16
+ function innerJoinDropsDocument(operation, processedOperations) {
17
+ if (!(operation instanceof InnerJoin)) {
18
+ return false;
19
+ }
20
+ const dot = operation.localField.indexOf('.');
21
+ if (dot === -1) {
22
+ return true;
23
+ }
24
+ const parentAlias = operation.localField.slice(0, dot);
25
+ const parent = processedOperations.find(op => (op instanceof LeftJoin || op instanceof InnerJoin || op instanceof LeftJoinMany) && op.as === parentAlias);
26
+ return !(parent instanceof LeftJoinMany);
27
+ }
16
28
  export function convertOperationsToPipeline(operations) {
17
29
  let pipeline = [];
18
30
  const processedOperations = [];
19
- const joinAliases = operations
20
- .filter(op => op instanceof LeftJoin || op instanceof InnerJoin || op instanceof LeftJoinMany)
21
- .map(op => op.as);
22
31
  operations.forEach(operation => {
23
32
  if (operation instanceof LeftJoin || operation instanceof InnerJoin) {
24
33
  const needsObjectIdConversion = operation.foreignField === '_id';
25
34
  const isNestedField = operation.localField.includes('.');
26
35
  const resolvedLocalField = resolveLocalFieldPath(operation.localField, processedOperations);
36
+ const preserveMissingMatch = !innerJoinDropsDocument(operation, processedOperations);
27
37
  if (needsObjectIdConversion) {
28
38
  pipeline.push({
29
39
  $lookup: {
@@ -51,7 +61,7 @@ export function convertOperationsToPipeline(operations) {
51
61
  }, {
52
62
  $unwind: {
53
63
  path: `$${operation.as}Arr`,
54
- preserveNullAndEmptyArrays: true
64
+ preserveNullAndEmptyArrays: preserveMissingMatch
55
65
  }
56
66
  }, {
57
67
  $addFields: {
@@ -79,7 +89,7 @@ export function convertOperationsToPipeline(operations) {
79
89
  }, {
80
90
  $unwind: {
81
91
  path: `$${operation.as}Arr`,
82
- preserveNullAndEmptyArrays: true
92
+ preserveNullAndEmptyArrays: preserveMissingMatch
83
93
  }
84
94
  }, {
85
95
  $addFields: {
@@ -1,4 +1,5 @@
1
1
  export * from './operation.js';
2
+ export * from './join-operation.js';
2
3
  export * from './left-join.operation.js';
3
4
  export * from './inner-join.operation.js';
4
5
  export * from './left-join-many.operation.js';
@@ -1,4 +1,5 @@
1
1
  export * from './operation.js';
2
+ export * from './join-operation.js';
2
3
  export * from './left-join.operation.js';
3
4
  export * from './inner-join.operation.js';
4
5
  export * from './left-join-many.operation.js';
@@ -0,0 +1,4 @@
1
+ import { LeftJoin } from "./left-join.operation.js";
2
+ import { InnerJoin } from "./inner-join.operation.js";
3
+ import { LeftJoinMany } from "./left-join-many.operation.js";
4
+ export type JoinOperation = LeftJoin | InnerJoin | LeftJoinMany;
@@ -0,0 +1 @@
1
+ export {};
@@ -39,7 +39,7 @@ export async function batchUpdate(connection, entities, operations, queryObject,
39
39
  const { columns, values } = columnsAndValuesFromEntity(updateData);
40
40
  const setClause = columns.map((col, index) => `${col} = $${index + 1}`).join(', ');
41
41
  queryObject.filters._id = { eq: _id };
42
- const { whereClause } = buildWhereClause(queryObject, values);
42
+ const { whereClause } = buildWhereClause(queryObject, pluralResourceName, values);
43
43
  const query = `
44
44
  UPDATE "${pluralResourceName}"
45
45
  SET ${setClause}
@@ -50,9 +50,8 @@ export async function batchUpdate(connection, entities, operations, queryObject,
50
50
  await session.query('COMMIT');
51
51
  const joinClauses = buildJoinClauses(operations, pluralResourceName);
52
52
  const hasJoins = operations.some(op => op instanceof LeftJoin || op instanceof InnerJoin || op instanceof LeftJoinMany);
53
- const tablePrefix = hasJoins ? pluralResourceName : undefined;
54
53
  queryObject.filters._id = { in: entityIds };
55
- const { whereClause, values } = buildWhereClause(queryObject, [], tablePrefix);
54
+ const { whereClause, values } = buildWhereClause(queryObject, pluralResourceName);
56
55
  const selectClause = hasJoins
57
56
  ? await buildSelectClause(session, pluralResourceName, operations)
58
57
  : '*';
@@ -2,7 +2,7 @@ import { buildWhereClause } from '../utils/build-where-clause.js';
2
2
  import { DeleteResult } from '../../models/delete-result.js';
3
3
  export async function deleteMany(client, queryObject, pluralResourceName) {
4
4
  try {
5
- const { whereClause, values } = buildWhereClause(queryObject);
5
+ const { whereClause, values } = buildWhereClause(queryObject, pluralResourceName);
6
6
  const query = `DELETE FROM "${pluralResourceName}" ${whereClause}`;
7
7
  const result = await client.query(query, values);
8
8
  return new DeleteResult(true, result.rowCount || 0);
@@ -54,9 +54,7 @@ export async function fullUpdateById(client, operations, id, entity, pluralResou
54
54
  throw new IdNotFoundError();
55
55
  }
56
56
  const hasJoins = operations.some(op => op instanceof LeftJoin || op instanceof InnerJoin || op instanceof LeftJoinMany);
57
- const joinClauses = hasJoins
58
- ? buildJoinClauses(operations, pluralResourceName, { oneToOneOnly: true })
59
- : buildJoinClauses(operations, pluralResourceName);
57
+ const joinClauses = buildJoinClauses(operations, pluralResourceName);
60
58
  const selectClause = hasJoins
61
59
  ? await buildSelectClause(client, pluralResourceName, operations)
62
60
  : '*';
@@ -24,9 +24,7 @@ export async function partialUpdateById(client, operations, id, entity, pluralRe
24
24
  throw new IdNotFoundError();
25
25
  }
26
26
  const hasJoins = operations.some(op => op instanceof LeftJoin || op instanceof InnerJoin || op instanceof LeftJoinMany);
27
- const joinClauses = hasJoins
28
- ? buildJoinClauses(operations, pluralResourceName, { oneToOneOnly: true })
29
- : buildJoinClauses(operations, pluralResourceName);
27
+ const joinClauses = buildJoinClauses(operations, pluralResourceName);
30
28
  const selectClause = hasJoins
31
29
  ? await buildSelectClause(client, pluralResourceName, operations)
32
30
  : '*';
@@ -9,7 +9,7 @@ import { buildSelectClause } from '../utils/build-select-clause.js';
9
9
  import { columnsAndValuesFromEntity } from '../utils/columns-and-values-from-entity.js';
10
10
  export async function update(client, queryObject, entity, operations, pluralResourceName) {
11
11
  try {
12
- const { whereClause, values: whereValues } = buildWhereClause(queryObject);
12
+ const { whereClause, values: whereValues } = buildWhereClause(queryObject, pluralResourceName);
13
13
  const { columns, values: entityValues } = columnsAndValuesFromEntity(entity);
14
14
  const updateColumns = columns.filter(col => col !== '"_id"');
15
15
  const updateValues = entityValues.filter((_, index) => columns[index] !== '"_id"');
@@ -32,12 +32,9 @@ export async function update(client, queryObject, entity, operations, pluralReso
32
32
  throw new NotFoundError('No records found matching update query');
33
33
  }
34
34
  const hasJoins = operations.some(op => op instanceof LeftJoin || op instanceof InnerJoin || op instanceof LeftJoinMany);
35
- const joinClauses = hasJoins
36
- ? buildJoinClauses(operations, pluralResourceName, { oneToOneOnly: true })
37
- : buildJoinClauses(operations, pluralResourceName);
38
- const orderByClause = buildOrderByClause(queryObject, hasJoins ? { tablePrefix: pluralResourceName } : undefined);
39
- const tablePrefix = hasJoins ? pluralResourceName : undefined;
40
- const { whereClause: selectWhereClause, values: selectWhereValues } = buildWhereClause(queryObject, [], tablePrefix);
35
+ const joinClauses = buildJoinClauses(operations, pluralResourceName);
36
+ const orderByClause = buildOrderByClause(queryObject, pluralResourceName);
37
+ const { whereClause: selectWhereClause, values: selectWhereValues } = buildWhereClause(queryObject, pluralResourceName);
41
38
  const selectClause = hasJoins
42
39
  ? await buildSelectClause(client, pluralResourceName, operations)
43
40
  : '*';
@@ -1,8 +1,8 @@
1
1
  import { buildWhereClause } from '../utils/build-where-clause.js';
2
2
  import { buildOrderByClause } from '../utils/build-order-by-clause.js';
3
3
  export async function findOne(client, queryObject, pluralResourceName) {
4
- const { whereClause, values } = buildWhereClause(queryObject);
5
- const orderByClause = buildOrderByClause(queryObject);
4
+ const { whereClause, values } = buildWhereClause(queryObject, pluralResourceName);
5
+ const orderByClause = buildOrderByClause(queryObject, pluralResourceName);
6
6
  const query = `SELECT * FROM "${pluralResourceName}" ${whereClause} ${orderByClause} LIMIT 1`;
7
7
  const result = await client.query(query, values);
8
8
  if (result.rows.length === 0) {
@@ -2,8 +2,8 @@ import { buildWhereClause } from '../utils/build-where-clause.js';
2
2
  import { buildOrderByClause } from '../utils/build-order-by-clause.js';
3
3
  import { buildPaginationClause } from '../utils/build-pagination-clause.js';
4
4
  export async function find(client, queryObject, pluralResourceName) {
5
- const { whereClause, values } = buildWhereClause(queryObject);
6
- const orderByClause = buildOrderByClause(queryObject);
5
+ const { whereClause, values } = buildWhereClause(queryObject, pluralResourceName);
6
+ const orderByClause = buildOrderByClause(queryObject, pluralResourceName);
7
7
  const paginationClause = buildPaginationClause(queryObject);
8
8
  const query = `SELECT * FROM "${pluralResourceName}" ${whereClause} ${orderByClause} ${paginationClause}`.trim();
9
9
  const result = await client.query(query, values);
@@ -5,9 +5,7 @@ import { buildJoinClauses } from '../utils/build-join-clauses.js';
5
5
  import { buildSelectClause } from '../utils/build-select-clause.js';
6
6
  export async function getAll(client, operations, pluralResourceName) {
7
7
  const hasJoins = operations.some(op => op instanceof LeftJoin || op instanceof InnerJoin || op instanceof LeftJoinMany);
8
- const joinClauses = hasJoins
9
- ? buildJoinClauses(operations, pluralResourceName, { oneToOneOnly: true })
10
- : buildJoinClauses(operations, pluralResourceName);
8
+ const joinClauses = buildJoinClauses(operations, pluralResourceName);
11
9
  const selectClause = hasJoins
12
10
  ? await buildSelectClause(client, pluralResourceName, operations)
13
11
  : '*';
@@ -9,16 +9,13 @@ export async function getById(client, operations, queryObject, id, pluralResourc
9
9
  if (!id)
10
10
  throw new BadRequestError('id is required');
11
11
  const hasJoins = operations.some(op => op instanceof LeftJoin || op instanceof InnerJoin || op instanceof LeftJoinMany);
12
- const joinClauses = hasJoins
13
- ? buildJoinClauses(operations, pluralResourceName, { oneToOneOnly: true })
14
- : buildJoinClauses(operations, pluralResourceName);
12
+ const joinClauses = buildJoinClauses(operations, pluralResourceName);
15
13
  const selectClause = hasJoins
16
14
  ? await buildSelectClause(client, pluralResourceName, operations)
17
15
  : '*';
18
16
  queryObject.filters || (queryObject.filters = {});
19
17
  queryObject.filters._id = { eq: id };
20
- const tablePrefix = hasJoins ? pluralResourceName : undefined;
21
- const { whereClause, values } = buildWhereClause(queryObject, [], tablePrefix);
18
+ const { whereClause, values } = buildWhereClause(queryObject, pluralResourceName, [], operations);
22
19
  const query = `SELECT ${selectClause} FROM "${pluralResourceName}" ${joinClauses} ${whereClause} LIMIT 1`;
23
20
  const result = await client.query(query, values);
24
21
  if (result.rows.length === 0) {
@@ -1,6 +1,3 @@
1
- import { LeftJoin } from '../../operations/left-join.operation.js';
2
- import { InnerJoin } from '../../operations/inner-join.operation.js';
3
- import { LeftJoinMany } from '../../operations/left-join-many.operation.js';
4
1
  import { buildWhereClause } from '../utils/build-where-clause.js';
5
2
  import { buildOrderByClause } from '../utils/build-order-by-clause.js';
6
3
  import { buildJoinClauses } from '../utils/build-join-clauses.js';
@@ -8,20 +5,18 @@ import { buildSelectClause } from '../utils/build-select-clause.js';
8
5
  import { executeCountQuery } from '../utils/build-count-query.js';
9
6
  import { apiUtils } from '../../../utils/api.utils.js';
10
7
  import { buildPaginationClause } from '../utils/build-pagination-clause.js';
8
+ import { isJoinOperation } from '../../utils/is-join-operation.util.js';
11
9
  export async function get(client, operations, queryOptions, pluralResourceName) {
12
- const hasJoins = operations.some(op => op instanceof LeftJoin || op instanceof InnerJoin || op instanceof LeftJoinMany);
13
- const joinClauses = hasJoins
14
- ? buildJoinClauses(operations, pluralResourceName, { oneToOneOnly: true })
15
- : buildJoinClauses(operations, pluralResourceName);
16
- const orderByClause = buildOrderByClause(queryOptions, hasJoins ? { tablePrefix: pluralResourceName } : undefined);
10
+ const hasJoins = operations.some(isJoinOperation);
11
+ const joinClauses = buildJoinClauses(operations, pluralResourceName);
12
+ const orderByClause = buildOrderByClause(queryOptions, pluralResourceName);
17
13
  const paginationClause = buildPaginationClause(queryOptions);
18
14
  const selectClause = hasJoins
19
15
  ? await buildSelectClause(client, pluralResourceName, operations)
20
16
  : '*';
21
- const tablePrefix = hasJoins ? pluralResourceName : undefined;
22
- const { whereClause, values } = buildWhereClause(queryOptions, [], tablePrefix);
17
+ const { whereClause, values } = buildWhereClause(queryOptions, pluralResourceName, [], operations);
23
18
  const baseQuery = `SELECT ${selectClause} FROM "${pluralResourceName}" ${joinClauses} ${whereClause}`;
24
- const total = await executeCountQuery(client, pluralResourceName, queryOptions);
19
+ const total = await executeCountQuery(client, pluralResourceName, queryOptions, operations);
25
20
  const dataQuery = `${baseQuery} ${orderByClause} ${paginationClause}`.trim();
26
21
  const dataResult = await client.query(dataQuery, values);
27
22
  const entities = hasJoins
@@ -1,3 +1,8 @@
1
1
  import type { PostgresConnection } from '../postgres-connection.js';
2
2
  import { IQueryOptions } from '@loomcore/common/models';
3
- export declare function executeCountQuery(client: PostgresConnection, pluralResourceName: string, queryOptions?: IQueryOptions): Promise<number>;
3
+ import type { Operation } from '../../operations/operation.js';
4
+ export declare function buildCountQuerySql(pluralResourceName: string, queryOptions?: IQueryOptions, operations?: Operation[]): {
5
+ countQuery: string;
6
+ values: any[];
7
+ };
8
+ export declare function executeCountQuery(client: PostgresConnection, pluralResourceName: string, queryOptions?: IQueryOptions, operations?: Operation[]): Promise<number>;
@@ -1,7 +1,15 @@
1
+ import { buildJoinClauses } from './build-join-clauses.js';
1
2
  import { buildWhereClause } from './build-where-clause.js';
2
- export async function executeCountQuery(client, pluralResourceName, queryOptions = {}) {
3
- const { whereClause, values } = buildWhereClause(queryOptions);
4
- const countQuery = `SELECT COUNT(*) as total FROM "${pluralResourceName}" ${whereClause}`;
3
+ export function buildCountQuerySql(pluralResourceName, queryOptions = {}, operations = []) {
4
+ const joinClauses = buildJoinClauses(operations, pluralResourceName);
5
+ const { whereClause, values } = buildWhereClause(queryOptions, pluralResourceName, [], operations);
6
+ const countQuery = ['SELECT COUNT(*) as total', `FROM "${pluralResourceName}"`, joinClauses, whereClause]
7
+ .filter(part => part.trim().length > 0)
8
+ .join(' ');
9
+ return { countQuery, values };
10
+ }
11
+ export async function executeCountQuery(client, pluralResourceName, queryOptions = {}, operations = []) {
12
+ const { countQuery, values } = buildCountQuerySql(pluralResourceName, queryOptions, operations);
5
13
  const countResult = await client.query(countQuery, values);
6
14
  return parseInt(countResult.rows[0].total, 10);
7
15
  }
@@ -1,5 +1,3 @@
1
1
  import { Operation } from '../../operations/operation.js';
2
- export interface BuildJoinClausesOptions {
3
- oneToOneOnly?: boolean;
4
- }
5
- export declare function buildJoinClauses(operations: Operation[], mainTableName?: string, options?: BuildJoinClausesOptions): string;
2
+ export declare function collectOuterOneToOneAliases(operations: Operation[], mainTableName: string): Set<string>;
3
+ export declare function buildJoinClauses(operations: Operation[], mainTableName: string): string;
@@ -1,54 +1,47 @@
1
1
  import { LeftJoin } from '../../operations/left-join.operation.js';
2
2
  import { InnerJoin } from '../../operations/inner-join.operation.js';
3
- import { LeftJoinMany } from '../../operations/left-join-many.operation.js';
4
3
  import { toPostgresStoreName } from './convert-keys.util.js';
5
4
  function getParentAlias(localField) {
6
5
  if (!localField.includes('.'))
7
6
  return null;
8
7
  return localField.split('.')[0];
9
8
  }
10
- function getThroughTableAliases(operations) {
11
- const through = new Set();
12
- for (const op of operations) {
13
- if (op instanceof LeftJoinMany && op.localField.includes('.')) {
14
- through.add(op.localField.split('.')[0]);
9
+ export function collectOuterOneToOneAliases(operations, mainTableName) {
10
+ const aliases = new Set();
11
+ for (const operation of operations) {
12
+ if (!(operation instanceof LeftJoin) && !(operation instanceof InnerJoin)) {
13
+ continue;
14
+ }
15
+ const parent = getParentAlias(operation.localField);
16
+ const parentInScope = parent === null || parent === mainTableName || aliases.has(parent);
17
+ if (!parentInScope) {
18
+ continue;
15
19
  }
20
+ aliases.add(operation.as);
16
21
  }
17
- return through;
22
+ return aliases;
18
23
  }
19
- export function buildJoinClauses(operations, mainTableName, options) {
20
- const oneToOneOnly = options?.oneToOneOnly ?? false;
21
- const throughTableAliases = oneToOneOnly ? getThroughTableAliases(operations) : new Set();
24
+ export function buildJoinClauses(operations, mainTableName) {
22
25
  const joinClauses = [];
23
- const oneToOneAliasesInScope = new Set();
26
+ const oneToOneAliasesInScope = collectOuterOneToOneAliases(operations, mainTableName);
24
27
  for (const operation of operations) {
25
- if (operation instanceof LeftJoinMany) {
26
- if (oneToOneOnly)
27
- continue;
28
+ if (!(operation instanceof LeftJoin) && !(operation instanceof InnerJoin)) {
29
+ continue;
30
+ }
31
+ if (!oneToOneAliasesInScope.has(operation.as)) {
32
+ continue;
28
33
  }
29
- else if (operation instanceof LeftJoin || operation instanceof InnerJoin) {
30
- if (oneToOneOnly) {
31
- const parent = getParentAlias(operation.localField);
32
- const parentInScope = parent === null || parent === mainTableName || oneToOneAliasesInScope.has(parent);
33
- if (!parentInScope)
34
- continue;
35
- oneToOneAliasesInScope.add(operation.as);
36
- }
34
+ const joinType = operation instanceof LeftJoin ? 'LEFT JOIN' : 'INNER JOIN';
35
+ let leftSide;
36
+ if (operation.localField.includes('.')) {
37
+ const [alias, column] = operation.localField.split('.');
38
+ leftSide = `"${alias}"."${column}"`;
37
39
  }
38
- if (operation instanceof LeftJoin || operation instanceof InnerJoin || operation instanceof LeftJoinMany) {
39
- const useLeftJoin = oneToOneOnly && operation instanceof InnerJoin && throughTableAliases.has(operation.as);
40
- const joinType = useLeftJoin || operation instanceof LeftJoin || operation instanceof LeftJoinMany ? 'LEFT JOIN' : 'INNER JOIN';
41
- let leftSide;
42
- if (operation.localField.includes('.')) {
43
- const [alias, column] = operation.localField.split('.');
44
- leftSide = `"${alias}"."${column}"`;
45
- }
46
- else {
47
- leftSide = mainTableName ? `"${mainTableName}"."${operation.localField}"` : `"${operation.localField}"`;
48
- }
49
- const fromTable = toPostgresStoreName(operation.from);
50
- joinClauses.push(`${joinType} "${fromTable}" AS "${operation.as}" ON ${leftSide} = "${operation.as}"."${operation.foreignField}"`);
40
+ else {
41
+ leftSide = `"${mainTableName}"."${operation.localField}"`;
51
42
  }
43
+ const fromTable = toPostgresStoreName(operation.from);
44
+ joinClauses.push(`${joinType} "${fromTable}" AS "${operation.as}" ON ${leftSide} = "${operation.as}"."${operation.foreignField}"`);
52
45
  }
53
46
  return joinClauses.join(' ');
54
47
  }
@@ -0,0 +1,9 @@
1
+ import type { Filter } from '@loomcore/common/models';
2
+ import type { Operation } from '../../operations/operation.js';
3
+ import { JoinFilter } from '../../models/join-filter.interface.js';
4
+ export interface SqlParamIndex {
5
+ current: number;
6
+ }
7
+ export declare function toSqlColumnName(column: string): string;
8
+ export declare function appendFilterConditions(qualifiedKey: string, value: Filter, conditions: string[], values: any[], paramIndex: SqlParamIndex): void;
9
+ export declare function joinFilterSql(joinFilter: JoinFilter, operations: Operation[], mainTableName: string, values: any[], paramIndex: SqlParamIndex): string | null;
@@ -0,0 +1,136 @@
1
+ import { readFilterValue } from '../../utils/nullish-filter-value.js';
2
+ import { findJoinOperation, parentAliasOf, } from '../../utils/resolve-join-filter.js';
3
+ import { collectOuterOneToOneAliases } from './build-join-clauses.js';
4
+ import { toPostgresStoreName, toSnakeCase } from './convert-keys.util.js';
5
+ export function toSqlColumnName(column) {
6
+ if (column.startsWith('_')) {
7
+ return column;
8
+ }
9
+ if (column === 'id') {
10
+ return '_id';
11
+ }
12
+ return toSnakeCase(column);
13
+ }
14
+ function quoteIdent(value) {
15
+ return `"${value.replace(/"/g, '""')}"`;
16
+ }
17
+ export function appendFilterConditions(qualifiedKey, value, conditions, values, paramIndex) {
18
+ if (value.eq !== undefined) {
19
+ const comparison = readFilterValue(value.eq);
20
+ if (comparison.kind === 'value') {
21
+ conditions.push(`(${qualifiedKey} = $${paramIndex.current} OR (${qualifiedKey} IS NULL AND $${paramIndex.current} IS NULL))`);
22
+ values.push(comparison.value);
23
+ paramIndex.current++;
24
+ }
25
+ else {
26
+ conditions.push(`${qualifiedKey} IS NULL`);
27
+ }
28
+ }
29
+ if (value.ne !== undefined) {
30
+ const comparison = readFilterValue(value.ne);
31
+ if (comparison.kind === 'value') {
32
+ conditions.push(`(${qualifiedKey} != $${paramIndex.current} OR (${qualifiedKey} IS NOT NULL AND $${paramIndex.current} IS NULL) OR (${qualifiedKey} IS NULL AND $${paramIndex.current} IS NOT NULL))`);
33
+ values.push(comparison.value);
34
+ paramIndex.current++;
35
+ }
36
+ else {
37
+ conditions.push(`${qualifiedKey} IS NOT NULL`);
38
+ }
39
+ }
40
+ if (value.in !== undefined && Array.isArray(value.in)) {
41
+ const placeholders = value.in.map(() => `$${paramIndex.current++}`).join(', ');
42
+ conditions.push(`${qualifiedKey} IN (${placeholders})`);
43
+ values.push(...value.in);
44
+ }
45
+ if (value.gte !== undefined) {
46
+ conditions.push(`${qualifiedKey} >= $${paramIndex.current}`);
47
+ values.push(value.gte);
48
+ paramIndex.current++;
49
+ }
50
+ if (value.lte !== undefined) {
51
+ conditions.push(`${qualifiedKey} <= $${paramIndex.current}`);
52
+ values.push(value.lte);
53
+ paramIndex.current++;
54
+ }
55
+ if (value.gt !== undefined) {
56
+ conditions.push(`${qualifiedKey} > $${paramIndex.current}`);
57
+ values.push(value.gt);
58
+ paramIndex.current++;
59
+ }
60
+ if (value.lt !== undefined) {
61
+ conditions.push(`${qualifiedKey} < $${paramIndex.current}`);
62
+ values.push(value.lt);
63
+ paramIndex.current++;
64
+ }
65
+ if (value.contains !== undefined) {
66
+ conditions.push(`LOWER(${qualifiedKey}) LIKE LOWER($${paramIndex.current})`);
67
+ values.push(`%${value.contains}%`);
68
+ paramIndex.current++;
69
+ }
70
+ }
71
+ function qualifyLocalField(localField, mainTableName) {
72
+ const dot = localField.indexOf('.');
73
+ if (dot === -1) {
74
+ return mainTableName ? `${quoteIdent(mainTableName)}.${quoteIdent(localField)}` : quoteIdent(localField);
75
+ }
76
+ const alias = localField.slice(0, dot);
77
+ const column = localField.slice(dot + 1);
78
+ return `${quoteIdent(alias)}.${quoteIdent(column)}`;
79
+ }
80
+ function ancestorChain(target, operations, mainTableName, outerAliases) {
81
+ const chain = [target];
82
+ let current = target;
83
+ const seen = new Set([target.as]);
84
+ while (true) {
85
+ const parentAlias = parentAliasOf(current.localField);
86
+ if (parentAlias === null || parentAlias === mainTableName || outerAliases.has(parentAlias)) {
87
+ break;
88
+ }
89
+ if (seen.has(parentAlias)) {
90
+ break;
91
+ }
92
+ const parent = findJoinOperation(operations, parentAlias);
93
+ if (!parent) {
94
+ break;
95
+ }
96
+ seen.add(parent.as);
97
+ chain.unshift(parent);
98
+ current = parent;
99
+ }
100
+ return chain;
101
+ }
102
+ function existsSql(target, qualifiedColumn, filter, operations, mainTableName, outerAliases, values, paramIndex) {
103
+ const chain = ancestorChain(target, operations, mainTableName, outerAliases);
104
+ const first = chain[0];
105
+ let sql = `EXISTS (SELECT 1 FROM ${quoteIdent(toPostgresStoreName(first.from))} AS ${quoteIdent(first.as)}`;
106
+ for (let index = 1; index < chain.length; index++) {
107
+ const child = chain[index];
108
+ const fromTable = quoteIdent(toPostgresStoreName(child.from));
109
+ const childAlias = quoteIdent(child.as);
110
+ const onLeft = qualifyLocalField(child.localField, mainTableName);
111
+ sql += ` INNER JOIN ${fromTable} AS ${childAlias} ON ${onLeft} = ${childAlias}.${quoteIdent(child.foreignField)}`;
112
+ }
113
+ const conditions = [
114
+ `${qualifyLocalField(first.localField, mainTableName)} = ${quoteIdent(first.as)}.${quoteIdent(first.foreignField)}`,
115
+ ];
116
+ appendFilterConditions(qualifiedColumn, filter, conditions, values, paramIndex);
117
+ sql += ` WHERE ${conditions.join(' AND ')})`;
118
+ return sql;
119
+ }
120
+ export function joinFilterSql(joinFilter, operations, mainTableName, values, paramIndex) {
121
+ const target = findJoinOperation(operations, joinFilter.aliases[joinFilter.aliases.length - 1]);
122
+ if (!target) {
123
+ return null;
124
+ }
125
+ const outerAliases = collectOuterOneToOneAliases(operations, mainTableName);
126
+ const qualified = `${quoteIdent(target.as)}.${quoteIdent(toSqlColumnName(joinFilter.column))}`;
127
+ if (!joinFilter.crossesMany && outerAliases.has(target.as)) {
128
+ const conditions = [];
129
+ appendFilterConditions(qualified, joinFilter.filter, conditions, values, paramIndex);
130
+ if (joinFilter.filter.ne !== undefined) {
131
+ conditions.push(`${quoteIdent(target.as)}."_id" IS NOT NULL`);
132
+ }
133
+ return conditions.join(' AND ');
134
+ }
135
+ return existsSql(target, qualified, joinFilter.filter, operations, mainTableName, outerAliases, values, paramIndex);
136
+ }
@@ -1,5 +1,2 @@
1
1
  import { IQueryOptions } from '@loomcore/common/models';
2
- export interface BuildOrderByClauseOptions {
3
- tablePrefix?: string;
4
- }
5
- export declare function buildOrderByClause(queryObject: IQueryOptions, options?: BuildOrderByClauseOptions): string;
2
+ export declare function buildOrderByClause(queryObject: IQueryOptions, tablePrefix: string): string;
@@ -1,9 +1,8 @@
1
- export function buildOrderByClause(queryObject, options) {
1
+ export function buildOrderByClause(queryObject, tablePrefix) {
2
2
  if (!queryObject.orderBy) {
3
3
  return '';
4
4
  }
5
5
  const orderBy = queryObject.orderBy;
6
6
  const sortDirection = queryObject.sortDirection || 'asc';
7
- const qualified = options?.tablePrefix ? `"${options.tablePrefix}"."${orderBy}"` : orderBy;
8
- return `ORDER BY ${qualified} ${sortDirection}`;
7
+ return `ORDER BY "${tablePrefix}"."${orderBy}" ${sortDirection}`;
9
8
  }
@@ -2,6 +2,7 @@ import { LeftJoin } from '../../operations/left-join.operation.js';
2
2
  import { InnerJoin } from '../../operations/inner-join.operation.js';
3
3
  import { LeftJoinMany } from '../../operations/left-join-many.operation.js';
4
4
  import { toPostgresStoreName } from './convert-keys.util.js';
5
+ import { isJoinOperation } from '../../utils/is-join-operation.util.js';
5
6
  const PK = '_id';
6
7
  export async function getTableColumns(client, tableName) {
7
8
  const storeName = toPostgresStoreName(tableName);
@@ -172,10 +173,7 @@ async function buildJoinDataObject(client, operations, mainTableName) {
172
173
  return jsonbBuildObject(entries);
173
174
  }
174
175
  export async function buildSelectClause(client, mainTableName, operations) {
175
- const leftJoinOperations = operations.filter(op => op instanceof LeftJoin);
176
- const innerJoinOperations = operations.filter(op => op instanceof InnerJoin);
177
- const leftJoinManyOperations = operations.filter(op => op instanceof LeftJoinMany);
178
- const hasJoins = leftJoinOperations.length > 0 || innerJoinOperations.length > 0 || leftJoinManyOperations.length > 0;
176
+ const hasJoins = operations.some(isJoinOperation);
179
177
  if (!hasJoins) {
180
178
  return '*';
181
179
  }
@@ -1,5 +1,6 @@
1
1
  import { IQueryOptions } from '@loomcore/common/models';
2
- export declare function buildWhereClause(queryObject: IQueryOptions, values?: any[], tablePrefix?: string): {
2
+ import type { Operation } from '../../operations/operation.js';
3
+ export declare function buildWhereClause(queryObject: IQueryOptions, tablePrefix: string, values?: any[], operations?: Operation[]): {
3
4
  whereClause: string;
4
5
  values: any[];
5
6
  };
@@ -1,76 +1,24 @@
1
- import { readFilterValue } from '../../utils/nullish-filter-value.js';
2
- import { toSnakeCase } from './convert-keys.util.js';
3
- export function buildWhereClause(queryObject, values = [], tablePrefix) {
4
- const filters = queryObject.filters || {};
1
+ import { splitFiltersByType } from '../../utils/resolve-join-filter.js';
2
+ import { appendFilterConditions, joinFilterSql, toSqlColumnName } from './build-join-where.js';
3
+ export function buildWhereClause(queryObject, tablePrefix, values = [], operations) {
4
+ const useJoinFilters = operations !== undefined && operations.length > 0;
5
+ const { rootOptions, joinFilters } = useJoinFilters
6
+ ? splitFiltersByType(queryObject, operations, tablePrefix)
7
+ : { rootOptions: queryObject, joinFilters: [] };
8
+ const filters = rootOptions.filters || {};
5
9
  const conditions = [];
6
- let paramIndex = values.length + 1;
7
- const qualifyColumn = (columnName) => {
8
- return tablePrefix ? `"${tablePrefix}"."${columnName}"` : `"${columnName}"`;
9
- };
10
+ const paramIndex = { current: values.length + 1 };
10
11
  for (const [key, value] of Object.entries(filters)) {
11
12
  if (value) {
12
- let snakeKey;
13
- if (key.startsWith('_')) {
14
- snakeKey = key;
15
- }
16
- else if (key === 'id') {
17
- snakeKey = '_id';
18
- }
19
- else {
20
- snakeKey = toSnakeCase(key);
21
- }
22
- const qualifiedKey = qualifyColumn(snakeKey);
23
- if (value.eq !== undefined) {
24
- const comparison = readFilterValue(value.eq);
25
- if (comparison.kind === 'value') {
26
- conditions.push(`(${qualifiedKey} = $${paramIndex} OR (${qualifiedKey} IS NULL AND $${paramIndex} IS NULL))`);
27
- values.push(comparison.value);
28
- paramIndex++;
29
- }
30
- else {
31
- conditions.push(`${qualifiedKey} IS NULL`);
32
- }
33
- }
34
- if (value.ne !== undefined) {
35
- const comparison = readFilterValue(value.ne);
36
- if (comparison.kind === 'value') {
37
- conditions.push(`(${qualifiedKey} != $${paramIndex} OR (${qualifiedKey} IS NOT NULL AND $${paramIndex} IS NULL) OR (${qualifiedKey} IS NULL AND $${paramIndex} IS NOT NULL))`);
38
- values.push(comparison.value);
39
- paramIndex++;
40
- }
41
- else {
42
- conditions.push(`${qualifiedKey} IS NOT NULL`);
43
- }
44
- }
45
- if (value.in !== undefined && Array.isArray(value.in)) {
46
- const placeholders = value.in.map(() => `$${paramIndex++}`).join(', ');
47
- conditions.push(`${qualifiedKey} IN (${placeholders})`);
48
- values.push(...value.in);
49
- }
50
- if (value.gte !== undefined) {
51
- conditions.push(`${qualifiedKey} >= $${paramIndex}`);
52
- values.push(value.gte);
53
- paramIndex++;
54
- }
55
- if (value.lte !== undefined) {
56
- conditions.push(`${qualifiedKey} <= $${paramIndex}`);
57
- values.push(value.lte);
58
- paramIndex++;
59
- }
60
- if (value.gt !== undefined) {
61
- conditions.push(`${qualifiedKey} > $${paramIndex}`);
62
- values.push(value.gt);
63
- paramIndex++;
64
- }
65
- if (value.lt !== undefined) {
66
- conditions.push(`${qualifiedKey} < $${paramIndex}`);
67
- values.push(value.lt);
68
- paramIndex++;
69
- }
70
- if (value.contains !== undefined) {
71
- conditions.push(`LOWER(${qualifiedKey}) LIKE LOWER($${paramIndex})`);
72
- values.push(`%${value.contains}%`);
73
- paramIndex++;
13
+ const qualifiedKey = `"${tablePrefix}"."${toSqlColumnName(key)}"`;
14
+ appendFilterConditions(qualifiedKey, value, conditions, values, paramIndex);
15
+ }
16
+ }
17
+ if (useJoinFilters) {
18
+ for (const joinFilter of joinFilters) {
19
+ const predicate = joinFilterSql(joinFilter, operations, tablePrefix, values, paramIndex);
20
+ if (predicate) {
21
+ conditions.push(predicate);
74
22
  }
75
23
  }
76
24
  }
@@ -0,0 +1,3 @@
1
+ import { type JoinOperation } from "../operations/join-operation.js";
2
+ import { type Operation } from "../operations/operation.js";
3
+ export declare function isJoinOperation(operation: Operation): operation is JoinOperation;
@@ -0,0 +1,6 @@
1
+ import { InnerJoin } from "../operations/inner-join.operation.js";
2
+ import { LeftJoin } from "../operations/left-join.operation.js";
3
+ import { LeftJoinMany } from "../operations/left-join-many.operation.js";
4
+ export function isJoinOperation(operation) {
5
+ return operation instanceof LeftJoin || operation instanceof InnerJoin || operation instanceof LeftJoinMany;
6
+ }
@@ -0,0 +1,13 @@
1
+ import type { IQueryOptions } from '@loomcore/common/models';
2
+ import type { ClassifiedFilterKey } from '../models/classified-filter-key.js';
3
+ import type { Operation } from '../operations/operation.js';
4
+ import type { JoinOperation } from '../operations/join-operation.js';
5
+ import { JoinFilter } from '../models/join-filter.interface.js';
6
+ export declare function parentAliasOf(localField: string): string | null;
7
+ export declare function findJoinOperation(operations: Operation[], alias: string): JoinOperation | undefined;
8
+ export declare function logicalParentAlias(operation: JoinOperation, operations: Operation[], mainTableName: string): string | null;
9
+ export declare function classifyFilterKey(key: string, operations: Operation[], mainTableName: string): ClassifiedFilterKey;
10
+ export declare function splitFiltersByType(queryOptions: IQueryOptions, operations: Operation[], mainTableName: string): {
11
+ rootOptions: IQueryOptions;
12
+ joinFilters: JoinFilter[];
13
+ };
@@ -0,0 +1,75 @@
1
+ import { BadRequestError } from '../../errors/index.js';
2
+ import { LeftJoinMany } from '../operations/left-join-many.operation.js';
3
+ import { isJoinOperation } from './is-join-operation.util.js';
4
+ export function parentAliasOf(localField) {
5
+ const dot = localField.indexOf('.');
6
+ if (dot === -1) {
7
+ return null;
8
+ }
9
+ return localField.slice(0, dot);
10
+ }
11
+ export function findJoinOperation(operations, alias) {
12
+ return operations.find((operation) => isJoinOperation(operation) && operation.as === alias);
13
+ }
14
+ export function logicalParentAlias(operation, operations, mainTableName) {
15
+ const rawParent = parentAliasOf(operation.localField);
16
+ if (rawParent === null || rawParent === mainTableName) {
17
+ return null;
18
+ }
19
+ const parent = findJoinOperation(operations, rawParent);
20
+ return parent ? parent.as : null;
21
+ }
22
+ export function classifyFilterKey(key, operations, mainTableName) {
23
+ if (!key.includes('.') || operations.length === 0) {
24
+ return { type: 'root' };
25
+ }
26
+ const parts = key.split('.');
27
+ if (parts.some(part => part.length === 0)) {
28
+ return { type: 'root' };
29
+ }
30
+ if (!findJoinOperation(operations, parts[0])) {
31
+ return { type: 'root' };
32
+ }
33
+ const column = parts[parts.length - 1];
34
+ const aliases = parts.slice(0, -1);
35
+ for (let index = 0; index < aliases.length; index++) {
36
+ const alias = aliases[index];
37
+ const operation = findJoinOperation(operations, alias);
38
+ if (!operation) {
39
+ throw new BadRequestError(`Invalid join filter "${key}": "${alias}" is not a joined table`);
40
+ }
41
+ const expectedParent = index === 0 ? null : aliases[index - 1];
42
+ const actualParent = logicalParentAlias(operation, operations, mainTableName);
43
+ if (actualParent !== expectedParent) {
44
+ const where = actualParent ? `"${actualParent}"` : 'the root collection';
45
+ throw new BadRequestError(`Invalid join filter "${key}": "${alias}" is nested under ${where}`);
46
+ }
47
+ }
48
+ const crossesMany = aliases.some(alias => findJoinOperation(operations, alias) instanceof LeftJoinMany);
49
+ return { type: 'join', aliases, column, crossesMany };
50
+ }
51
+ export function splitFiltersByType(queryOptions, operations, mainTableName) {
52
+ const rootFilters = {};
53
+ const joinFilters = [];
54
+ for (const [key, filter] of Object.entries(queryOptions.filters ?? {})) {
55
+ if (!filter) {
56
+ continue;
57
+ }
58
+ const classified = classifyFilterKey(key, operations, mainTableName);
59
+ if (classified.type === 'root') {
60
+ rootFilters[key] = filter;
61
+ continue;
62
+ }
63
+ joinFilters.push({
64
+ key,
65
+ filter,
66
+ aliases: classified.aliases,
67
+ column: classified.column,
68
+ crossesMany: classified.crossesMany,
69
+ });
70
+ }
71
+ return {
72
+ rootOptions: { ...queryOptions, filters: rootFilters },
73
+ joinFilters,
74
+ };
75
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loomcore/api",
3
- "version": "0.2.69",
3
+ "version": "0.2.70",
4
4
  "private": false,
5
5
  "description": "Loom Core Api - An opinionated Node.js api using Typescript, Express, and MongoDb or PostgreSQL",
6
6
  "scripts": {