@graphoria/server 0.1.5 → 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 (101) 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 +102 -29
  15. package/dist/src/config/types/db.d.ts.map +1 -1
  16. package/dist/src/config/types/index.d.ts +1 -1
  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 +4 -4
  29. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +2 -2
  30. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts.map +1 -1
  31. package/dist/src/configuration/getSchemas/mergeEntities/index.test.d.ts +2 -0
  32. package/dist/src/configuration/getSchemas/mergeEntities/index.test.d.ts.map +1 -0
  33. package/dist/src/configuration/getSchemas/type-definition-generator/index.d.ts.map +1 -1
  34. package/dist/src/configuration/index.d.ts +4 -4
  35. package/dist/src/configuration/rest/generateOpenAPI.d.ts.map +1 -1
  36. package/dist/src/configuration/rest/handleRESTRequestFactory.d.ts.map +1 -1
  37. package/dist/src/databases/common.d.ts +3 -1
  38. package/dist/src/databases/common.d.ts.map +1 -1
  39. package/dist/src/databases/core/query-builder.d.ts +1 -1
  40. package/dist/src/databases/directives.d.ts.map +1 -1
  41. package/dist/src/databases/engines/mssql/connection.d.ts +1 -1
  42. package/dist/src/databases/engines/mssql/connection.d.ts.map +1 -1
  43. package/dist/src/databases/engines/mssql/query/index.d.ts.map +1 -1
  44. package/dist/src/databases/engines/mysql/connection.d.ts.map +1 -1
  45. package/dist/src/databases/engines/mysql/getStructure.d.ts.map +1 -1
  46. package/dist/src/databases/engines/mysql/placeholders.d.ts +22 -0
  47. package/dist/src/databases/engines/mysql/placeholders.d.ts.map +1 -0
  48. package/dist/src/databases/engines/mysql/placeholders.test.d.ts +2 -0
  49. package/dist/src/databases/engines/mysql/placeholders.test.d.ts.map +1 -0
  50. package/dist/src/databases/engines/mysql/query/index.d.ts.map +1 -1
  51. package/dist/src/databases/engines/postgresql/query/index.d.ts.map +1 -1
  52. package/dist/src/databases/high-level-operations.d.ts +1 -1
  53. package/dist/src/databases/sqlTypeUtils.d.ts.map +1 -1
  54. package/dist/src/databases/transformers/data-transformers.d.ts +2 -2
  55. package/dist/src/singletons/env.d.ts +3 -3
  56. package/dist/src/types/env.d.ts +52 -52
  57. package/dist/src/types/resolver.d.ts +12 -2
  58. package/dist/src/types/resolver.d.ts.map +1 -1
  59. package/dist/src/types/zod/auth.d.ts +6 -6
  60. package/dist/src/types/zod/configuration.d.ts +46 -46
  61. package/dist/src/types/zod/configuration.d.ts.map +1 -1
  62. package/dist/src/types/zod/cron.d.ts +1 -1
  63. package/dist/src/types/zod/cron.d.ts.map +1 -1
  64. package/dist/src/types/zod/db.d.ts +12 -12
  65. package/dist/src/types/zod/queue.d.ts +26 -26
  66. package/dist/src/types/zod/strict-keys.test.d.ts +2 -0
  67. package/dist/src/types/zod/strict-keys.test.d.ts.map +1 -0
  68. package/dist/tsconfig.tsbuildinfo +1 -1
  69. package/package.json +1 -1
  70. package/src/analyzeQuery/resolveVariables.ts +37 -7
  71. package/src/config/helpers/operationHelper.ts +146 -5
  72. package/src/config/index.ts +139 -62
  73. package/src/config/types/ai.ts +2 -2
  74. package/src/config/types/auth.ts +37 -6
  75. package/src/config/types/cron.ts +1 -1
  76. package/src/config/types/db.ts +10 -10
  77. package/src/config/types/index.ts +2 -0
  78. package/src/config/types/operation.ts +49 -9
  79. package/src/config/types/queue.ts +8 -8
  80. package/src/config/types/remote-rest.ts +1 -1
  81. package/src/config/types/remote-schema.ts +2 -2
  82. package/src/config/types/virtual-columns.ts +1 -1
  83. package/src/configuration/getSchemas/mergeEntities/index.ts +6 -0
  84. package/src/configuration/getSchemas/type-definition-generator/index.ts +13 -4
  85. package/src/configuration/rest/generateOpenAPI.ts +5 -9
  86. package/src/configuration/rest/handleRESTRequestFactory.ts +40 -14
  87. package/src/databases/common.ts +151 -65
  88. package/src/databases/directives.ts +26 -8
  89. package/src/databases/engines/mssql/connection.ts +1 -1
  90. package/src/databases/engines/mssql/query/index.ts +29 -11
  91. package/src/databases/engines/mysql/auth.ts +2 -2
  92. package/src/databases/engines/mysql/connection.ts +5 -4
  93. package/src/databases/engines/mysql/getStructure.ts +61 -30
  94. package/src/databases/engines/mysql/placeholders.ts +84 -0
  95. package/src/databases/engines/mysql/query/index.ts +49 -4
  96. package/src/databases/engines/postgresql/query/index.ts +40 -2
  97. package/src/databases/sqlTypeUtils.ts +3 -1
  98. package/src/subscriptions/strategies/index.ts +1 -1
  99. package/src/subscriptions/strategies/queue.ts +1 -1
  100. package/src/types/resolver.ts +17 -1
  101. package/src/types/zod/configuration.ts +1 -1
