@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.
- package/dist/src/analyzeQuery/resolveVariables.d.ts.map +1 -1
- package/dist/src/authentication/jwt.d.ts.map +1 -1
- package/dist/src/authentication/paseto.d.ts.map +1 -1
- package/dist/src/authentication/tokenRepository.d.ts +2 -1
- package/dist/src/authentication/tokenRepository.d.ts.map +1 -1
- package/dist/src/config/helpers/operationHelper.d.ts +60 -6
- package/dist/src/config/helpers/operationHelper.d.ts.map +1 -1
- package/dist/src/config/helpers/operationHelper.test.d.ts +2 -0
- package/dist/src/config/helpers/operationHelper.test.d.ts.map +1 -0
- package/dist/src/config/index.d.ts +65 -60
- package/dist/src/config/index.d.ts.map +1 -1
- package/dist/src/config/types/ai.d.ts +9 -3
- package/dist/src/config/types/ai.d.ts.map +1 -1
- package/dist/src/config/types/auth.d.ts +55 -15
- package/dist/src/config/types/auth.d.ts.map +1 -1
- package/dist/src/config/types/cron.d.ts +96 -2
- package/dist/src/config/types/cron.d.ts.map +1 -1
- package/dist/src/config/types/db.d.ts +102 -29
- package/dist/src/config/types/db.d.ts.map +1 -1
- package/dist/src/config/types/index.d.ts +1 -1
- package/dist/src/config/types/index.d.ts.map +1 -1
- package/dist/src/config/types/operation.d.ts +49 -17
- package/dist/src/config/types/operation.d.ts.map +1 -1
- package/dist/src/config/types/queue.d.ts +61 -14
- package/dist/src/config/types/queue.d.ts.map +1 -1
- package/dist/src/config/types/remote-rest.d.ts +10 -1
- package/dist/src/config/types/remote-rest.d.ts.map +1 -1
- package/dist/src/config/types/remote-schema.d.ts +13 -3
- package/dist/src/config/types/remote-schema.d.ts.map +1 -1
- package/dist/src/config/types/virtual-columns.d.ts +1 -1
- package/dist/src/config/types/virtual-columns.d.ts.map +1 -1
- package/dist/src/configuration/getSchemas/index.d.ts +6 -4
- package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
- package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +9 -2
- package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts.map +1 -1
- package/dist/src/configuration/getSchemas/mergeEntities/index.test.d.ts +2 -0
- package/dist/src/configuration/getSchemas/mergeEntities/index.test.d.ts.map +1 -0
- package/dist/src/configuration/getSchemas/type-definition-generator/index.d.ts.map +1 -1
- package/dist/src/configuration/index.d.ts +5 -4
- package/dist/src/configuration/index.d.ts.map +1 -1
- package/dist/src/configuration/rest/generateOpenAPI.d.ts.map +1 -1
- package/dist/src/configuration/rest/handleRESTRequestFactory.d.ts.map +1 -1
- package/dist/src/configuration/rest/parseStringParams.d.ts +15 -0
- package/dist/src/configuration/rest/parseStringParams.d.ts.map +1 -0
- package/dist/src/configuration/rest/parseStringParams.test.d.ts +2 -0
- package/dist/src/configuration/rest/parseStringParams.test.d.ts.map +1 -0
- package/dist/src/databases/common.d.ts +9 -1
- package/dist/src/databases/common.d.ts.map +1 -1
- package/dist/src/databases/directives.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/connection.d.ts +1 -1
- package/dist/src/databases/engines/mssql/connection.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/getStructure.d.ts +1 -0
- package/dist/src/databases/engines/mssql/getStructure.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/query/index.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/connection.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/getStructure.d.ts +1 -0
- package/dist/src/databases/engines/mysql/getStructure.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/placeholders.d.ts +22 -0
- package/dist/src/databases/engines/mysql/placeholders.d.ts.map +1 -0
- package/dist/src/databases/engines/mysql/placeholders.test.d.ts +2 -0
- package/dist/src/databases/engines/mysql/placeholders.test.d.ts.map +1 -0
- package/dist/src/databases/engines/mysql/query/index.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/getStructure.d.ts +1 -0
- package/dist/src/databases/engines/postgresql/getStructure.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/query/index.d.ts.map +1 -1
- package/dist/src/databases/high-level-operations.d.ts +1 -0
- package/dist/src/databases/high-level-operations.d.ts.map +1 -1
- package/dist/src/databases/identifierMapping.test.d.ts +2 -0
- package/dist/src/databases/identifierMapping.test.d.ts.map +1 -0
- package/dist/src/databases/metadata/structure.d.ts +1 -0
- package/dist/src/databases/metadata/structure.d.ts.map +1 -1
- package/dist/src/databases/sqlTypeUtils.d.ts.map +1 -1
- package/dist/src/databases/transformers/data-transformers.d.ts +1 -0
- package/dist/src/databases/transformers/data-transformers.d.ts.map +1 -1
- package/dist/src/databases/transformers/genResolverName.d.ts.map +1 -1
- package/dist/src/databases/transformers/graphqlName.d.ts +23 -0
- package/dist/src/databases/transformers/graphqlName.d.ts.map +1 -0
- package/dist/src/databases/transformers/graphqlName.test.d.ts +2 -0
- package/dist/src/databases/transformers/graphqlName.test.d.ts.map +1 -0
- package/dist/src/singletons/env.d.ts +2 -2
- package/dist/src/types/env.d.ts +56 -56
- package/dist/src/types/resolver.d.ts +12 -2
- package/dist/src/types/resolver.d.ts.map +1 -1
- package/dist/src/types/zod/auth.d.ts +6 -6
- package/dist/src/types/zod/configuration.d.ts +52 -52
- package/dist/src/types/zod/configuration.d.ts.map +1 -1
- package/dist/src/types/zod/cron.d.ts +1 -1
- package/dist/src/types/zod/cron.d.ts.map +1 -1
- package/dist/src/types/zod/db.d.ts +21 -12
- package/dist/src/types/zod/db.d.ts.map +1 -1
- package/dist/src/types/zod/queue.d.ts +38 -38
- package/dist/src/types/zod/strict-keys.test.d.ts +2 -0
- package/dist/src/types/zod/strict-keys.test.d.ts.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/analyzeQuery/resolveVariables.ts +37 -7
- package/src/authentication/jwt.ts +3 -1
- package/src/authentication/paseto.ts +3 -1
- package/src/authentication/tokenRepository.ts +12 -4
- package/src/config/helpers/operationHelper.ts +146 -5
- package/src/config/index.ts +139 -62
- package/src/config/types/ai.ts +2 -2
- package/src/config/types/auth.ts +37 -6
- package/src/config/types/cron.ts +1 -1
- package/src/config/types/db.ts +10 -10
- package/src/config/types/index.ts +2 -0
- package/src/config/types/operation.ts +49 -9
- package/src/config/types/queue.ts +8 -8
- package/src/config/types/remote-rest.ts +1 -1
- package/src/config/types/remote-schema.ts +2 -2
- package/src/config/types/virtual-columns.ts +1 -1
- package/src/configuration/getSchemas/mergeEntities/index.ts +32 -1
- package/src/configuration/getSchemas/type-definition-generator/index.ts +23 -9
- package/src/configuration/rest/generateOpenAPI.ts +5 -9
- package/src/configuration/rest/handleRESTRequestFactory.ts +38 -14
- package/src/configuration/rest/parseStringParams.ts +92 -0
- package/src/databases/common.ts +163 -66
- package/src/databases/directives.ts +26 -8
- package/src/databases/engines/mssql/connection.ts +1 -1
- package/src/databases/engines/mssql/query/index.ts +47 -16
- package/src/databases/engines/mysql/auth.ts +2 -2
- package/src/databases/engines/mysql/connection.ts +5 -4
- package/src/databases/engines/mysql/getStructure.ts +61 -30
- package/src/databases/engines/mysql/placeholders.ts +84 -0
- package/src/databases/engines/mysql/query/index.ts +68 -10
- package/src/databases/engines/postgresql/query/index.ts +59 -7
- package/src/databases/high-level-operations.ts +2 -2
- package/src/databases/sqlTypeUtils.ts +3 -1
- package/src/databases/transformers/genResolverName.ts +3 -3
- package/src/databases/transformers/graphqlName.ts +28 -0
- package/src/subscriptions/strategies/index.ts +1 -1
- package/src/subscriptions/strategies/queue.ts +1 -1
- package/src/types/resolver.ts +17 -1
- package/src/types/zod/configuration.ts +1 -1
- 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
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
+
};
|
package/src/databases/common.ts
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
36
|
+
Object.keys(obj).some((key) => OPERATORS.has(key));
|
|
37
|
+
|
|
38
|
+
// These operators branch on the operand instead of binding it. Every literal in a
|
|
39
|
+
// query document is hoisted into a generated variable (analyzeQuery/valueExtractors),
|
|
40
|
+
// so the operand arrives as "$name" — testing that string for truthiness would make
|
|
41
|
+
// `is_null: false` behave exactly like `is_null: true`. Resolve the real boolean.
|
|
42
|
+
const NULL_CHECK_OPERATORS = new Set(["is_null", "is_not_null", "not_null"]);
|
|
43
|
+
|
|
44
|
+
const resolveBooleanOperand = (
|
|
45
|
+
operand: unknown,
|
|
46
|
+
variables: Record<string, unknown>,
|
|
47
|
+
variablesDefinition: VariableDefinition[],
|
|
48
|
+
): boolean => {
|
|
49
|
+
if (!isString(operand) || !operand.startsWith("$")) return Boolean(operand);
|
|
50
|
+
|
|
51
|
+
const name = operand.substring(1);
|
|
52
|
+
|
|
53
|
+
// The hash-key path passes the request variables without the generated defaults,
|
|
54
|
+
// so fall back to the definition's defaultValue for hoisted literals.
|
|
55
|
+
return name in variables
|
|
56
|
+
? Boolean(variables[name])
|
|
57
|
+
: Boolean(variablesDefinition.find((v) => v.name === name)?.defaultValue);
|
|
58
|
+
};
|
|
32
59
|
|
|
33
60
|
export const generateTableAlias = (level: number): string => `t${level}`;
|
|
34
61
|
|
|
@@ -47,6 +74,8 @@ export const buildConditions = (
|
|
|
47
74
|
tableAlias: string,
|
|
48
75
|
level: number,
|
|
49
76
|
aliasMap: { [alias: string]: string },
|
|
77
|
+
/** Query name of the table being filtered — resolves its virtual columns. */
|
|
78
|
+
tableName?: string,
|
|
50
79
|
): string => {
|
|
51
80
|
if (!whereArgs) return "";
|
|
52
81
|
|
|
@@ -58,25 +87,50 @@ export const buildConditions = (
|
|
|
58
87
|
if (isOperatorObject(value)) {
|
|
59
88
|
for (const [operator, operand] of Object.entries(value)) {
|
|
60
89
|
// All values are now variable references (e.g., "$varName" or ["$var1", "$var2"])
|
|
61
|
-
const resolvedValue =
|
|
62
|
-
? operand
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
: operand
|
|
72
|
-
|
|
73
|
-
|
|
90
|
+
const resolvedValue = NULL_CHECK_OPERATORS.has(operator)
|
|
91
|
+
? resolveBooleanOperand(operand, variables, variablesDefinition)
|
|
92
|
+
: Array.isArray(operand)
|
|
93
|
+
? operand.map((op) => {
|
|
94
|
+
if (isString(op) && op.startsWith("$")) {
|
|
95
|
+
const index = variablesDefinition.findIndex((v) => v.name === op.substring(1));
|
|
96
|
+
return `${mappingDbTypeCharVar[dbType]}${index + 1}`;
|
|
97
|
+
}
|
|
98
|
+
return op;
|
|
99
|
+
})
|
|
100
|
+
: isString(operand) && operand.startsWith("$")
|
|
101
|
+
? `${mappingDbTypeCharVar[dbType]}${variablesDefinition.findIndex((v) => v.name === operand.substring(1)) + 1}`
|
|
102
|
+
: operand;
|
|
103
|
+
|
|
104
|
+
const condition = buildCondition(
|
|
105
|
+
columnTargetFp(dbType)(entities, tableName, fieldName, tableAlias),
|
|
106
|
+
fieldName,
|
|
107
|
+
operator,
|
|
108
|
+
resolvedValue,
|
|
109
|
+
dbType,
|
|
110
|
+
);
|
|
74
111
|
|
|
75
112
|
if (condition) {
|
|
76
113
|
conditions.push(condition);
|
|
77
114
|
}
|
|
78
115
|
}
|
|
79
116
|
} else if (typeof value === "object" && !Array.isArray(value)) {
|
|
117
|
+
// Only entities (tables/views) appear in queriesMap. An operator-less filter object on a
|
|
118
|
+
// column (e.g. `{ col: {} }`) has no operator keys, so it lands here despite not being a
|
|
119
|
+
// relation; skip it as a no-op instead of dereferencing a missing queriesMap entry.
|
|
120
|
+
const nestedEntity = entities.queriesMap[fieldName];
|
|
121
|
+
if (!nestedEntity) {
|
|
122
|
+
const [unknownOperator] = Object.keys(value as object);
|
|
123
|
+
// A non-empty filter object on something that is not a relation can only
|
|
124
|
+
// be a column filter whose operator we do not implement. Dropping it here
|
|
125
|
+
// would return every row.
|
|
126
|
+
if (unknownOperator) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`Unsupported filter operator "${unknownOperator}" on column "${fieldName}"`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
80
134
|
const nestedTableAlias = `t${level}`;
|
|
81
135
|
|
|
82
136
|
const parentTableName = aliasMap[tableAlias];
|
|
@@ -90,6 +144,11 @@ export const buildConditions = (
|
|
|
90
144
|
dbType,
|
|
91
145
|
);
|
|
92
146
|
|
|
147
|
+
// Record this relation's alias→table so a deeper nested where (3+ levels) can resolve
|
|
148
|
+
// its own parent via aliasMap[tableAlias]; without it the next recursion sees undefined
|
|
149
|
+
// and emits a join-less "WHERE AND (...)".
|
|
150
|
+
aliasMap[nestedTableAlias] = fieldName;
|
|
151
|
+
|
|
93
152
|
const nestedConditions = buildConditions(
|
|
94
153
|
entities,
|
|
95
154
|
dbType,
|
|
@@ -99,11 +158,12 @@ export const buildConditions = (
|
|
|
99
158
|
nestedTableAlias,
|
|
100
159
|
level + 1,
|
|
101
160
|
aliasMap,
|
|
161
|
+
fieldName,
|
|
102
162
|
);
|
|
103
163
|
|
|
104
164
|
const existsClause = `EXISTS (
|
|
105
165
|
SELECT 1
|
|
106
|
-
FROM ${
|
|
166
|
+
FROM ${nestedEntity.dottedQuotedName} ${nestedTableAlias}
|
|
107
167
|
WHERE ${joinCondition}${nestedConditions ? ` AND (${nestedConditions})` : ""}
|
|
108
168
|
)`;
|
|
109
169
|
|
|
@@ -142,6 +202,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
|
-
|
|
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 `${
|
|
258
|
+
return `${target} = ${value}`;
|
|
165
259
|
case "neq":
|
|
166
|
-
return `${
|
|
260
|
+
return `${target} <> ${value}`;
|
|
167
261
|
case "gt":
|
|
168
|
-
return `${
|
|
262
|
+
return `${target} > ${value}`;
|
|
169
263
|
case "gte":
|
|
170
|
-
return `${
|
|
264
|
+
return `${target} >= ${value}`;
|
|
171
265
|
case "lt":
|
|
172
|
-
return `${
|
|
266
|
+
return `${target} < ${value}`;
|
|
173
267
|
case "lte":
|
|
174
|
-
return `${
|
|
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 `${
|
|
273
|
+
return dbType === "mssql" ? `${target} LIKE ${value} ESCAPE '\\'` : `${target} LIKE ${value}`;
|
|
177
274
|
case "in":
|
|
178
|
-
return `${
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 `${
|
|
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 ${
|
|
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 `${
|
|
559
|
+
return `${target} ${sort}`;
|
|
454
560
|
} else {
|
|
455
561
|
// Need to force NULL positioning with CASE
|
|
456
|
-
return `CASE WHEN ${
|
|
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
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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 (
|
|
18
|
+
if (PLACEHOLDER_RE.test(s)) return s;
|
|
17
19
|
return `'${s}'`;
|
|
18
20
|
};
|
|
19
21
|
|
|
@@ -32,12 +34,18 @@ export const DIRECTIVE_HANDLERS: Record<string, DirectiveHandler> = {
|
|
|
32
34
|
`SUBSTRING(${querySelector}, ${directive.arguments!["start"]}, ${directive.arguments!["length"]})`,
|
|
33
35
|
replace: (querySelector, directive) =>
|
|
34
36
|
`REPLACE(${querySelector}, ${sqlString(directive.arguments!["find"])}, ${sqlString(directive.arguments!["replaceWith"])})`,
|
|
35
|
-
concat: (querySelector, directive) => {
|
|
36
|
-
const
|
|
37
|
+
concat: (querySelector, directive, dbType) => {
|
|
38
|
+
const rendered = sqlString(directive.arguments!["with"]);
|
|
39
|
+
// CONCAT is variadic "any" in PostgreSQL, so it gives the planner nothing to
|
|
40
|
+
// infer a bare placeholder from and the statement dies with
|
|
41
|
+
// "could not determine data type of parameter $n". The cast annotates the
|
|
42
|
+
// parameter type without unbinding the value.
|
|
43
|
+
const withValue =
|
|
44
|
+
dbType === "pg" && PLACEHOLDER_RE.test(rendered) ? `${rendered}::TEXT` : rendered;
|
|
37
45
|
const position = (directive.arguments?.["position"] as string) ?? "after";
|
|
38
46
|
return position === "before"
|
|
39
|
-
? `CONCAT(${
|
|
40
|
-
: `CONCAT(${querySelector}, ${
|
|
47
|
+
? `CONCAT(${withValue}, ${querySelector})`
|
|
48
|
+
: `CONCAT(${querySelector}, ${withValue})`;
|
|
41
49
|
},
|
|
42
50
|
pad: (querySelector, directive, dbType) => {
|
|
43
51
|
const length = directive.arguments!["length"] as number;
|
|
@@ -47,13 +55,23 @@ export const DIRECTIVE_HANDLERS: Record<string, DirectiveHandler> = {
|
|
|
47
55
|
return side === "left"
|
|
48
56
|
? `LPAD(${querySelector}::TEXT, ${length}, ${char})`
|
|
49
57
|
: `RPAD(${querySelector}::TEXT, ${length}, ${char})`;
|
|
50
|
-
}
|
|
58
|
+
}
|
|
59
|
+
// MySQL has LPAD/RPAD too, and needs no cast. It used to fall through to the
|
|
60
|
+
// SQL Server branch, where REPLICATE and VARCHAR(MAX) are syntax errors.
|
|
61
|
+
if (dbType === "mysql") {
|
|
51
62
|
return side === "left"
|
|
52
|
-
? `
|
|
53
|
-
: `
|
|
63
|
+
? `LPAD(${querySelector}, ${length}, ${char})`
|
|
64
|
+
: `RPAD(${querySelector}, ${length}, ${char})`;
|
|
54
65
|
}
|
|
66
|
+
// SQL Server: LPAD/RPAD only exist from 2022.
|
|
67
|
+
return side === "left"
|
|
68
|
+
? `RIGHT(REPLICATE(${char}, ${length}) + CAST(${querySelector} AS VARCHAR(MAX)), ${length})`
|
|
69
|
+
: `LEFT(CAST(${querySelector} AS VARCHAR(MAX)) + REPLICATE(${char}, ${length}), ${length})`;
|
|
55
70
|
},
|
|
56
71
|
dateFormat: (querySelector, directive, dbType) => {
|
|
72
|
+
if (dbType === "mysql") {
|
|
73
|
+
throw new Error("@dateFormat is not supported on MySQL. Expose a virtual column instead.");
|
|
74
|
+
}
|
|
57
75
|
const format = directive.arguments!["format"] as string;
|
|
58
76
|
return dbType === "pg"
|
|
59
77
|
? `TO_CHAR(${querySelector}, ${sqlString(format)})`
|
|
@@ -145,7 +145,7 @@ export const callStoredProcedure = async (
|
|
|
145
145
|
// never as SQL text, so reserved words need no delimiting here.
|
|
146
146
|
const data = await request.execute(sp.dottedName);
|
|
147
147
|
|
|
148
|
-
return data?.
|
|
148
|
+
return !data?.returnValue;
|
|
149
149
|
} catch (e: unknown) {
|
|
150
150
|
logger("mssql").error({ err: e }, "stored procedure execution failed");
|
|
151
151
|
return false;
|