@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.
Files changed (123) hide show
  1. package/dist/src/analyzeQuery/resolveVariables.d.ts.map +1 -1
  2. package/dist/src/config/helpers/operationHelper.d.ts +60 -6
  3. package/dist/src/config/helpers/operationHelper.d.ts.map +1 -1
  4. package/dist/src/config/helpers/operationHelper.test.d.ts +2 -0
  5. package/dist/src/config/helpers/operationHelper.test.d.ts.map +1 -0
  6. package/dist/src/config/index.d.ts +65 -60
  7. package/dist/src/config/index.d.ts.map +1 -1
  8. package/dist/src/config/types/ai.d.ts +9 -3
  9. package/dist/src/config/types/ai.d.ts.map +1 -1
  10. package/dist/src/config/types/auth.d.ts +55 -15
  11. package/dist/src/config/types/auth.d.ts.map +1 -1
  12. package/dist/src/config/types/cron.d.ts +96 -2
  13. package/dist/src/config/types/cron.d.ts.map +1 -1
  14. package/dist/src/config/types/db.d.ts +186 -24
  15. package/dist/src/config/types/db.d.ts.map +1 -1
  16. package/dist/src/config/types/index.d.ts +3 -3
  17. package/dist/src/config/types/index.d.ts.map +1 -1
  18. package/dist/src/config/types/operation.d.ts +49 -17
  19. package/dist/src/config/types/operation.d.ts.map +1 -1
  20. package/dist/src/config/types/queue.d.ts +59 -12
  21. package/dist/src/config/types/queue.d.ts.map +1 -1
  22. package/dist/src/config/types/remote-rest.d.ts +10 -1
  23. package/dist/src/config/types/remote-rest.d.ts.map +1 -1
  24. package/dist/src/config/types/remote-schema.d.ts +13 -3
  25. package/dist/src/config/types/remote-schema.d.ts.map +1 -1
  26. package/dist/src/config/types/virtual-columns.d.ts +1 -1
  27. package/dist/src/config/types/virtual-columns.d.ts.map +1 -1
  28. package/dist/src/configuration/getSchemas/index.d.ts +52 -4
  29. package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
  30. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +26 -2
  31. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts.map +1 -1
  32. package/dist/src/configuration/getSchemas/mergeEntities/index.test.d.ts +2 -0
  33. package/dist/src/configuration/getSchemas/mergeEntities/index.test.d.ts.map +1 -0
  34. package/dist/src/configuration/getSchemas/type-definition-generator/index.d.ts.map +1 -1
  35. package/dist/src/configuration/index.d.ts +40 -4
  36. package/dist/src/configuration/index.d.ts.map +1 -1
  37. package/dist/src/configuration/rest/generateOpenAPI.d.ts.map +1 -1
  38. package/dist/src/configuration/rest/handleRESTRequestFactory.d.ts.map +1 -1
  39. package/dist/src/databases/common.d.ts +3 -1
  40. package/dist/src/databases/common.d.ts.map +1 -1
  41. package/dist/src/databases/core/query-builder.d.ts +7 -1
  42. package/dist/src/databases/core/query-builder.d.ts.map +1 -1
  43. package/dist/src/databases/directives.d.ts.map +1 -1
  44. package/dist/src/databases/engines/mssql/connection.d.ts +1 -1
  45. package/dist/src/databases/engines/mssql/connection.d.ts.map +1 -1
  46. package/dist/src/databases/engines/mssql/getStructure.d.ts +7 -0
  47. package/dist/src/databases/engines/mssql/getStructure.d.ts.map +1 -1
  48. package/dist/src/databases/engines/mssql/query/index.d.ts.map +1 -1
  49. package/dist/src/databases/engines/mysql/connection.d.ts.map +1 -1
  50. package/dist/src/databases/engines/mysql/getStructure.d.ts +7 -0
  51. package/dist/src/databases/engines/mysql/getStructure.d.ts.map +1 -1
  52. package/dist/src/databases/engines/mysql/placeholders.d.ts +22 -0
  53. package/dist/src/databases/engines/mysql/placeholders.d.ts.map +1 -0
  54. package/dist/src/databases/engines/mysql/placeholders.test.d.ts +2 -0
  55. package/dist/src/databases/engines/mysql/placeholders.test.d.ts.map +1 -0
  56. package/dist/src/databases/engines/mysql/query/index.d.ts.map +1 -1
  57. package/dist/src/databases/engines/postgresql/getStructure.d.ts +7 -0
  58. package/dist/src/databases/engines/postgresql/getStructure.d.ts.map +1 -1
  59. package/dist/src/databases/engines/postgresql/query/index.d.ts.map +1 -1
  60. package/dist/src/databases/high-level-operations.d.ts +14 -1
  61. package/dist/src/databases/high-level-operations.d.ts.map +1 -1
  62. package/dist/src/databases/metadata/structure.d.ts +7 -0
  63. package/dist/src/databases/metadata/structure.d.ts.map +1 -1
  64. package/dist/src/databases/sqlTypeUtils.d.ts.map +1 -1
  65. package/dist/src/databases/transformers/data-transformers.d.ts +39 -2
  66. package/dist/src/databases/transformers/data-transformers.d.ts.map +1 -1
  67. package/dist/src/singletons/env.d.ts +21 -3
  68. package/dist/src/singletons/env.d.ts.map +1 -1
  69. package/dist/src/types/configuration.d.ts +1 -1
  70. package/dist/src/types/configuration.d.ts.map +1 -1
  71. package/dist/src/types/env.d.ts +127 -51
  72. package/dist/src/types/env.d.ts.map +1 -1
  73. package/dist/src/types/resolver.d.ts +12 -2
  74. package/dist/src/types/resolver.d.ts.map +1 -1
  75. package/dist/src/types/zod/auth.d.ts +6 -6
  76. package/dist/src/types/zod/configuration.d.ts +85 -45
  77. package/dist/src/types/zod/configuration.d.ts.map +1 -1
  78. package/dist/src/types/zod/cron.d.ts +1 -1
  79. package/dist/src/types/zod/cron.d.ts.map +1 -1
  80. package/dist/src/types/zod/db.d.ts +167 -8
  81. package/dist/src/types/zod/db.d.ts.map +1 -1
  82. package/dist/src/types/zod/queue.d.ts +26 -26
  83. package/dist/src/types/zod/strict-keys.test.d.ts +2 -0
  84. package/dist/src/types/zod/strict-keys.test.d.ts.map +1 -0
  85. package/dist/tsconfig.tsbuildinfo +1 -1
  86. package/package.json +1 -1
  87. package/src/analyzeQuery/resolveVariables.ts +37 -7
  88. package/src/config/helpers/operationHelper.ts +146 -5
  89. package/src/config/index.ts +139 -62
  90. package/src/config/types/ai.ts +2 -2
  91. package/src/config/types/auth.ts +37 -6
  92. package/src/config/types/cron.ts +1 -1
  93. package/src/config/types/db.ts +56 -9
  94. package/src/config/types/index.ts +5 -0
  95. package/src/config/types/operation.ts +49 -9
  96. package/src/config/types/queue.ts +8 -8
  97. package/src/config/types/remote-rest.ts +1 -1
  98. package/src/config/types/remote-schema.ts +2 -2
  99. package/src/config/types/virtual-columns.ts +1 -1
  100. package/src/configuration/getSchemas/mergeEntities/index.ts +6 -0
  101. package/src/configuration/getSchemas/type-definition-generator/index.ts +13 -4
  102. package/src/configuration/rest/generateOpenAPI.ts +5 -9
  103. package/src/configuration/rest/handleRESTRequestFactory.ts +40 -14
  104. package/src/databases/common.ts +279 -83
  105. package/src/databases/directives.ts +26 -8
  106. package/src/databases/engines/mssql/connection.ts +1 -1
  107. package/src/databases/engines/mssql/getStructure.ts +1 -0
  108. package/src/databases/engines/mssql/query/index.ts +29 -11
  109. package/src/databases/engines/mysql/auth.ts +2 -2
  110. package/src/databases/engines/mysql/connection.ts +5 -4
  111. package/src/databases/engines/mysql/getStructure.ts +61 -29
  112. package/src/databases/engines/mysql/placeholders.ts +84 -0
  113. package/src/databases/engines/mysql/query/index.ts +49 -4
  114. package/src/databases/engines/postgresql/getStructure.ts +1 -0
  115. package/src/databases/engines/postgresql/query/index.ts +40 -2
  116. package/src/databases/high-level-operations.ts +25 -0
  117. package/src/databases/sqlTypeUtils.ts +3 -1
  118. package/src/subscriptions/strategies/index.ts +1 -1
  119. package/src/subscriptions/strategies/queue.ts +1 -1
  120. package/src/types/configuration.ts +7 -1
  121. package/src/types/resolver.ts +17 -1
  122. package/src/types/zod/configuration.ts +1 -1
  123. package/src/types/zod/db.ts +4 -0