@@ -4,6 +4,7 @@ import type { SelectionAnalysis, VariableDefinition } from "../analyzeQuery/type
4
4
  import type { MergedEntities } from "../configuration/getSchemas/mergeEntities";
5
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,43 +236,51 @@ 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
 
@@ -377,6 +470,7 @@ export const buildWhereClauseFp =
377
470
  tableAlias,
378
471
  level + 1,
379
472
  aliasMap,
473
+ field.name ?? aliasMap[tableAlias],
380
474
  ),
381
475
  parentTableName && parentTableAlias
382
476
  ? findJoinCondition(
@@ -413,7 +507,6 @@ const parseOrderDirection = (direction: string): { sort: string; nulls?: string
413
507
  export const buildOrderByClauseFp =
414
508
  (dbType: DatabaseType = "pg") =>
415
509
  (entities: MergedEntities, field: SelectionAnalysis, tableAlias: string): string => {
416
- const wrap = wrapIdentifierFp(dbType);
417
510
  if (field.arguments?.["orderBy"]) {
418
511
  const orderBy = field.arguments?.["orderBy"];
419
512
  // orderBy is an array or object of { column: direction } - no variable resolution needed
@@ -433,16 +526,18 @@ export const buildOrderByClauseFp =
433
526
  const [colName, direction] = Object.entries(ob)[0];
434
527
  const { sort, nulls } = parseOrderDirection(direction as string);
435
528
 
436
- 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);
437
532
 
438
533
  if (nulls) {
439
534
  if (dbType === "pg") {
440
535
  // PostgreSQL supports NULLS FIRST/LAST natively
441
- return `${tableAlias}.${wrap(colName)} ${sort} NULLS ${nulls}`;
536
+ return `${target} ${sort} NULLS ${nulls}`;
442
537
  } else if (dbType === "mysql") {
443
538
  // MySQL: Use CASE statement for NULL handling
444
539
  const nullFirst = nulls === "FIRST";
445
- 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}`;
446
541
  } else {
447
542
  // MSSQL requires CASE statements for NULL handling
448
543
  const nullFirst = nulls === "FIRST";
@@ -450,23 +545,16 @@ export const buildOrderByClauseFp =
450
545
 
451
546
  if ((nullFirst && isAsc) || (!nullFirst && !isAsc)) {
452
547
  // NULLs naturally come first in this combination
453
- return `${tableAlias}.${wrap(colName)} ${sort}`;
548
+ return `${target} ${sort}`;
454
549
  } else {
455
550
  // Need to force NULL positioning with CASE
456
- 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}`;
457
552
  }
458
553
  }
459
- } else {
460
- // No explicit NULL handling - use database defaults
461
- if (virtualColumn) {
462
- const vc = virtualColumn as VirtualColumn;
463
- return `(
464
- ${vc.function ? `${vc.function}(${vc.params?.join(", ")})` : `${vc.expression}`}
465
- ) ${sort}`;
466
- }
467
-
468
- return `${tableAlias}.${wrap(colName)} ${sort}`;
469
554
  }
555
+
556
+ // No explicit NULL handling - use database defaults
557
+ return `${target} ${sort}`;
470
558
  })
