backeneko 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/compile.ts ADDED
@@ -0,0 +1,263 @@
1
+ import {
2
+ buildASTSchema,
3
+ Kind,
4
+ parse,
5
+ print,
6
+ visit,
7
+ type ASTNode,
8
+ type DefinitionNode,
9
+ type DocumentNode,
10
+ type GraphQLSchema,
11
+ } from "graphql";
12
+ import { validateSDL } from "./graphql-internal.ts";
13
+ import { withMockDirectives } from "./directives.ts";
14
+ import type { SkippedDefinition } from "./merge.ts";
15
+
16
+ export interface CompileResult {
17
+ schema: GraphQLSchema;
18
+ document: DocumentNode;
19
+ skipped: SkippedDefinition[];
20
+ warnings: string[];
21
+ }
22
+
23
+ const BUILT_IN_DIRECTIVES = new Set(["skip", "include", "deprecated", "specifiedBy", "oneOf"]);
24
+ const QUERY_PLACEHOLDER = parse("type Query { _empty: String }").definitions[0]!;
25
+ const MAX_ITERATIONS = 1000;
26
+
27
+ type MemberArrayKey = "fields" | "values" | "types";
28
+
29
+ interface MemberRemoval {
30
+ keys: Set<number>;
31
+ entries: SkippedDefinition[];
32
+ }
33
+
34
+ export function compileDocument(input: DocumentNode): CompileResult {
35
+ const warnings: string[] = [];
36
+ const warningSet = new Set<string>();
37
+ const warn = (message: string): void => {
38
+ if (!warningSet.has(message)) {
39
+ warningSet.add(message);
40
+ warnings.push(message);
41
+ }
42
+ };
43
+
44
+ const { document: stripped, droppedDirectives } = stripUnknownDirectives(
45
+ withMockDirectives(input),
46
+ );
47
+ for (const [name, count] of droppedDirectives) {
48
+ warn(`dropped unknown directive @${name} (${count} use${count > 1 ? "s" : ""})`);
49
+ }
50
+
51
+ const pruned = pruneInvalidDefinitions(normalize(stripped).definitions.slice(), warn);
52
+
53
+ let document: DocumentNode = { kind: Kind.DOCUMENT, definitions: pruned.definitions };
54
+ let schema = buildTolerant(document, warn);
55
+
56
+ if (!schema.getQueryType()) {
57
+ document = append(document, QUERY_PLACEHOLDER);
58
+ schema = buildASTSchema(document);
59
+ warn("no Query type found — added `type Query { _empty: String }`");
60
+ }
61
+
62
+ return { schema, document, skipped: pruned.skipped, warnings };
63
+ }
64
+
65
+ function pruneInvalidDefinitions(
66
+ initial: DefinitionNode[],
67
+ warn: (message: string) => void,
68
+ ): { definitions: DefinitionNode[]; skipped: SkippedDefinition[] } {
69
+ let definitions = initial;
70
+ const skipped: SkippedDefinition[] = [];
71
+
72
+ for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
73
+ const document: DocumentNode = { kind: Kind.DOCUMENT, definitions };
74
+ const errors = validateSDL(document);
75
+ if (errors.length === 0) break;
76
+
77
+ const dropDefinitions = new Set<DefinitionNode>();
78
+ const dropMembers = new Map<DefinitionNode, MemberRemoval>();
79
+
80
+ for (const error of errors) {
81
+ const node = lastLocatedNode(error.nodes ?? []);
82
+ if (!node?.loc) {
83
+ warn(`unattributed schema error: ${error.message}`);
84
+ continue;
85
+ }
86
+ const definition = findDefinition(definitions, node.loc.start, node.loc.end);
87
+ if (!definition) {
88
+ warn(`unattributed schema error: ${error.message}`);
89
+ continue;
90
+ }
91
+
92
+ const member = findMember(definition, node.loc.start, node.loc.end);
93
+ if (member) {
94
+ const removal = dropMembers.get(definition) ?? { keys: new Set(), entries: [] };
95
+ removal.keys.add(member.key);
96
+ removal.entries.push({
97
+ name: `${definitionName(definition)}.${member.name}`,
98
+ reason: error.message,
99
+ });
100
+ dropMembers.set(definition, removal);
101
+ } else {
102
+ dropDefinitions.add(definition);
103
+ skipped.push({ name: definitionName(definition), reason: error.message });
104
+ }
105
+ }
106
+
107
+ const next: DefinitionNode[] = [];
108
+ let changed = false;
109
+
110
+ for (const definition of definitions) {
111
+ if (dropDefinitions.has(definition)) {
112
+ changed = true;
113
+ continue;
114
+ }
115
+ const removal = dropMembers.get(definition);
116
+ if (!removal) {
117
+ next.push(definition);
118
+ continue;
119
+ }
120
+ changed = true;
121
+ const filtered = removeMembers(definition, removal.keys);
122
+ if (filtered !== null && membersOf(filtered).length > 0) {
123
+ next.push(filtered);
124
+ skipped.push(...removal.entries);
125
+ } else {
126
+ // The type lost every field, so the definition cannot stand on its own.
127
+ for (const entry of removal.entries) {
128
+ skipped.push({ name: definitionName(definition), reason: entry.reason });
129
+ }
130
+ }
131
+ }
132
+
133
+ definitions = next;
134
+ if (!changed) break;
135
+ }
136
+
137
+ return { definitions, skipped };
138
+ }
139
+
140
+ function lastLocatedNode(nodes: ReadonlyArray<ASTNode>): ASTNode | null {
141
+ // Duplicate-field errors report every occurrence; the last node is the
142
+ // offending duplicate, so pruning it keeps the original definition intact.
143
+ let found: ASTNode | null = null;
144
+ for (const node of nodes) {
145
+ if (node.loc) found = node;
146
+ }
147
+ return found;
148
+ }
149
+
150
+ function findDefinition(
151
+ definitions: ReadonlyArray<DefinitionNode>,
152
+ start: number,
153
+ end: number,
154
+ ): DefinitionNode | null {
155
+ for (const definition of definitions) {
156
+ const loc = definition.loc;
157
+ if (loc && start >= loc.start && end <= loc.end) return definition;
158
+ }
159
+ return null;
160
+ }
161
+
162
+ interface MemberInfo {
163
+ key: number;
164
+ name: string;
165
+ }
166
+
167
+ function findMember(definition: DefinitionNode, start: number, end: number): MemberInfo | null {
168
+ for (const member of membersOf(definition)) {
169
+ const loc = member.loc;
170
+ if (loc && start >= loc.start && end <= loc.end) {
171
+ return { key: loc.start, name: memberName(member) };
172
+ }
173
+ }
174
+ return null;
175
+ }
176
+
177
+ function memberArrayKey(definition: DefinitionNode): MemberArrayKey | null {
178
+ switch (definition.kind) {
179
+ case Kind.OBJECT_TYPE_DEFINITION:
180
+ case Kind.OBJECT_TYPE_EXTENSION:
181
+ case Kind.INTERFACE_TYPE_DEFINITION:
182
+ case Kind.INTERFACE_TYPE_EXTENSION:
183
+ case Kind.INPUT_OBJECT_TYPE_DEFINITION:
184
+ case Kind.INPUT_OBJECT_TYPE_EXTENSION:
185
+ return "fields";
186
+ case Kind.ENUM_TYPE_DEFINITION:
187
+ case Kind.ENUM_TYPE_EXTENSION:
188
+ return "values";
189
+ case Kind.UNION_TYPE_DEFINITION:
190
+ case Kind.UNION_TYPE_EXTENSION:
191
+ return "types";
192
+ default:
193
+ return null;
194
+ }
195
+ }
196
+
197
+ function membersOf(definition: DefinitionNode): readonly ASTNode[] {
198
+ const key = memberArrayKey(definition);
199
+ if (!key) return [];
200
+ const value = (definition as unknown as Record<string, unknown>)[key];
201
+ return Array.isArray(value) ? (value as ASTNode[]) : [];
202
+ }
203
+
204
+ function removeMembers(definition: DefinitionNode, keys: Set<number>): DefinitionNode | null {
205
+ const key = memberArrayKey(definition);
206
+ if (!key) return null;
207
+ const members = membersOf(definition).filter((member) => !member.loc || !keys.has(member.loc.start));
208
+ return { ...(definition as object), [key]: members } as unknown as DefinitionNode;
209
+ }
210
+
211
+ function memberName(node: ASTNode): string {
212
+ const name = (node as { name?: { value?: string } }).name;
213
+ return name && typeof name.value === "string" ? name.value : "member";
214
+ }
215
+
216
+ function definitionName(definition: DefinitionNode): string {
217
+ if ("name" in definition && definition.name && typeof definition.name.value === "string") {
218
+ return definition.name.value;
219
+ }
220
+ if (definition.kind === Kind.SCHEMA_DEFINITION || definition.kind === Kind.SCHEMA_EXTENSION) {
221
+ return "schema";
222
+ }
223
+ return definition.kind.toLowerCase().replace(/_/g, " ");
224
+ }
225
+
226
+ function buildTolerant(document: DocumentNode, warn: (message: string) => void): GraphQLSchema {
227
+ try {
228
+ return buildASTSchema(document);
229
+ } catch (error) {
230
+ const message = error instanceof Error ? error.message : String(error);
231
+ warn(`schema could not be built (${message}); falling back to an empty schema`);
232
+ return buildASTSchema({ kind: Kind.DOCUMENT, definitions: [] });
233
+ }
234
+ }
235
+
236
+ function stripUnknownDirectives(document: DocumentNode): {
237
+ document: DocumentNode;
238
+ droppedDirectives: Map<string, number>;
239
+ } {
240
+ const known = new Set(BUILT_IN_DIRECTIVES);
241
+ for (const def of document.definitions) {
242
+ if (def.kind === Kind.DIRECTIVE_DEFINITION) known.add(def.name.value);
243
+ }
244
+
245
+ const droppedDirectives = new Map<string, number>();
246
+ const result = visit(document, {
247
+ Directive(node) {
248
+ if (known.has(node.name.value)) return undefined;
249
+ droppedDirectives.set(node.name.value, (droppedDirectives.get(node.name.value) ?? 0) + 1);
250
+ return null;
251
+ },
252
+ });
253
+
254
+ return { document: result, droppedDirectives };
255
+ }
256
+
257
+ function normalize(document: DocumentNode): DocumentNode {
258
+ return parse(print(document));
259
+ }
260
+
261
+ function append(document: DocumentNode, definition: DefinitionNode): DocumentNode {
262
+ return { kind: Kind.DOCUMENT, definitions: [...document.definitions, definition] };
263
+ }
@@ -0,0 +1,125 @@
1
+ import {
2
+ Kind,
3
+ isEnumType,
4
+ isScalarType,
5
+ parse,
6
+ visit,
7
+ type ConstDirectiveNode,
8
+ type DefinitionNode,
9
+ type DocumentNode,
10
+ type FieldDefinitionNode,
11
+ type GraphQLNamedType,
12
+ } from "graphql";
13
+
14
+ // The two project directives are interchangeable: `@backeneko` and `@mock`.
15
+ export const MOCK_DIRECTIVES: readonly string[] = ["backeneko", "mock"];
16
+ export const MOCK_DIRECTIVE_ARG = "value";
17
+
18
+ export type MockValueResult =
19
+ | { ok: true; expression: string }
20
+ | { ok: false; reason: string };
21
+
22
+ // backeneko owns these directives, so a schema may use them without declaring
23
+ // them. When a name is used but not defined, its definition is injected before
24
+ // SDL validation (otherwise `stripUnknownDirectives` would drop the usage).
25
+ export function withMockDirectives(document: DocumentNode): DocumentNode {
26
+ const used = new Set<string>();
27
+ visit(document, {
28
+ Directive(node) {
29
+ if (MOCK_DIRECTIVES.includes(node.name.value)) used.add(node.name.value);
30
+ },
31
+ });
32
+
33
+ const defined = new Set<string>();
34
+ for (const def of document.definitions) {
35
+ if (def.kind === Kind.DIRECTIVE_DEFINITION) defined.add(def.name.value);
36
+ }
37
+
38
+ const additions: DefinitionNode[] = [];
39
+ for (const name of MOCK_DIRECTIVES) {
40
+ if (!used.has(name) || defined.has(name)) continue;
41
+ additions.push(
42
+ ...parse(`directive @${name}(${MOCK_DIRECTIVE_ARG}: String) on FIELD_DEFINITION`).definitions,
43
+ );
44
+ }
45
+ if (additions.length === 0) return document;
46
+
47
+ return { kind: Kind.DOCUMENT, definitions: [...additions, ...document.definitions] };
48
+ }
49
+
50
+ // Collects `Type.field` -> raw string value for every field annotated with a
51
+ // mock directive. Covers type definitions and extensions.
52
+ export function collectFieldDirectives(document: DocumentNode): Map<string, string> {
53
+ const values = new Map<string, string>();
54
+
55
+ for (const definition of document.definitions) {
56
+ const name = "name" in definition ? definition.name?.value : undefined;
57
+ const fields = fieldDefinitions(definition);
58
+ if (!name || fields.length === 0) continue;
59
+
60
+ for (const field of fields) {
61
+ const key = `${name}.${field.name.value}`;
62
+ if (values.has(key)) continue;
63
+ const value = mockDirectiveValue(field.directives);
64
+ if (value !== undefined) values.set(key, value);
65
+ }
66
+ }
67
+
68
+ return values;
69
+ }
70
+
71
+ export function renderMockValue(
72
+ value: string,
73
+ named: GraphQLNamedType,
74
+ list: boolean,
75
+ ): MockValueResult {
76
+ if (list) return { ok: false, reason: "list fields are not supported yet" };
77
+
78
+ if (isEnumType(named)) return { ok: true, expression: JSON.stringify(value) };
79
+
80
+ if (isScalarType(named)) {
81
+ switch (named.name) {
82
+ case "Int": {
83
+ const parsed = Number(value);
84
+ if (Number.isSafeInteger(parsed) && String(parsed) === value.trim()) {
85
+ return { ok: true, expression: String(parsed) };
86
+ }
87
+ return { ok: false, reason: `"${value}" is not an Int` };
88
+ }
89
+ case "Float": {
90
+ const parsed = Number(value);
91
+ if (value.trim() !== "" && Number.isFinite(parsed)) {
92
+ return { ok: true, expression: String(parsed) };
93
+ }
94
+ return { ok: false, reason: `"${value}" is not a Float` };
95
+ }
96
+ case "Boolean": {
97
+ if (value === "true") return { ok: true, expression: "true" };
98
+ if (value === "false") return { ok: true, expression: "false" };
99
+ return { ok: false, reason: `"${value}" is not a Boolean` };
100
+ }
101
+ default:
102
+ // String, ID and custom scalars keep the raw string.
103
+ return { ok: true, expression: JSON.stringify(value) };
104
+ }
105
+ }
106
+
107
+ return { ok: false, reason: "only scalar and enum fields are supported" };
108
+ }
109
+
110
+ function mockDirectiveValue(
111
+ directives: readonly ConstDirectiveNode[] | undefined,
112
+ ): string | undefined {
113
+ for (const directive of directives ?? []) {
114
+ if (!MOCK_DIRECTIVES.includes(directive.name.value)) continue;
115
+ const argument = directive.arguments?.find((entry) => entry.name.value === MOCK_DIRECTIVE_ARG);
116
+ if (argument && argument.value.kind === Kind.STRING) return argument.value.value;
117
+ }
118
+ return undefined;
119
+ }
120
+
121
+ function fieldDefinitions(definition: DefinitionNode): readonly FieldDefinitionNode[] {
122
+ if (!("fields" in definition)) return [];
123
+ const fields = (definition as { fields?: unknown }).fields;
124
+ return Array.isArray(fields) ? (fields as readonly FieldDefinitionNode[]) : [];
125
+ }