@graphoria/server 0.1.5 → 0.2.1

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 (135) hide show
  1. package/dist/src/analyzeQuery/resolveVariables.d.ts.map +1 -1
  2. package/dist/src/authentication/jwt.d.ts.map +1 -1
  3. package/dist/src/authentication/paseto.d.ts.map +1 -1
  4. package/dist/src/authentication/tokenRepository.d.ts +2 -1
  5. package/dist/src/authentication/tokenRepository.d.ts.map +1 -1
  6. package/dist/src/config/helpers/operationHelper.d.ts +60 -6
  7. package/dist/src/config/helpers/operationHelper.d.ts.map +1 -1
  8. package/dist/src/config/helpers/operationHelper.test.d.ts +2 -0
  9. package/dist/src/config/helpers/operationHelper.test.d.ts.map +1 -0
  10. package/dist/src/config/index.d.ts +65 -60
  11. package/dist/src/config/index.d.ts.map +1 -1
  12. package/dist/src/config/types/ai.d.ts +9 -3
  13. package/dist/src/config/types/ai.d.ts.map +1 -1
  14. package/dist/src/config/types/auth.d.ts +55 -15
  15. package/dist/src/config/types/auth.d.ts.map +1 -1
  16. package/dist/src/config/types/cron.d.ts +96 -2
  17. package/dist/src/config/types/cron.d.ts.map +1 -1
  18. package/dist/src/config/types/db.d.ts +102 -29
  19. package/dist/src/config/types/db.d.ts.map +1 -1
  20. package/dist/src/config/types/index.d.ts +1 -1
  21. package/dist/src/config/types/index.d.ts.map +1 -1
  22. package/dist/src/config/types/operation.d.ts +49 -17
  23. package/dist/src/config/types/operation.d.ts.map +1 -1
  24. package/dist/src/config/types/queue.d.ts +61 -14
  25. package/dist/src/config/types/queue.d.ts.map +1 -1
  26. package/dist/src/config/types/remote-rest.d.ts +10 -1
  27. package/dist/src/config/types/remote-rest.d.ts.map +1 -1
  28. package/dist/src/config/types/remote-schema.d.ts +13 -3
  29. package/dist/src/config/types/remote-schema.d.ts.map +1 -1
  30. package/dist/src/config/types/virtual-columns.d.ts +1 -1
  31. package/dist/src/config/types/virtual-columns.d.ts.map +1 -1
  32. package/dist/src/configuration/getSchemas/index.d.ts +6 -4
  33. package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
  34. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +9 -2
  35. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts.map +1 -1
  36. package/dist/src/configuration/getSchemas/mergeEntities/index.test.d.ts +2 -0
  37. package/dist/src/configuration/getSchemas/mergeEntities/index.test.d.ts.map +1 -0
  38. package/dist/src/configuration/getSchemas/type-definition-generator/index.d.ts.map +1 -1
  39. package/dist/src/configuration/index.d.ts +5 -4
  40. package/dist/src/configuration/index.d.ts.map +1 -1
  41. package/dist/src/configuration/rest/generateOpenAPI.d.ts.map +1 -1
  42. package/dist/src/configuration/rest/handleRESTRequestFactory.d.ts.map +1 -1
  43. package/dist/src/configuration/rest/parseStringParams.d.ts +15 -0
  44. package/dist/src/configuration/rest/parseStringParams.d.ts.map +1 -0
  45. package/dist/src/configuration/rest/parseStringParams.test.d.ts +2 -0
  46. package/dist/src/configuration/rest/parseStringParams.test.d.ts.map +1 -0
  47. package/dist/src/databases/common.d.ts +9 -1
  48. package/dist/src/databases/common.d.ts.map +1 -1
  49. package/dist/src/databases/directives.d.ts.map +1 -1
  50. package/dist/src/databases/engines/mssql/connection.d.ts +1 -1
  51. package/dist/src/databases/engines/mssql/connection.d.ts.map +1 -1
  52. package/dist/src/databases/engines/mssql/getStructure.d.ts +1 -0
  53. package/dist/src/databases/engines/mssql/getStructure.d.ts.map +1 -1
  54. package/dist/src/databases/engines/mssql/query/index.d.ts.map +1 -1
  55. package/dist/src/databases/engines/mysql/connection.d.ts.map +1 -1
  56. package/dist/src/databases/engines/mysql/getStructure.d.ts +1 -0
  57. package/dist/src/databases/engines/mysql/getStructure.d.ts.map +1 -1
  58. package/dist/src/databases/engines/mysql/placeholders.d.ts +22 -0
  59. package/dist/src/databases/engines/mysql/placeholders.d.ts.map +1 -0
  60. package/dist/src/databases/engines/mysql/placeholders.test.d.ts +2 -0
  61. package/dist/src/databases/engines/mysql/placeholders.test.d.ts.map +1 -0
  62. package/dist/src/databases/engines/mysql/query/index.d.ts.map +1 -1
  63. package/dist/src/databases/engines/postgresql/getStructure.d.ts +1 -0
  64. package/dist/src/databases/engines/postgresql/getStructure.d.ts.map +1 -1
  65. package/dist/src/databases/engines/postgresql/query/index.d.ts.map +1 -1
  66. package/dist/src/databases/high-level-operations.d.ts +1 -0
  67. package/dist/src/databases/high-level-operations.d.ts.map +1 -1
  68. package/dist/src/databases/identifierMapping.test.d.ts +2 -0
  69. package/dist/src/databases/identifierMapping.test.d.ts.map +1 -0
  70. package/dist/src/databases/metadata/structure.d.ts +1 -0
  71. package/dist/src/databases/metadata/structure.d.ts.map +1 -1
  72. package/dist/src/databases/sqlTypeUtils.d.ts.map +1 -1
  73. package/dist/src/databases/transformers/data-transformers.d.ts +1 -0
  74. package/dist/src/databases/transformers/data-transformers.d.ts.map +1 -1
  75. package/dist/src/databases/transformers/genResolverName.d.ts.map +1 -1
  76. package/dist/src/databases/transformers/graphqlName.d.ts +23 -0
  77. package/dist/src/databases/transformers/graphqlName.d.ts.map +1 -0
  78. package/dist/src/databases/transformers/graphqlName.test.d.ts +2 -0
  79. package/dist/src/databases/transformers/graphqlName.test.d.ts.map +1 -0
  80. package/dist/src/singletons/env.d.ts +2 -2
  81. package/dist/src/types/env.d.ts +56 -56
  82. package/dist/src/types/resolver.d.ts +12 -2
  83. package/dist/src/types/resolver.d.ts.map +1 -1
  84. package/dist/src/types/zod/auth.d.ts +6 -6
  85. package/dist/src/types/zod/configuration.d.ts +52 -52
  86. package/dist/src/types/zod/configuration.d.ts.map +1 -1
  87. package/dist/src/types/zod/cron.d.ts +1 -1
  88. package/dist/src/types/zod/cron.d.ts.map +1 -1
  89. package/dist/src/types/zod/db.d.ts +21 -12
  90. package/dist/src/types/zod/db.d.ts.map +1 -1
  91. package/dist/src/types/zod/queue.d.ts +38 -38
  92. package/dist/src/types/zod/strict-keys.test.d.ts +2 -0
  93. package/dist/src/types/zod/strict-keys.test.d.ts.map +1 -0
  94. package/dist/tsconfig.tsbuildinfo +1 -1
  95. package/package.json +2 -2
  96. package/src/analyzeQuery/resolveVariables.ts +37 -7
  97. package/src/authentication/jwt.ts +3 -1
  98. package/src/authentication/paseto.ts +3 -1
  99. package/src/authentication/tokenRepository.ts +12 -4
  100. package/src/config/helpers/operationHelper.ts +146 -5
  101. package/src/config/index.ts +139 -62
  102. package/src/config/types/ai.ts +2 -2
  103. package/src/config/types/auth.ts +37 -6
  104. package/src/config/types/cron.ts +1 -1
  105. package/src/config/types/db.ts +10 -10
  106. package/src/config/types/index.ts +2 -0
  107. package/src/config/types/operation.ts +49 -9
  108. package/src/config/types/queue.ts +8 -8
  109. package/src/config/types/remote-rest.ts +1 -1
  110. package/src/config/types/remote-schema.ts +2 -2
  111. package/src/config/types/virtual-columns.ts +1 -1
  112. package/src/configuration/getSchemas/mergeEntities/index.ts +32 -1
  113. package/src/configuration/getSchemas/type-definition-generator/index.ts +23 -9
  114. package/src/configuration/rest/generateOpenAPI.ts +5 -9
  115. package/src/configuration/rest/handleRESTRequestFactory.ts +38 -14
  116. package/src/configuration/rest/parseStringParams.ts +92 -0
  117. package/src/databases/common.ts +163 -66
  118. package/src/databases/directives.ts +26 -8
  119. package/src/databases/engines/mssql/connection.ts +1 -1
  120. package/src/databases/engines/mssql/query/index.ts +47 -16
  121. package/src/databases/engines/mysql/auth.ts +2 -2
  122. package/src/databases/engines/mysql/connection.ts +5 -4
  123. package/src/databases/engines/mysql/getStructure.ts +61 -30
  124. package/src/databases/engines/mysql/placeholders.ts +84 -0
  125. package/src/databases/engines/mysql/query/index.ts +68 -10
  126. package/src/databases/engines/postgresql/query/index.ts +59 -7
  127. package/src/databases/high-level-operations.ts +2 -2
  128. package/src/databases/sqlTypeUtils.ts +3 -1
  129. package/src/databases/transformers/genResolverName.ts +3 -3
  130. package/src/databases/transformers/graphqlName.ts +28 -0
  131. package/src/subscriptions/strategies/index.ts +1 -1
  132. package/src/subscriptions/strategies/queue.ts +1 -1
  133. package/src/types/resolver.ts +17 -1
  134. package/src/types/zod/configuration.ts +1 -1
  135. package/src/types/zod/db.ts +4 -0