@@ -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
- ["eq", "neq", "gt", "gte", "lt", "lte", "like", "in", "is_null", "is_not_null"].includes(key),
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 = Array.isArray(operand)
62
- ? operand.map((op) => {
63
- if (isString(op) && op.startsWith("$")) {
64
- const index = variablesDefinition.findIndex((v) => v.name === op.substring(1));
65
- return `${mappingDbTypeCharVar[dbType]}${index + 1}`;
66
- }
67
- return op;
68
- })
69
- : isString(operand) && operand.startsWith("$")
70
- ? `${mappingDbTypeCharVar[dbType]}${variablesDefinition.findIndex((v) => v.name === operand.substring(1)) + 1}`
71
- : operand;
72
-
73
- const condition = buildCondition(tableAlias, fieldName, operator, resolvedValue, dbType);
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 ${entities.queriesMap[fieldName]!.dottedQuotedName} ${nestedTableAlias}
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
- tableAlias: string,
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 `${tableAlias}.${wrappedField} = ${value}`;
247
+ return `${target} = ${value}`;
165
248
  case "neq":
166
- return `${tableAlias}.${wrappedField} <> ${value}`;
249
+ return `${target} <> ${value}`;
167
250
  case "gt":
168
- return `${tableAlias}.${wrappedField} > ${value}`;
251
+ return `${target} > ${value}`;
169
252
  case "gte":