471
559
  .join(", ");
472
560
 
@@ -733,14 +821,6 @@ export const buildPaginationClauseFp =
733
821
  (field: SelectionAnalysis, variablesDefinition: VariableDefinition[] = []): string => {
734
822
  const args = field.arguments;
735
823
 
736
- // MSSQL requires ORDER BY for pagination
737
- if (
738
- dbType === "mssql" &&
739
- (!args || args["limit"] === undefined || args["orderBy"] === undefined)
740
- ) {
741
- return "";
742
- }
743
-
744
824
  if (!args || args["limit"] === undefined) {
745
825
  return "";
746
826
  }
@@ -756,10 +836,16 @@ export const buildPaginationClauseFp =
756
836
  ? toPositionalPlaceholder(args["offset"], variablesDefinition, dbType)
757
837
  : "0";
758
838
 
759
- // Return database-specific syntax
760
- return dbType === "mssql"
761
- ? `OFFSET ${offsetPlaceholder} ROWS FETCH NEXT ${limitPlaceholder} ROWS ONLY`
762
- : `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`;
763
849
  };
764
850
 
765
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;
@@ -19,6 +19,8 @@ import {
19
19
  wrapIdentifierMSSQL,
20
20
  } from "../../../common";
21
21
 
22
+ import { applyDirectives } from "../../../directives";
23
+
22
24
  // FOR JSON PATH uses the unbracketed alias as the JSON key, so bracket-quoting
23
25
  // (required for reserved words like PRINT) never leaks into results.
24
26
 
@@ -44,9 +46,13 @@ const buildAggregationCTE = (
44
46
  selectClauses.push(`COUNT(*) AS ${agg.alias}`);
45
47
  } else {
46
48
  const func = agg.name.toUpperCase();
47
- selectClauses.push(
48
- `${func}(${tableAlias}.${wrapIdentifierMSSQL(agg.fieldName)}) AS ${agg.alias}`,
49
- );
49
+ // SQL Server's AVG over an integer column is integer division — AVG of
50
+ // 1, 5, 5, 2 is 3, not 3.25 so the average has to be taken on a float.
51
+ const operand =
52
+ agg.name === "avg"
53
+ ? `CAST(${tableAlias}.${wrapIdentifierMSSQL(agg.fieldName)} AS FLOAT)`
54
+ : `${tableAlias}.${wrapIdentifierMSSQL(agg.fieldName)}`;
55
+ selectClauses.push(`${func}(${operand}) AS ${agg.alias}`);
50
56
  }
51
57
  });
52
58
 