@@ -18,6 +18,7 @@ import { databasesConnections, repositoryMap } from "../../singletons/databases"
18
18
  import { queueManager } from "../../singletons/queues";
19
19
  import { S200, S401, S404 } from "../../utils/responses";
20
20
  import { buildApiRoutes } from "../rest";
21
+ import { parseStringParams } from "./parseStringParams";
21
22
  import { logger } from "../../logging";
22
23
 
23
24
  // Build remote REST route matchers
@@ -114,24 +115,25 @@ export const handleRESTRequestFactory = (
114
115
  });
115
116
  }
116
117
 
117
- // Validate and parse path parameters with Zod
118
- const pathVariables = route.rest!.pathParams?.parse(pathParameters) as Record<
119
- string,
120
- unknown
121
- >;
118
+ // Validate and parse each REST parameter source with Zod. A source is
119
+ // left `undefined` when its schema is not configured; that `undefined` is
120
+ // forwarded to `beforeRequest` so the hook can tell the sources apart,
121
+ // while `allVariables` stays identical because spreading `undefined` is a
122
+ // no-op. Path and query values are strings on the wire, so
123
+ // `parseStringParams` converts the keys declared as `z.boolean()` first.
124
+ const pathVariables = parseStringParams(route.rest!.pathParams, pathParameters);
122
125
 
