@graphoria/server 0.1.4 → 0.2.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/dist/src/analyzeQuery/resolveVariables.d.ts.map +1 -1
- package/dist/src/config/helpers/operationHelper.d.ts +60 -6
- package/dist/src/config/helpers/operationHelper.d.ts.map +1 -1
- package/dist/src/config/helpers/operationHelper.test.d.ts +2 -0
- package/dist/src/config/helpers/operationHelper.test.d.ts.map +1 -0
- package/dist/src/config/index.d.ts +65 -60
- package/dist/src/config/index.d.ts.map +1 -1
- package/dist/src/config/types/ai.d.ts +9 -3
- package/dist/src/config/types/ai.d.ts.map +1 -1
- package/dist/src/config/types/auth.d.ts +55 -15
- package/dist/src/config/types/auth.d.ts.map +1 -1
- package/dist/src/config/types/cron.d.ts +96 -2
- package/dist/src/config/types/cron.d.ts.map +1 -1
- package/dist/src/config/types/db.d.ts +186 -24
- package/dist/src/config/types/db.d.ts.map +1 -1
- package/dist/src/config/types/index.d.ts +3 -3
- package/dist/src/config/types/index.d.ts.map +1 -1
- package/dist/src/config/types/operation.d.ts +49 -17
- package/dist/src/config/types/operation.d.ts.map +1 -1
- package/dist/src/config/types/queue.d.ts +59 -12
- package/dist/src/config/types/queue.d.ts.map +1 -1
- package/dist/src/config/types/remote-rest.d.ts +10 -1
- package/dist/src/config/types/remote-rest.d.ts.map +1 -1
- package/dist/src/config/types/remote-schema.d.ts +13 -3
- package/dist/src/config/types/remote-schema.d.ts.map +1 -1
- package/dist/src/config/types/virtual-columns.d.ts +1 -1
- package/dist/src/config/types/virtual-columns.d.ts.map +1 -1
- package/dist/src/configuration/getSchemas/index.d.ts +52 -4
- package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
- package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +26 -2
- package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts.map +1 -1
- package/dist/src/configuration/getSchemas/mergeEntities/index.test.d.ts +2 -0
- package/dist/src/configuration/getSchemas/mergeEntities/index.test.d.ts.map +1 -0
- package/dist/src/configuration/getSchemas/type-definition-generator/index.d.ts.map +1 -1
- package/dist/src/configuration/index.d.ts +40 -4
- package/dist/src/configuration/index.d.ts.map +1 -1
- package/dist/src/configuration/rest/generateOpenAPI.d.ts.map +1 -1
- package/dist/src/configuration/rest/handleRESTRequestFactory.d.ts.map +1 -1
- package/dist/src/databases/common.d.ts +3 -1
- package/dist/src/databases/common.d.ts.map +1 -1
- package/dist/src/databases/core/query-builder.d.ts +7 -1
- package/dist/src/databases/core/query-builder.d.ts.map +1 -1
- package/dist/src/databases/directives.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/connection.d.ts +1 -1
- package/dist/src/databases/engines/mssql/connection.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/getStructure.d.ts +7 -0
- package/dist/src/databases/engines/mssql/getStructure.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/query/index.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/connection.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/getStructure.d.ts +7 -0
- package/dist/src/databases/engines/mysql/getStructure.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/placeholders.d.ts +22 -0
- package/dist/src/databases/engines/mysql/placeholders.d.ts.map +1 -0
- package/dist/src/databases/engines/mysql/placeholders.test.d.ts +2 -0
- package/dist/src/databases/engines/mysql/placeholders.test.d.ts.map +1 -0
- package/dist/src/databases/engines/mysql/query/index.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/getStructure.d.ts +7 -0
- package/dist/src/databases/engines/postgresql/getStructure.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/query/index.d.ts.map +1 -1
- package/dist/src/databases/high-level-operations.d.ts +14 -1
- package/dist/src/databases/high-level-operations.d.ts.map +1 -1
- package/dist/src/databases/metadata/structure.d.ts +7 -0
- package/dist/src/databases/metadata/structure.d.ts.map +1 -1
- package/dist/src/databases/sqlTypeUtils.d.ts.map +1 -1
- package/dist/src/databases/transformers/data-transformers.d.ts +39 -2
- package/dist/src/databases/transformers/data-transformers.d.ts.map +1 -1
- package/dist/src/singletons/env.d.ts +21 -3
- package/dist/src/singletons/env.d.ts.map +1 -1
- package/dist/src/types/configuration.d.ts +1 -1
- package/dist/src/types/configuration.d.ts.map +1 -1
- package/dist/src/types/env.d.ts +127 -51
- package/dist/src/types/env.d.ts.map +1 -1
- package/dist/src/types/resolver.d.ts +12 -2
- package/dist/src/types/resolver.d.ts.map +1 -1
- package/dist/src/types/zod/auth.d.ts +6 -6
- package/dist/src/types/zod/configuration.d.ts +85 -45
- package/dist/src/types/zod/configuration.d.ts.map +1 -1
- package/dist/src/types/zod/cron.d.ts +1 -1
- package/dist/src/types/zod/cron.d.ts.map +1 -1
- package/dist/src/types/zod/db.d.ts +167 -8
- package/dist/src/types/zod/db.d.ts.map +1 -1
- package/dist/src/types/zod/queue.d.ts +26 -26
- package/dist/src/types/zod/strict-keys.test.d.ts +2 -0
- package/dist/src/types/zod/strict-keys.test.d.ts.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/analyzeQuery/resolveVariables.ts +37 -7
- package/src/config/helpers/operationHelper.ts +146 -5
- package/src/config/index.ts +139 -62
- package/src/config/types/ai.ts +2 -2
- package/src/config/types/auth.ts +37 -6
- package/src/config/types/cron.ts +1 -1
- package/src/config/types/db.ts +56 -9
- package/src/config/types/index.ts +5 -0
- package/src/config/types/operation.ts +49 -9
- package/src/config/types/queue.ts +8 -8
- package/src/config/types/remote-rest.ts +1 -1
- package/src/config/types/remote-schema.ts +2 -2
- package/src/config/types/virtual-columns.ts +1 -1
- package/src/configuration/getSchemas/mergeEntities/index.ts +6 -0
- package/src/configuration/getSchemas/type-definition-generator/index.ts +13 -4
- package/src/configuration/rest/generateOpenAPI.ts +5 -9
- package/src/configuration/rest/handleRESTRequestFactory.ts +40 -14
- package/src/databases/common.ts +279 -83
- package/src/databases/directives.ts +26 -8
- package/src/databases/engines/mssql/connection.ts +1 -1
- package/src/databases/engines/mssql/getStructure.ts +1 -0
- package/src/databases/engines/mssql/query/index.ts +29 -11
- package/src/databases/engines/mysql/auth.ts +2 -2
- package/src/databases/engines/mysql/connection.ts +5 -4
- package/src/databases/engines/mysql/getStructure.ts +61 -29
- package/src/databases/engines/mysql/placeholders.ts +84 -0
- package/src/databases/engines/mysql/query/index.ts +49 -4
- package/src/databases/engines/postgresql/getStructure.ts +1 -0
- package/src/databases/engines/postgresql/query/index.ts +40 -2
- package/src/databases/high-level-operations.ts +25 -0
- package/src/databases/sqlTypeUtils.ts +3 -1
- package/src/subscriptions/strategies/index.ts +1 -1
- package/src/subscriptions/strategies/queue.ts +1 -1
- package/src/types/configuration.ts +7 -1
- package/src/types/resolver.ts +17 -1
- package/src/types/zod/configuration.ts +1 -1
- package/src/types/zod/db.ts +4 -0
package/src/databases/common.ts
CHANGED
|
@@ -2,8 +2,9 @@ import { isBoolean, isString } from "es-toolkit/compat";
|
|
|
2
2
|
|
|
3
3
|
import type { SelectionAnalysis, VariableDefinition } from "../analyzeQuery/types";
|
|
4
4
|
import type { MergedEntities } from "../configuration/getSchemas/mergeEntities";
|
|
5
|
-
import type { DatabaseType, VirtualColumn } from "../types/configuration";
|
|
5
|
+
import type { DatabaseType, RelationshipCondition, VirtualColumn } from "../types/configuration";
|
|
6
6
|
|
|
7
|
+
import { FILTER_OPERATORS } from "../config/types/auth";
|
|
7
8
|
import { applyDirectives } from "./directives";
|
|
8
9
|
|
|
9
10
|
// Common aggregation types
|
|
@@ -25,10 +26,36 @@ export interface GroupByInfo {
|
|
|
25
26
|
cteAlias: string;
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
// isOperatorObject decides whether a filter object is a column filter or a nested
|
|
30
|
+
// relation, so an operator missing from this set is not merely unhandled — the
|
|
31
|
+
// whole filter is routed to the relation branch and dropped. Shared with the
|
|
32
|
+
// config schema so the two cannot drift.
|
|
33
|
+
const OPERATORS = new Set<string>(FILTER_OPERATORS);
|
|
34
|
+
|
|
28
35
|
const isOperatorObject = (obj: object): boolean =>
|
|
29
|
-
Object.keys(obj).some((key) =>
|
|
30
|
-
|
|
31
|
-
|
|
36
|
+
Object.keys(obj).some((key) => OPERATORS.has(key));
|
|
37
|
+
|
|
38
|
+
// These operators branch on the operand instead of binding it. Every literal in a
|
|
39
|
+
// query document is hoisted into a generated variable (analyzeQuery/valueExtractors),
|
|
40
|
+
// so the operand arrives as "$name" — testing that string for truthiness would make
|
|
41
|
+
// `is_null: false` behave exactly like `is_null: true`. Resolve the real boolean.
|
|
42
|
+
const NULL_CHECK_OPERATORS = new Set(["is_null", "is_not_null", "not_null"]);
|
|
43
|
+
|
|
44
|
+
const resolveBooleanOperand = (
|
|
45
|
+
operand: unknown,
|
|
46
|
+
variables: Record<string, unknown>,
|
|
47
|
+
variablesDefinition: VariableDefinition[],
|
|
48
|
+
): boolean => {
|
|
49
|
+
if (!isString(operand) || !operand.startsWith("$")) return Boolean(operand);
|
|
50
|
+
|
|
51
|
+
const name = operand.substring(1);
|
|
52
|
+
|
|
53
|
+
// The hash-key path passes the request variables without the generated defaults,
|
|
54
|
+
// so fall back to the definition's defaultValue for hoisted literals.
|
|
55
|
+
return name in variables
|
|
56
|
+
? Boolean(variables[name])
|
|
57
|
+
: Boolean(variablesDefinition.find((v) => v.name === name)?.defaultValue);
|
|
58
|
+
};
|
|
32
59
|
|
|
33
60
|
export const generateTableAlias = (level: number): string => `t${level}`;
|
|
34
61
|
|
|
@@ -47,6 +74,8 @@ export const buildConditions = (
|
|
|
47
74
|
tableAlias: string,
|
|
48
75
|
level: number,
|
|
49
76
|
aliasMap: { [alias: string]: string },
|
|
77
|
+
/** Query name of the table being filtered — resolves its virtual columns. */
|
|
78
|
+
tableName?: string,
|
|
50
79
|
): string => {
|
|
51
80
|
if (!whereArgs) return "";
|
|
52
81
|
|
|
@@ -58,25 +87,50 @@ export const buildConditions = (
|
|
|
58
87
|
if (isOperatorObject(value)) {
|
|
59
88
|
for (const [operator, operand] of Object.entries(value)) {
|
|
60
89
|
// All values are now variable references (e.g., "$varName" or ["$var1", "$var2"])
|
|
61
|
-
const resolvedValue =
|
|
62
|
-
? operand
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
: operand
|
|
72
|
-
|
|
73
|
-
|
|
90
|
+
const resolvedValue = NULL_CHECK_OPERATORS.has(operator)
|
|
91
|
+
? resolveBooleanOperand(operand, variables, variablesDefinition)
|
|
92
|
+
: Array.isArray(operand)
|
|
93
|
+
? operand.map((op) => {
|
|
94
|
+
if (isString(op) && op.startsWith("$")) {
|
|
95
|
+
const index = variablesDefinition.findIndex((v) => v.name === op.substring(1));
|
|
96
|
+
return `${mappingDbTypeCharVar[dbType]}${index + 1}`;
|
|
97
|
+
}
|
|
98
|
+
return op;
|
|
99
|
+
})
|
|
100
|
+
: isString(operand) && operand.startsWith("$")
|
|
101
|
+
? `${mappingDbTypeCharVar[dbType]}${variablesDefinition.findIndex((v) => v.name === operand.substring(1)) + 1}`
|
|
102
|
+
: operand;
|
|
103
|
+
|
|
104
|
+
const condition = buildCondition(
|
|
105
|
+
columnTargetFp(dbType)(entities, tableName, fieldName, tableAlias),
|
|
106
|
+
fieldName,
|
|
107
|
+
operator,
|
|
108
|
+
resolvedValue,
|
|
109
|
+
dbType,
|
|
110
|
+
);
|
|
74
111
|
|
|
75
112
|
if (condition) {
|
|
76
113
|
conditions.push(condition);
|
|
77
114
|
}
|
|
78
115
|
}
|
|
79
116
|
} else if (typeof value === "object" && !Array.isArray(value)) {
|
|
117
|
+
// Only entities (tables/views) appear in queriesMap. An operator-less filter object on a
|
|
118
|
+
// column (e.g. `{ col: {} }`) has no operator keys, so it lands here despite not being a
|
|
119
|
+
// relation; skip it as a no-op instead of dereferencing a missing queriesMap entry.
|
|
120
|
+
const nestedEntity = entities.queriesMap[fieldName];
|
|
121
|
+
if (!nestedEntity) {
|
|
122
|
+
const [unknownOperator] = Object.keys(value as object);
|
|
123
|
+
// A non-empty filter object on something that is not a relation can only
|
|
124
|
+
// be a column filter whose operator we do not implement. Dropping it here
|
|
125
|
+
// would return every row.
|
|
126
|
+
if (unknownOperator) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`Unsupported filter operator "${unknownOperator}" on column "${fieldName}"`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
80
134
|
const nestedTableAlias = `t${level}`;
|
|
81
135
|
|
|
82
136
|
const parentTableName = aliasMap[tableAlias];
|
|
@@ -90,6 +144,11 @@ export const buildConditions = (
|
|
|
90
144
|
dbType,
|
|
91
145
|
);
|
|
92
146
|
|
|
147
|
+
// Record this relation's alias→table so a deeper nested where (3+ levels) can resolve
|
|
148
|
+
// its own parent via aliasMap[tableAlias]; without it the next recursion sees undefined
|
|
149
|
+
// and emits a join-less "WHERE AND (...)".
|
|
150
|
+
aliasMap[nestedTableAlias] = fieldName;
|
|
151
|
+
|
|
93
152
|
const nestedConditions = buildConditions(
|
|
94
153
|
entities,
|
|
95
154
|
dbType,
|
|
@@ -99,11 +158,12 @@ export const buildConditions = (
|
|
|
99
158
|
nestedTableAlias,
|
|
100
159
|
level + 1,
|
|
101
160
|
aliasMap,
|
|
161
|
+
fieldName,
|
|
102
162
|
);
|
|
103
163
|
|
|
104
164
|
const existsClause = `EXISTS (
|
|
105
165
|
SELECT 1
|
|
106
|
-
FROM ${
|
|
166
|
+
FROM ${nestedEntity.dottedQuotedName} ${nestedTableAlias}
|
|
107
167
|
WHERE ${joinCondition}${nestedConditions ? ` AND (${nestedConditions})` : ""}
|
|
108
168
|
)`;
|
|
109
169
|
|
|
@@ -142,6 +202,31 @@ export const qualifiedNameFp =
|
|
|
142
202
|
return `${wrap(schema)}.${wrap(name)}`;
|
|
143
203
|
};
|
|
144
204
|
|
|
205
|
+
const renderVirtualColumn = (vc: VirtualColumn): string =>
|
|
206
|
+
vc.function ? `(${vc.function}(${vc.params?.join(", ")}))` : `(${vc.expression})`;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* What a column name compiles to on the left of a comparison or in ORDER BY.
|
|
210
|
+
*
|
|
211
|
+
* A virtual column has no physical counterpart, so emitting `alias."name"` for
|
|
212
|
+
* one produces `column t1.name does not exist`. WHERE and ORDER BY both go
|
|
213
|
+
* through here so they cannot disagree about what a column is.
|
|
214
|
+
*/
|
|
215
|
+
const columnTargetFp =
|
|
216
|
+
(dbType: DatabaseType) =>
|
|
217
|
+
(
|
|
218
|
+
entities: MergedEntities,
|
|
219
|
+
tableName: string | undefined,
|
|
220
|
+
colName: string,
|
|
221
|
+
tableAlias: string,
|
|
222
|
+
): string => {
|
|
223
|
+
const virtualColumn = tableName ? entities.isVirtualColumn(tableName, colName) : undefined;
|
|
224
|
+
|
|
225
|
+
return virtualColumn
|
|
226
|
+
? renderVirtualColumn(virtualColumn as VirtualColumn)
|
|
227
|
+
: `${tableAlias}.${wrapIdentifierFp(dbType)(colName)}`;
|
|
228
|
+
};
|
|
229
|
+
|
|
145
230
|
// Helper to resolve variable values
|
|
146
231
|
const resolveVariable = <T>(value: unknown, variables: Record<string, unknown>): T => {
|
|
147
232
|
if (isString(value) && value.startsWith("$")) {
|
|
@@ -151,61 +236,169 @@ const resolveVariable = <T>(value: unknown, variables: Record<string, unknown>):
|
|
|
151
236
|
};
|
|
152
237
|
|
|
153
238
|
const buildCondition = (
|
|
154
|
-
|
|
239
|
+
target: string,
|
|
155
240
|
field: string,
|
|
156
241
|
operator: string,
|
|
157
242
|
value: unknown,
|
|
158
243
|
dbType: DatabaseType,
|
|
159
244
|
): string | null => {
|
|
160
|
-
const wrappedField = wrapIdentifierFp(dbType)(field);
|
|
161
|
-
|
|
162
245
|
switch (operator) {
|
|
163
246
|
case "eq":
|
|
164
|
-
return `${
|
|
247
|
+
return `${target} = ${value}`;
|
|
165
248
|
case "neq":
|
|
166
|
-
return `${
|
|
249
|
+
return `${target} <> ${value}`;
|
|
167
250
|
case "gt":
|
|
168
|
-
return `${
|
|
251
|
+
return `${target} > ${value}`;
|
|
169
252
|
case "gte":
|
|
170
|
-
return `${
|
|
253
|
+
return `${target} >= ${value}`;
|
|
171
254
|
case "lt":
|
|
172
|
-
return `${
|
|
255
|
+
return `${target} < ${value}`;
|
|
173
256
|
case "lte":
|
|
174
|
-
return `${
|
|
257
|
+
return `${target} <= ${value}`;
|
|
258
|
+
// PostgreSQL and MySQL treat a backslash as the LIKE escape by default;
|
|
259
|
+
// T-SQL has no default escape character at all, so `100\%` matched a literal
|
|
260
|
+
// backslash and found nothing. Declare the escape to make the three agree.
|
|
175
261
|
case "like":
|
|
176
|
-
return `${
|
|
262
|
+
return dbType === "mssql" ? `${target} LIKE ${value} ESCAPE '\\'` : `${target} LIKE ${value}`;
|
|
177
263
|
case "in":
|
|
178
|
-
return `${
|
|
179
|
-
Array.isArray(value) ? value.join(", ") : value
|
|
180
|
-
})`;
|
|
264
|
+
return `${target} IN (${Array.isArray(value) ? value.join(", ") : value})`;
|
|
181
265
|
case "is_null":
|
|
182
|
-
return value
|
|
183
|
-
? `${tableAlias}.${wrappedField} IS NULL`
|
|
184
|
-
: `${tableAlias}.${wrappedField} IS NOT NULL`;
|
|
266
|
+
return value ? `${target} IS NULL` : `${target} IS NOT NULL`;
|
|
185
267
|
case "is_not_null":
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
268
|
+
case "not_null":
|
|
269
|
+
return value ? `${target} IS NOT NULL` : `${target} IS NULL`;
|
|
270
|
+
case "between": {
|
|
271
|
+
const bounds = Array.isArray(value) ? value : [];
|
|
272
|
+
if (bounds.length !== 2) {
|
|
273
|
+
throw new Error(
|
|
274
|
+
`Filter operator "between" on column "${field}" expects exactly two values, received ${bounds.length}`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
return `${target} BETWEEN ${bounds[0]} AND ${bounds[1]}`;
|
|
278
|
+
}
|
|
279
|
+
// Returning null here would drop the condition, so a filter Graphoria cannot
|
|
280
|
+
// honour would widen the result set instead of failing — a role filter naming
|
|
281
|
+
// an unimplemented operator would return the whole table.
|
|
189
282
|
default:
|
|
190
|
-
|
|
283
|
+
throw new Error(`Unsupported filter operator "${operator}" on column "${field}"`);
|
|
191
284
|
}
|
|
192
285
|
};
|
|
193
286
|
|
|
287
|
+
const COLLATION_NAME_RE = /^[A-Za-z0-9_]+$/;
|
|
288
|
+
|
|
289
|
+
// Equating two character columns with different collations raises a hard error (SQL Server:
|
|
290
|
+
// "cannot resolve the collation conflict"; MySQL: "illegal mix of collations"). Coerce the right
|
|
291
|
+
// operand to a shared collation, but only when both columns carry one and the two differ: a null
|
|
292
|
+
// collation marks a non-character column, where COLLATE is itself illegal. Matching or unknown
|
|
293
|
+
// collations leave the equality byte-for-byte unchanged (and keep the join column SARGable).
|
|
294
|
+
const collateSuffix = (
|
|
295
|
+
dbType: DatabaseType,
|
|
296
|
+
parentCollation: string | null | undefined,
|
|
297
|
+
childCollation: string | null | undefined,
|
|
298
|
+
): string => {
|
|
299
|
+
if (!parentCollation || !childCollation || parentCollation === childCollation) return "";
|
|
300
|
+
|
|
301
|
+
if (dbType === "mssql") return " COLLATE DATABASE_DEFAULT";
|
|
302
|
+
if (dbType === "pg") return ` COLLATE "default"`;
|
|
303
|
+
// MySQL has no database-default token; coerce to the left column's own collation name.
|
|
304
|
+
return COLLATION_NAME_RE.test(parentCollation) ? ` COLLATE ${parentCollation}` : "";
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const columnCollation = (
|
|
308
|
+
entities: MergedEntities,
|
|
309
|
+
tableName: string,
|
|
310
|
+
columnName: string,
|
|
311
|
+
): string | null | undefined => {
|
|
312
|
+
const column = entities.queriesMap[tableName]?.columns.find((c) => c.name === columnName);
|
|
313
|
+
return column && "collation" in column ? column.collation : undefined;
|
|
314
|
+
};
|
|
315
|
+
|
|
194
316
|
const pairsToAnd = (
|
|
195
317
|
pairs: {
|
|
196
318
|
parentAlias: string;
|
|
197
319
|
childAlias: string;
|
|
198
320
|
parentColumn: string;
|
|
199
321
|
childColumn: string;
|
|
322
|
+
parentCollation?: string | null;
|
|
323
|
+
childCollation?: string | null;
|
|
200
324
|
}[],
|
|
201
325
|
dbType: DatabaseType,
|
|
202
326
|
) => {
|
|
203
327
|
const wrap = wrapIdentifierFp(dbType);
|
|
204
328
|
return pairs
|
|
205
|
-
.map(
|
|
329
|
+
.map(
|
|
330
|
+
(p) =>
|
|
331
|
+
`${p.parentAlias}.${wrap(p.parentColumn)} = ${p.childAlias}.${wrap(p.childColumn)}${collateSuffix(dbType, p.parentCollation, p.childCollation)}`,
|
|
332
|
+
)
|
|
206
333
|
.join(" AND ");
|
|
207
334
|
};
|
|
208
335
|
|
|
336
|
+
const RELATIONSHIP_CONDITION_SQL_OPERATORS: Record<string, string> = {
|
|
337
|
+
eq: "=",
|
|
338
|
+
neq: "<>",
|
|
339
|
+
gt: ">",
|
|
340
|
+
gte: ">=",
|
|
341
|
+
lt: "<",
|
|
342
|
+
lte: "<=",
|
|
343
|
+
like: "LIKE",
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
// Static join-condition values are literals (never bound as params at this layer),
|
|
347
|
+
// so render them safely: escape quotes in strings, reject non-finite numbers, and
|
|
348
|
+
// emit dialect-correct booleans (MSSQL has no boolean type — compare against 1/0).
|
|
349
|
+
const sqlLiteral = (dbType: DatabaseType, value: string | number | boolean): string => {
|
|
350
|
+
if (typeof value === "number") {
|
|
351
|
+
if (!Number.isFinite(value)) {
|
|
352
|
+
throw new Error(`Relationship condition value must be a finite number, got ${value}`);
|
|
353
|
+
}
|
|
354
|
+
return String(value);
|
|
355
|
+
}
|
|
356
|
+
if (typeof value === "boolean") {
|
|
357
|
+
if (dbType === "mssql") return value ? "1" : "0";
|
|
358
|
+
return value ? "TRUE" : "FALSE";
|
|
359
|
+
}
|
|
360
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const renderCondition = (
|
|
364
|
+
dbType: DatabaseType,
|
|
365
|
+
alias: string,
|
|
366
|
+
column: string,
|
|
367
|
+
operator: string,
|
|
368
|
+
value: string | number | boolean | undefined,
|
|
369
|
+
): string => {
|
|
370
|
+
const target = `${alias}.${wrapIdentifierFp(dbType)(column)}`;
|
|
371
|
+
|
|
372
|
+
if (operator === "is_null") return `${target} IS NULL`;
|
|
373
|
+
if (operator === "is_not_null") return `${target} IS NOT NULL`;
|
|
374
|
+
|
|
375
|
+
const sqlOperator = RELATIONSHIP_CONDITION_SQL_OPERATORS[operator];
|
|
376
|
+
if (!sqlOperator || value === undefined) {
|
|
377
|
+
throw new Error(`Invalid relationship condition operator "${operator}"`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return `${target} ${sqlOperator} ${sqlLiteral(dbType, value)}`;
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// A condition targets exactly one side of the relationship: `source` (a column on
|
|
384
|
+
// the declaring table → fromAlias) or `target` (a column on the referenced table →
|
|
385
|
+
// toAlias). The from/to aliases flip between forward and reverse joins.
|
|
386
|
+
const conditionsToAnd = (
|
|
387
|
+
conditions: readonly RelationshipCondition[] | undefined,
|
|
388
|
+
fromAlias: string,
|
|
389
|
+
toAlias: string,
|
|
390
|
+
dbType: DatabaseType,
|
|
391
|
+
): string =>
|
|
392
|
+
(conditions ?? [])
|
|
393
|
+
.map((c) =>
|
|
394
|
+
c.source !== undefined
|
|
395
|
+
? renderCondition(dbType, fromAlias, c.source, c.operator ?? "eq", c.value)
|
|
396
|
+
: renderCondition(dbType, toAlias, c.target!, c.operator ?? "eq", c.value),
|
|
397
|
+
)
|
|
398
|
+
.join(" AND ");
|
|
399
|
+
|
|
400
|
+
const joinParts = (...parts: string[]): string => parts.filter(Boolean).join(" AND ");
|
|
401
|
+
|
|
209
402
|
export const findJoinCondition = (
|
|
210
403
|
entities: MergedEntities,
|
|
211
404
|
parentTableName: string,
|
|
@@ -220,25 +413,35 @@ export const findJoinCondition = (
|
|
|
220
413
|
|
|
221
414
|
return [
|
|
222
415
|
...(relations.relationships?.map((relation) =>
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
416
|
+
joinParts(
|
|
417
|
+
pairsToAnd(
|
|
418
|
+
relation.columns.map((c) => ({
|
|
419
|
+
parentAlias,
|
|
420
|
+
childAlias,
|
|
421
|
+
parentColumn: c.source,
|
|
422
|
+
childColumn: c.target,
|
|
423
|
+
parentCollation: columnCollation(entities, parentTableName, c.source),
|
|
424
|
+
childCollation: columnCollation(entities, childTableName, c.target),
|
|
425
|
+
})),
|
|
426
|
+
dbType,
|
|
427
|
+
),
|
|
428
|
+
conditionsToAnd(relation.conditions, parentAlias, childAlias, dbType),
|
|
231
429
|
),
|
|
232
430
|
) ?? []),
|
|
233
431
|
...(relations.relationshipsReversed?.map((relation) =>
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
432
|
+
joinParts(
|
|
433
|
+
pairsToAnd(
|
|
434
|
+
relation.columns.map((c) => ({
|
|
435
|
+
parentAlias,
|
|
436
|
+
childAlias,
|
|
437
|
+
parentColumn: c.target,
|
|
438
|
+
childColumn: c.source,
|
|
439
|
+
parentCollation: columnCollation(entities, parentTableName, c.target),
|
|
440
|
+
childCollation: columnCollation(entities, childTableName, c.source),
|
|
441
|
+
})),
|
|
442
|
+
dbType,
|
|
443
|
+
),
|
|
444
|
+
conditionsToAnd(relation.conditions, childAlias, parentAlias, dbType),
|
|
242
445
|
),
|
|
243
446
|
) ?? []),
|
|
244
447
|
].join(" AND ");
|
|
@@ -267,6 +470,7 @@ export const buildWhereClauseFp =
|
|
|
267
470
|
tableAlias,
|
|
268
471
|
level + 1,
|
|
269
472
|
aliasMap,
|
|
473
|
+
field.name ?? aliasMap[tableAlias],
|
|
270
474
|
),
|
|
271
475
|
parentTableName && parentTableAlias
|
|
272
476
|
? findJoinCondition(
|
|
@@ -303,7 +507,6 @@ const parseOrderDirection = (direction: string): { sort: string; nulls?: string
|
|
|
303
507
|
export const buildOrderByClauseFp =
|
|
304
508
|
(dbType: DatabaseType = "pg") =>
|
|
305
509
|
(entities: MergedEntities, field: SelectionAnalysis, tableAlias: string): string => {
|
|
306
|
-
const wrap = wrapIdentifierFp(dbType);
|
|
307
510
|
if (field.arguments?.["orderBy"]) {
|
|
308
511
|
const orderBy = field.arguments?.["orderBy"];
|
|
309
512
|
// orderBy is an array or object of { column: direction } - no variable resolution needed
|
|
@@ -323,16 +526,18 @@ export const buildOrderByClauseFp =
|
|
|
323
526
|
const [colName, direction] = Object.entries(ob)[0];
|
|
324
527
|
const { sort, nulls } = parseOrderDirection(direction as string);
|
|
325
528
|
|
|
326
|
-
|
|
529
|
+
// Resolved once, above the NULLS branch: a virtual column has to expand
|
|
530
|
+
// to its expression in every emission below, not only the default one.
|
|
531
|
+
const target = columnTargetFp(dbType)(entities, field.name, colName, tableAlias);
|
|
327
532
|
|
|
328
533
|
if (nulls) {
|
|
329
534
|
if (dbType === "pg") {
|
|
330
535
|
// PostgreSQL supports NULLS FIRST/LAST natively
|
|
331
|
-
return `${
|
|
536
|
+
return `${target} ${sort} NULLS ${nulls}`;
|
|
332
537
|
} else if (dbType === "mysql") {
|
|
333
538
|
// MySQL: Use CASE statement for NULL handling
|
|
334
539
|
const nullFirst = nulls === "FIRST";
|
|
335
|
-
return `CASE WHEN ${
|
|
540
|
+
return `CASE WHEN ${target} IS NULL THEN ${nullFirst ? 0 : 1} ELSE ${nullFirst ? 1 : 0} END, ${target} ${sort}`;
|
|
336
541
|
} else {
|
|
337
542
|
// MSSQL requires CASE statements for NULL handling
|
|
338
543
|
const nullFirst = nulls === "FIRST";
|
|
@@ -340,23 +545,16 @@ export const buildOrderByClauseFp =
|
|
|
340
545
|
|
|
341
546
|
if ((nullFirst && isAsc) || (!nullFirst && !isAsc)) {
|
|
342
547
|
// NULLs naturally come first in this combination
|
|
343
|
-
return `${
|
|
548
|
+
return `${target} ${sort}`;
|
|
344
549
|
} else {
|
|
345
550
|
// Need to force NULL positioning with CASE
|
|
346
|
-
return `CASE WHEN ${
|
|
551
|
+
return `CASE WHEN ${target} IS NULL THEN ${nullFirst ? 0 : 1} ELSE ${nullFirst ? 1 : 0} END, ${target} ${sort}`;
|
|
347
552
|
}
|
|
348
553
|
}
|
|
349
|
-
} else {
|
|
350
|
-
// No explicit NULL handling - use database defaults
|
|
351
|
-
if (virtualColumn) {
|
|
352
|
-
const vc = virtualColumn as VirtualColumn;
|
|
353
|
-
return `(
|
|
354
|
-
${vc.function ? `${vc.function}(${vc.params?.join(", ")})` : `${vc.expression}`}
|
|
355
|
-
) ${sort}`;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
return `${tableAlias}.${wrap(colName)} ${sort}`;
|
|
359
554
|
}
|
|
555
|
+
|
|
556
|
+
// No explicit NULL handling - use database defaults
|
|
557
|
+
return `${target} ${sort}`;
|
|
360
558
|
})
|
|
361
559
|
.join(", ");
|
|
362
560
|
|
|
@@ -623,14 +821,6 @@ export const buildPaginationClauseFp =
|
|
|
623
821
|
(field: SelectionAnalysis, variablesDefinition: VariableDefinition[] = []): string => {
|
|
624
822
|
const args = field.arguments;
|
|
625
823
|
|
|
626
|
-
// MSSQL requires ORDER BY for pagination
|
|
627
|
-
if (
|
|
628
|
-
dbType === "mssql" &&
|
|
629
|
-
(!args || args["limit"] === undefined || args["orderBy"] === undefined)
|
|
630
|
-
) {
|
|
631
|
-
return "";
|
|
632
|
-
}
|
|
633
|
-
|
|
634
824
|
if (!args || args["limit"] === undefined) {
|
|
635
825
|
return "";
|
|
636
826
|
}
|
|
@@ -646,10 +836,16 @@ export const buildPaginationClauseFp =
|
|
|
646
836
|
? toPositionalPlaceholder(args["offset"], variablesDefinition, dbType)
|
|
647
837
|
: "0";
|
|
648
838
|
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
839
|
+
if (dbType !== "mssql") {
|
|
840
|
+
return `LIMIT ${limitPlaceholder} OFFSET ${offsetPlaceholder}`;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// T-SQL only accepts OFFSET/FETCH after an ORDER BY. Dropping the limit when
|
|
844
|
+
// the query has none returned the whole table instead, so supply the
|
|
845
|
+
// order-agnostic ORDER BY that T-SQL accepts for exactly this case.
|
|
846
|
+
const orderByFallback = args["orderBy"] === undefined ? "ORDER BY (SELECT NULL) " : "";
|
|
847
|
+
|
|
848
|
+
return `${orderByFallback}OFFSET ${offsetPlaceholder} ROWS FETCH NEXT ${limitPlaceholder} ROWS ONLY`;
|
|
653
849
|
};
|
|
654
850
|
|
|
655
851
|
export const buildPaginationClausePG = buildPaginationClauseFp("pg");
|
|
@@ -10,10 +10,12 @@ export type DirectiveHandler = (
|
|
|
10
10
|
dbType: DatabaseType,
|
|
11
11
|
) => string;
|
|
12
12
|
|
|
13
|
+
const PLACEHOLDER_RE = /^[$@]\d+$/;
|
|
14
|
+
|
|
13
15
|
// Wrap a value in SQL string quotes unless it's a positional placeholder ($1, @1, etc.)
|
|
14
16
|
const sqlString = (value: unknown): string => {
|
|
15
17
|
const s = String(value);
|
|
16
|
-
if (
|
|
18
|
+
if (PLACEHOLDER_RE.test(s)) return s;
|
|
17
19
|
return `'${s}'`;
|
|
18
20
|
};
|
|
19
21
|
|
|
@@ -32,12 +34,18 @@ export const DIRECTIVE_HANDLERS: Record<string, DirectiveHandler> = {
|
|
|
32
34
|
`SUBSTRING(${querySelector}, ${directive.arguments!["start"]}, ${directive.arguments!["length"]})`,
|
|
33
35
|
replace: (querySelector, directive) =>
|
|
34
36
|
`REPLACE(${querySelector}, ${sqlString(directive.arguments!["find"])}, ${sqlString(directive.arguments!["replaceWith"])})`,
|
|
35
|
-
concat: (querySelector, directive) => {
|
|
36
|
-
const
|
|
37
|
+
concat: (querySelector, directive, dbType) => {
|
|
38
|
+
const rendered = sqlString(directive.arguments!["with"]);
|
|
39
|
+
// CONCAT is variadic "any" in PostgreSQL, so it gives the planner nothing to
|
|
40
|
+
// infer a bare placeholder from and the statement dies with
|
|
41
|
+
// "could not determine data type of parameter $n". The cast annotates the
|
|
42
|
+
// parameter type without unbinding the value.
|
|
43
|
+
const withValue =
|
|
44
|
+
dbType === "pg" && PLACEHOLDER_RE.test(rendered) ? `${rendered}::TEXT` : rendered;
|
|
37
45
|
const position = (directive.arguments?.["position"] as string) ?? "after";
|
|
38
46
|
return position === "before"
|
|
39
|
-
? `CONCAT(${
|
|
40
|
-
: `CONCAT(${querySelector}, ${
|
|
47
|
+
? `CONCAT(${withValue}, ${querySelector})`
|
|
48
|
+
: `CONCAT(${querySelector}, ${withValue})`;
|
|
41
49
|
},
|
|
42
50
|
pad: (querySelector, directive, dbType) => {
|
|
43
51
|
const length = directive.arguments!["length"] as number;
|
|
@@ -47,13 +55,23 @@ export const DIRECTIVE_HANDLERS: Record<string, DirectiveHandler> = {
|
|
|
47
55
|
return side === "left"
|
|
48
56
|
? `LPAD(${querySelector}::TEXT, ${length}, ${char})`
|
|
49
57
|
: `RPAD(${querySelector}::TEXT, ${length}, ${char})`;
|
|
50
|
-
}
|
|
58
|
+
}
|
|
59
|
+
// MySQL has LPAD/RPAD too, and needs no cast. It used to fall through to the
|
|
60
|
+
// SQL Server branch, where REPLICATE and VARCHAR(MAX) are syntax errors.
|
|
61
|
+
if (dbType === "mysql") {
|
|
51
62
|
return side === "left"
|
|
52
|
-
? `
|
|
53
|
-
: `
|
|
63
|
+
? `LPAD(${querySelector}, ${length}, ${char})`
|
|
64
|
+
: `RPAD(${querySelector}, ${length}, ${char})`;
|
|
54
65
|
}
|
|
66
|
+
// SQL Server: LPAD/RPAD only exist from 2022.
|
|
67
|
+
return side === "left"
|
|
68
|
+
? `RIGHT(REPLICATE(${char}, ${length}) + CAST(${querySelector} AS VARCHAR(MAX)), ${length})`
|
|
69
|
+
: `LEFT(CAST(${querySelector} AS VARCHAR(MAX)) + REPLICATE(${char}, ${length}), ${length})`;
|
|
55
70
|
},
|
|
56
71
|
dateFormat: (querySelector, directive, dbType) => {
|
|
72
|
+
if (dbType === "mysql") {
|
|
73
|
+
throw new Error("@dateFormat is not supported on MySQL. Expose a virtual column instead.");
|
|
74
|
+
}
|
|
57
75
|
const format = directive.arguments!["format"] as string;
|
|
58
76
|
return dbType === "pg"
|
|
59
77
|
? `TO_CHAR(${querySelector}, ${sqlString(format)})`
|
|
@@ -145,7 +145,7 @@ export const callStoredProcedure = async (
|
|
|
145
145
|
// never as SQL text, so reserved words need no delimiting here.
|
|
146
146
|
const data = await request.execute(sp.dottedName);
|
|
147
147
|
|
|
148
|
-
return data?.
|
|
148
|
+
return !data?.returnValue;
|
|
149
149
|
} catch (e: unknown) {
|
|
150
150
|
logger("mssql").error({ err: e }, "stored procedure execution failed");
|
|
151
151
|
return false;
|
|
@@ -26,6 +26,7 @@ export const getDatabaseStructure = async (db: Database) => {
|
|
|
26
26
|
c.COLUMN_NAME AS name,
|
|
27
27
|
c.DATA_TYPE AS dataType,
|
|
28
28
|
CAST(CASE WHEN c.IS_NULLABLE = 'YES' THEN 1 ELSE 0 END AS bit) AS isNullable,
|
|
29
|
+
c.COLLATION_NAME AS collation,
|
|
29
30
|
(
|
|
30
31
|
SELECT CAST(value AS NVARCHAR(MAX))
|
|
31
32
|
FROM sys.extended_properties ep
|