@@ -64,6 +70,7 @@ const buildAggregationCTE = (
64
70
  // Build the main query for grouped results
65
71
  const buildGroupedQuery = (
66
72
  entities: MergedEntities,
73
+ variablesDefinition: VariableDefinition[],
67
74
  field: SelectionAnalysis,
68
75
  groupByInfo: GroupByInfo,
69
76
  dottedQuotedName: string,
@@ -78,10 +85,15 @@ const buildGroupedQuery = (
78
85
  if (hasKey) {
79
86
  // Add key object with group by fields
80
87
  const keyFields = keys
81
- .map(
82
- (field) =>
83
- `${cteAlias}.${wrapIdentifierMSSQL(field.name)} AS ${wrapIdentifierMSSQL(field.alias || field.name)}`,
84
- )
88
+ .map((field) => {
89
+ const selector = applyDirectives(
90
+ `${cteAlias}.${wrapIdentifierMSSQL(field.name)}`,
91
+ field.directives,
92
+ "mssql",
93
+ variablesDefinition,
94
+ );
95
+ return `${selector} AS ${wrapIdentifierMSSQL(field.alias || field.name)}`;
96
+ })
85
97
  .join(", ");
86
98
 
87
99
  selectClauses.push(
@@ -107,10 +119,15 @@ const buildGroupedQuery = (
107
119
  if (itemsSelection?.selections) {
108
120
  const itemFields = itemsSelection.selections
109
121
  .filter((sel) => !isAggregationField(sel.name) && sel.name !== "items")
110
- .map(
111
- (sel) =>
112
- `${tableAlias}.${wrapIdentifierMSSQL(sel.name)} AS ${wrapIdentifierMSSQL(sel.alias || sel.name)}`,
113
- )
122
+ .map((sel) => {
123
+ const selector = applyDirectives(
124
+ `${tableAlias}.${wrapIdentifierMSSQL(sel.name)}`,
125
+ sel.directives,
126
+ "mssql",
127
+ variablesDefinition,
128
+ );
129
+ return `${selector} AS ${wrapIdentifierMSSQL(sel.alias || sel.name)}`;
130
+ })
114
131
  .join(", ");
115
132
 
116
133
  if (itemFields) {
@@ -265,6 +282,7 @@ export const buildSQLForField = (
265
282
  // Handle GROUP BY aggregation query
266
283
  const mainQuery = buildGroupedQuery(
267
284
  entities,
285
+ variablesDefinition,
268
286
  field,
269
287
  groupByInfo,
270
288
  dottedQuotedName,
@@ -41,7 +41,7 @@ export const checkUserCredentials = async (
41
41
  const schema = assertSafeIdentifier(auth.schema!, "schema");
42
42
 
43
43
  const result = await pool.unsafe<UserRecord[]>(
44
- `SELECT * FROM \`${schema}\`.\`user\` WHERE username = $1 AND is_active = TRUE`,
44
+ `SELECT * FROM \`${schema}\`.\`user\` WHERE username = ? AND is_active = TRUE`,
45
45
  [username],
46
46
  );
47
47
 
@@ -94,7 +94,7 @@ export const insertAuthUser = async (
94
94
 
95
95
  await pool.unsafe(
96
96
  `INSERT INTO \`${schema}\`.\`user\` (username, password, role, is_active, claims)
97
- VALUES ($1, $2, $3, TRUE, $4)`,
97
+ VALUES (?, ?, ?, TRUE, ?)`,
98
98
  [input.username, hashed, input.role, JSON.stringify(input.claims ?? {})],
99
99
  );
100
100
  };
@@ -6,6 +6,7 @@ import type { Database } from "../../../types/configuration";
6
6
  import type { ProcedureResolver } from "../../../types/db";
7
7
 
8
8
  import { databasesConnections } from "../../../singletons/databases";
9
+ import { toMySQLPlaceholders } from "./placeholders";
9
10
 
10
11
  export const getPool = async (db: Database) => {
11
12
  const opts = db.connectionOptions as BunSQLConnectionOptions | undefined;
@@ -42,10 +43,10 @@ export const executeQueryFactory =
42
43
  ) => {
43
44
  const pool = singleQuery ? await getPool(db) : await getPoolSingleton(db);
44
45
 
45
- const result = await pool.unsafe<T>(
46
- query,
47
- variablesDefinition.map((v) => values[v.name]),
48
- );
46
+ // Bun's MySQL adapter binds `?` positionally; the builders emit `$n`.
47
+ const bound = toMySQLPlaceholders(query, variablesDefinition, values);
48
+
49
+ const result = await pool.unsafe<T>(bound.query, bound.params);
49
50
 
50
51
  if (singleQuery) {
51
52
  await pool.close();