123
126
  const paramsQuery = new URLSearchParams(url.search);
124
127
  const paramsQueryDictionary = Object.fromEntries(paramsQuery.entries());
125
128
 
126
- const queryVariables = (route.rest!.queryParams?.parse(paramsQueryDictionary) ??
127
- {}) as Record<string, unknown>;
129
+ const queryVariables = parseStringParams(route.rest!.queryParams, paramsQueryDictionary);
128
130
 
129
- let bodyVariables: Record<string, unknown> = {};
131
+ let bodyVariables: Record<string, unknown> | undefined;
130
132
 
131
133
  if (req.method === "POST") {
132
134
  const body = req.body ? await req.json() : {};
133
135
 
134
- bodyVariables = (route.rest!.body?.parse(body) ?? {}) as Record<string, unknown>;
136
+ bodyVariables = route.rest!.body?.parse(body) as Record<string, unknown> | undefined;
135
137
  }
136
138
 
137
139
  // Prepare all variables for the request
@@ -150,6 +152,9 @@ export const handleRESTRequestFactory = (
150
152
  (await route.hooks?.beforeRequest?.(
151
153
  {
152
154
  input: allVariables,
155
+ pathParams: pathVariables,
156
+ queryParams: queryVariables,
157
+ body: bodyVariables,
153
158
  },
154
159
  routesInitDataPromises[route.routeKey],
155
160
  )) ?? allVariables;
@@ -286,6 +291,19 @@ export const handleRESTRequestFactory = (
286
291
  }
287
292
  }