170
- return `${tableAlias}.${wrappedField} >= ${value}`;
253
+ return `${target} >= ${value}`;
171
254
  case "lt":
172
- return `${tableAlias}.${wrappedField} < ${value}`;
255
+ return `${target} < ${value}`;
173
256
  case "lte":
174
- return `${tableAlias}.${wrappedField} <= ${value}`;
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 `${tableAlias}.${wrappedField} LIKE ${value}`;
262
+ return dbType === "mssql" ? `${target} LIKE ${value} ESCAPE '\\'` : `${target} LIKE ${value}`;
177
263
  case "in":
178
- return `${tableAlias}.${wrappedField} IN (${
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
- return value
187
- ? `${tableAlias}.${wrappedField} IS NOT NULL`
188
- : `${tableAlias}.${wrappedField} IS NULL`;
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
- return null;
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((p) => `${p.parentAlias}.${wrap(p.parentColumn)} = ${p.childAlias}.${wrap(p.childColumn)}`)
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
- pairsToAnd(
224
- relation.columns.map((c) => ({
225
- parentAlias,
226
- childAlias,
227
- parentColumn: c.source,
228
- childColumn: c.target,
229
- })),
230
- dbType,
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
- pairsToAnd(
235
- relation.columns.map((c) => ({
236
- parentAlias,
237
- childAlias,
238
- parentColumn: c.target,
239
- childColumn: c.source,
240
- })),
241
- dbType,
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
- const virtualColumn = entities.isVirtualColumn(field.name, colName);
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 `${tableAlias}.${wrap(colName)} ${sort} NULLS ${nulls}`;
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 ${tableAlias}.${wrap(colName)} IS NULL THEN ${nullFirst ? 0 : 1} ELSE ${nullFirst ? 1 : 0} END, ${tableAlias}.${wrap(colName)} ${sort}`;
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 `${tableAlias}.${wrap(colName)} ${sort}`;
548
+ return `${target} ${sort}`;
344
549
  } else {
345
550
  // Need to force NULL positioning with CASE
346
- return `CASE WHEN ${tableAlias}.${wrap(colName)} IS NULL THEN ${nullFirst ? 0 : 1} ELSE ${nullFirst ? 1 : 0} END, ${tableAlias}.${wrap(colName)} ${sort}`;
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
- // Return database-specific syntax
650
- return dbType === "mssql"
651
- ? `OFFSET ${offsetPlaceholder} ROWS FETCH NEXT ${limitPlaceholder} ROWS ONLY`
652
- : `LIMIT ${limitPlaceholder} OFFSET ${offsetPlaceholder}`;
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 (/^[$@]\d+$/.test(s)) return s;
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 withValue = directive.arguments!["with"] as string;
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(${sqlString(withValue)}, ${querySelector})`
40
- : `CONCAT(${querySelector}, ${sqlString(withValue)})`;
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
- } else {
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
- ? `RIGHT(REPLICATE(${char}, ${length}) + CAST(${querySelector} AS VARCHAR(MAX)), ${length})`
53
- : `LEFT(CAST(${querySelector} AS VARCHAR(MAX)) + REPLICATE(${char}, ${length}), ${length})`;
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?.recordset ?? false;
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