288
293
 
294
+ // Apply the operation's afterRequest hook (if configured) to a GraphQL
295
+ // result. The hook receives the unwrapped `data` payload; its return
296
+ // replaces `data`, leaving the rest of the envelope (e.g. `sqlQueries`)
297
+ // intact.
298
+ const applyAfterRequest = async (result: Awaited<ReturnType<typeof gql.handler>>) => {
299
+ if (!route.hooks?.afterRequest) return result;
300
+
301
+ return {
302
+ ...result,
303
+ data: await route.hooks.afterRequest({ output: result.data }),
304
+ };
305
+ };
306
+
289
307
  // Check if this route has caching enabled
290
308
  const cache = route.routeKey ? getCache(route.routeKey) : undefined;
291
309
 
@@ -299,7 +317,8 @@ export const handleRESTRequestFactory = (
299
317
  role: session?.role,
300
318
  });
301
319
 
302
- // Try to get from cache first
320
+ // Try to get from cache first. A cached entry has already been through
321
+ // afterRequest, so serve it directly without re-running the hook.
303
322
  const cachedResult = await cache.get(cacheKey);
304
323
  if (cachedResult) {
305
324
  log.debug({ route: route.routeKey }, "rest cache hit");
@@ -308,10 +327,13 @@ export const handleRESTRequestFactory = (
308
327
  log.debug({ route: route.routeKey }, "rest cache miss");
309
328
 
310
329
  try {
311
- // Execute the GraphQL request
312
- const result = await gql.handler(queryAnalysis!, variables, req, session);
330
+ // Execute the GraphQL request, then transform via afterRequest before
331
+ // caching so hits and misses return the same shape.
332
+ const result = await applyAfterRequest(
333
+ await gql.handler(queryAnalysis!, variables, req, session),
334
+ );
313
335
 
314
- // Cache the result
336
+ // Cache the (already transformed) result
315
337
  await cache.set(cacheKey, result);
316
338
 
317
339
  return new S200(result);
@@ -321,7 +343,9 @@ export const handleRESTRequestFactory = (
321
343
  }
322
344
  } else if (queryAnalysis) {
323
345
  // No caching for this route, execute normally
324
- return new S200(await gql.handler(queryAnalysis, variables, req, session));
346
+ return new S200(
347
+ await applyAfterRequest(await gql.handler(queryAnalysis, variables, req, session)),
348
+ );
325
349
  }
326
350
 
327
351
  // Fallback - should not reach here if endpoint is properly configured
@@ -0,0 +1,92 @@
1
+ import type { z } from "zod";
2
+
3
+ /**
4
+ * The slice of Zod's runtime schema internals this module walks to find which
5
+ * keys of a `z.object()` ultimately validate a boolean.
6
+ */
7
+ type SchemaDef = {
8
+ type: string;
9
+ innerType?: { def: SchemaDef };
10
+ in?: { def: SchemaDef };
11
+ shape?: Record<string, { def: SchemaDef }>;
12
+ };
13
+
14
+ /** Wrappers that keep the validated type of the schema they wrap. */
15
+ const wrappers = new Set([
16
+ "optional",
17
+ "nullable",
18
+ "nonoptional",
19
+ "default",
20
+ "prefault",
21
+ "catch",
22
+ "readonly",
23
+ ]);
24
+
25
+ /** Accepted spellings, matching the ones `z.stringbool()` recognizes. */
26
+ const truthy = new Set(["true", "1", "yes", "on", "y", "enabled"]);
27
+ const falsy = new Set(["false", "0", "no", "off", "n", "disabled"]);
28
+
29
+ const defOf = (schema: z.ZodType) => (schema as unknown as { def: SchemaDef }).def;
30
+
31
+ const resolveType = (def: SchemaDef): string => {
32
+ if (wrappers.has(def.type) && def.innerType) return resolveType(def.innerType.def);
33
+ // A pipe validates its input side, so `z.stringbool()` resolves to a string.
34
+ if (def.type === "pipe" && def.in) return resolveType(def.in.def);
35
+
36
+ return def.type;
37
+ };
38
+
39
+ // Schemas are created once at config time, so the key scan is done once per schema.
40
+ const booleanKeysCache = new WeakMap<z.ZodType, Set<string>>();
41
+
42
+ const booleanKeys = (schema: z.ZodType) => {
43
+ const cached = booleanKeysCache.get(schema);
44
+ if (cached) return cached;
45
+
46
+ const keys = new Set<string>();
47
+ for (const [key, field] of Object.entries(defOf(schema).shape ?? {})) {
48
+ if (resolveType(field.def) === "boolean") keys.add(key);
49
+ }
50
+
51
+ booleanKeysCache.set(schema, keys);
52
+
53
+ return keys;
54
+ };
55
+
56
+ /**
57
+ * Parses path or query parameters, which always arrive as strings.
58
+ *
59
+ * A parameter declared as `z.boolean()` — the declaration that makes the
60
+ * OpenAPI docs render a true/false picker instead of a free-text box — would
61
+ * never parse on its own, so those keys (and only those) are converted before
62
+ * validation. A value that spells neither true nor false is left as the string
63
+ * it was, so Zod still reports it as invalid.
64
+ *
65
+ * Returns `undefined` when no schema is configured for that source, which is
66
+ * what the REST handler forwards to `beforeRequest`.
67
+ */
68
+ export const parseStringParams = (
69
+ schema: z.ZodType | undefined,
70
+ values: Record<string, string | string[]>,
71
+ ): Record<string, unknown> | undefined => {
72
+ if (!schema) return undefined;
73
+
74
+ const keys = booleanKeys(schema);
75
+
76
+ if (keys.size === 0) return schema.parse(values) as Record<string, unknown>;
77
+
78
+ const coerced: Record<string, unknown> = { ...values };
79
+
80
+ for (const key of keys) {
81
+ const value = coerced[key];
82
+
83
+ if (typeof value !== "string") continue;
84
+
85
+ const spelling = value.toLowerCase();
86
+
87
+ if (truthy.has(spelling)) coerced[key] = true;
88
+ else if (falsy.has(spelling)) coerced[key] = false;
89
+ }
90
+
91
+ return schema.parse(coerced) as Record<string, unknown>;
92
+ };
@@ -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,42 @@ 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)(sqlColumnName(entities, tableName, colName))}`;
228
+ };
229
+
230
+ /**
231
+ * The SQL identifier a GraphQL field name compiles to. A column GraphQL cannot
232
+ * spell is exposed under a sanitised field name, so the name the client sent is
233
+ * not necessarily the name the database knows.
234
+ */
235
+ export const sqlColumnName = (
236
+ entities: MergedEntities,
237
+ tableName: string | undefined,
238
+ fieldName: string,
239
+ ): string => (tableName ? entities.columnSqlName(tableName, fieldName) : fieldName);
240
+
145
241
  // Helper to resolve variable values
146
242
  const resolveVariable = <T>(value: unknown, variables: Record<string, unknown>): T => {
147
243
  if (isString(value) && value.startsWith("$")) {
@@ -151,43 +247,51 @@ const resolveVariable = <T>(value: unknown, variables: Record<string, unknown>):
151
247
  };
152
248
 
153
249
  const buildCondition = (
154
- tableAlias: string,
250
+ target: string,
155
251
  field: string,
156
252
  operator: string,
157
253
  value: unknown,
158
254
  dbType: DatabaseType,
159
255
  ): string | null => {
160
- const wrappedField = wrapIdentifierFp(dbType)(field);
161
-
162
256
  switch (operator) {
163
257
  case "eq":
164
- return `${tableAlias}.${wrappedField} = ${value}`;
258
+ return `${target} = ${value}`;
165
259
  case "neq":
166
- return `${tableAlias}.${wrappedField} <> ${value}`;
260
+ return `${target} <> ${value}`;
167
261
  case "gt":
168
- return `${tableAlias}.${wrappedField} > ${value}`;
262
+ return `${target} > ${value}`;
169
263
  case "gte":
170
- return `${tableAlias}.${wrappedField} >= ${value}`;
264
+ return `${target} >= ${value}`;
171
265
  case "lt":
172
- return `${tableAlias}.${wrappedField} < ${value}`;
266
+ return `${target} < ${value}`;
173
267
  case "lte":
174
- return `${tableAlias}.${wrappedField} <= ${value}`;
268
+ return `${target} <= ${value}`;
269
+ // PostgreSQL and MySQL treat a backslash as the LIKE escape by default;
270
+ // T-SQL has no default escape character at all, so `100\%` matched a literal
271
+ // backslash and found nothing. Declare the escape to make the three agree.
175
272
  case "like":
176
- return `${tableAlias}.${wrappedField} LIKE ${value}`;
273
+ return dbType === "mssql" ? `${target} LIKE ${value} ESCAPE '\\'` : `${target} LIKE ${value}`;
177
274
  case "in":
178
- return `${tableAlias}.${wrappedField} IN (${
179
- Array.isArray(value) ? value.join(", ") : value
180
- })`;
275
+ return `${target} IN (${Array.isArray(value) ? value.join(", ") : value})`;
181
276
  case "is_null":
182
- return value
183
- ? `${tableAlias}.${wrappedField} IS NULL`
184
- : `${tableAlias}.${wrappedField} IS NOT NULL`;
277
+ return value ? `${target} IS NULL` : `${target} IS NOT NULL`;
185
278
  case "is_not_null":
186
- return value
187
- ? `${tableAlias}.${wrappedField} IS NOT NULL`
188
- : `${tableAlias}.${wrappedField} IS NULL`;
279
+ case "not_null":
280
+ return value ? `${target} IS NOT NULL` : `${target} IS NULL`;
281
+ case "between": {
282
+ const bounds = Array.isArray(value) ? value : [];
283
+ if (bounds.length !== 2) {
284
+ throw new Error(
285
+ `Filter operator "between" on column "${field}" expects exactly two values, received ${bounds.length}`,
286
+ );
287
+ }
288
+ return `${target} BETWEEN ${bounds[0]} AND ${bounds[1]}`;
289
+ }
290
+ // Returning null here would drop the condition, so a filter Graphoria cannot
291
+ // honour would widen the result set instead of failing — a role filter naming
292
+ // an unimplemented operator would return the whole table.
189
293
  default:
190
- return null;
294
+ throw new Error(`Unsupported filter operator "${operator}" on column "${field}"`);
191
295
  }
192
296
  };
193
297
 
@@ -377,6 +481,7 @@ export const buildWhereClauseFp =
377
481
  tableAlias,
378
482
  level + 1,
379
483
  aliasMap,
484
+ field.name ?? aliasMap[tableAlias],
380
485
  ),
381
486
  parentTableName && parentTableAlias
382
487
  ? findJoinCondition(
@@ -413,7 +518,6 @@ const parseOrderDirection = (direction: string): { sort: string; nulls?: string
413
518
  export const buildOrderByClauseFp =
414
519
  (dbType: DatabaseType = "pg") =>
415
520
  (entities: MergedEntities, field: SelectionAnalysis, tableAlias: string): string => {
416
- const wrap = wrapIdentifierFp(dbType);
417
521
  if (field.arguments?.["orderBy"]) {
418
522
  const orderBy = field.arguments?.["orderBy"];
419
523
  // orderBy is an array or object of { column: direction } - no variable resolution needed
@@ -433,16 +537,18 @@ export const buildOrderByClauseFp =
433
537
  const [colName, direction] = Object.entries(ob)[0];
434
538
  const { sort, nulls } = parseOrderDirection(direction as string);
435
539
 
436
- const virtualColumn = entities.isVirtualColumn(field.name, colName);
540
+ // Resolved once, above the NULLS branch: a virtual column has to expand
541
+ // to its expression in every emission below, not only the default one.
542
+ const target = columnTargetFp(dbType)(entities, field.name, colName, tableAlias);
437
543
 
438
544
  if (nulls) {
439
545
  if (dbType === "pg") {
440
546
  // PostgreSQL supports NULLS FIRST/LAST natively
441
- return `${tableAlias}.${wrap(colName)} ${sort} NULLS ${nulls}`;
547
+ return `${target} ${sort} NULLS ${nulls}`;
442
548
  } else if (dbType === "mysql") {
443
549
  // MySQL: Use CASE statement for NULL handling
444
550
  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}`;
551
+ return `CASE WHEN ${target} IS NULL THEN ${nullFirst ? 0 : 1} ELSE ${nullFirst ? 1 : 0} END, ${target} ${sort}`;
446
552
  } else {
447
553
  // MSSQL requires CASE statements for NULL handling
448
554
  const nullFirst = nulls === "FIRST";
@@ -450,23 +556,16 @@ export const buildOrderByClauseFp =
450
556
 
451
557
  if ((nullFirst && isAsc) || (!nullFirst && !isAsc)) {
452
558
  // NULLs naturally come first in this combination
453
- return `${tableAlias}.${wrap(colName)} ${sort}`;
559
+ return `${target} ${sort}`;
454
560
  } else {
455
561
  // 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}`;
562
+ return `CASE WHEN ${target} IS NULL THEN ${nullFirst ? 0 : 1} ELSE ${nullFirst ? 1 : 0} END, ${target} ${sort}`;
457
563
  }
458
564
  }
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
565
  }
566
+
567
+ // No explicit NULL handling - use database defaults
568
+ return `${target} ${sort}`;
470
569
  })
471
570
  .join(", ");
472
571
 
@@ -612,7 +711,7 @@ export const processFieldSelectionsFp =
612
711
  selectClauses.push([`${colName}`, `(${vc.expression})`]);
613
712
  }
614
713
  } else {
615
- let querySelector = `${tableAlias}.${wrapIdentifierFp(dbType)(sel.name)}`;
714
+ let querySelector = `${tableAlias}.${wrapIdentifierFp(dbType)(sqlColumnName(entities, tableName, sel.name))}`;
616
715
 
617
716
  // Apply all directives using the handler registry
618
717
  querySelector = applyDirectives(
@@ -733,14 +832,6 @@ export const buildPaginationClauseFp =
733
832
  (field: SelectionAnalysis, variablesDefinition: VariableDefinition[] = []): string => {
734
833
  const args = field.arguments;
735
834
 
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
835
  if (!args || args["limit"] === undefined) {
745
836
  return "";
746
837
  }
@@ -756,10 +847,16 @@ export const buildPaginationClauseFp =
756
847
  ? toPositionalPlaceholder(args["offset"], variablesDefinition, dbType)
757
848
  : "0";
758
849
 
759
- // Return database-specific syntax
760
- return dbType === "mssql"
761
- ? `OFFSET ${offsetPlaceholder} ROWS FETCH NEXT ${limitPlaceholder} ROWS ONLY`
762
- : `LIMIT ${limitPlaceholder} OFFSET ${offsetPlaceholder}`;
850
+ if (dbType !== "mssql") {
851
+ return `LIMIT ${limitPlaceholder} OFFSET ${offsetPlaceholder}`;
852
+ }
853
+
854
+ // T-SQL only accepts OFFSET/FETCH after an ORDER BY. Dropping the limit when
855
+ // the query has none returned the whole table instead, so supply the
856
+ // order-agnostic ORDER BY that T-SQL accepts for exactly this case.
857
+ const orderByFallback = args["orderBy"] === undefined ? "ORDER BY (SELECT NULL) " : "";
858
+
859
+ return `${orderByFallback}OFFSET ${offsetPlaceholder} ROWS FETCH NEXT ${limitPlaceholder} ROWS ONLY`;
763
860
  };
764
861
 
765
862
  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;