@johnhenry/graceholder 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/LICENSE +21 -0
- package/README.md +492 -2
- package/STABILITY.md +24 -0
- package/package.json +126 -4
- package/src/adapters/apollo.d.ts +13 -0
- package/src/adapters/apollo.js +48 -0
- package/src/adapters/fetch.d.ts +15 -0
- package/src/adapters/fetch.js +150 -0
- package/src/adapters/shared.d.ts +14 -0
- package/src/adapters/shared.js +65 -0
- package/src/adapters/urql.d.ts +8 -0
- package/src/adapters/urql.js +53 -0
- package/src/anchor.js +13 -0
- package/src/args.js +41 -0
- package/src/bulk/csv.js +118 -0
- package/src/bulk/ndjson.js +10 -0
- package/src/bulk/plan.js +195 -0
- package/src/bulk/shape.js +67 -0
- package/src/bulk/source.js +24 -0
- package/src/bulk/sql.js +366 -0
- package/src/bulk/stream.js +55 -0
- package/src/cursor.js +25 -0
- package/src/dbg.d.ts +720 -0
- package/src/detmath.js +74 -0
- package/src/directives.js +113 -0
- package/src/dist.js +90 -0
- package/src/errors.js +17 -0
- package/src/execute.js +577 -0
- package/src/explain.js +473 -0
- package/src/fast-check.d.ts +31 -0
- package/src/fast-check.js +58 -0
- package/src/generate.js +84 -0
- package/src/generator-types.d.ts +90 -0
- package/src/generators.js +361 -0
- package/src/image.js +55 -0
- package/src/index.d.ts +507 -0
- package/src/index.js +62 -0
- package/src/infer.js +888 -0
- package/src/key.js +30 -0
- package/src/learn.js +397 -0
- package/src/naming.js +74 -0
- package/src/pack-registry.js +59 -0
- package/src/packs/PROVENANCE.md +26 -0
- package/src/packs/commerce.d.ts +4 -0
- package/src/packs/commerce.js +83 -0
- package/src/packs/finance.d.ts +4 -0
- package/src/packs/finance.js +180 -0
- package/src/packs/geo.d.ts +4 -0
- package/src/packs/geo.js +227 -0
- package/src/packs/health.d.ts +4 -0
- package/src/packs/health.js +76 -0
- package/src/packs/index.d.ts +7 -0
- package/src/packs/index.js +9 -0
- package/src/rng.js +89 -0
- package/src/rules.js +228 -0
- package/src/scenario.js +120 -0
- package/src/schema.js +148 -0
- package/src/spec.js +235 -0
- package/src/suggest.js +138 -0
- package/src/typed.d.ts +723 -0
- package/src/vocab/PROVENANCE.md +12 -0
- package/src/vocab/en.js +106 -0
- package/src/world.js +461 -0
- package/src/xxh64.js +87 -0
package/src/infer.js
ADDED
|
@@ -0,0 +1,888 @@
|
|
|
1
|
+
// Schema inference from a query (docs/design.md section 4): walk the selected
|
|
2
|
+
// operation, merge every (parentType, fieldName), apply the L/N/T rules, emit SDL.
|
|
3
|
+
import { parse, Kind, print, GraphQLError, valueFromASTUntyped, visit, validate, printSchema, isObjectType, isInterfaceType, isUnionType, isEnumType, isAbstractType, getNamedType } from "graphql";
|
|
4
|
+
import { GraceholderError } from "./errors.js";
|
|
5
|
+
import { typeNameFor, pascal, tokenize, singularize, pluralize } from "./naming.js";
|
|
6
|
+
import { listRule, classifyScalar, countOf } from "./rules.js";
|
|
7
|
+
import { checkDirectives, stripDirectives, directiveProps } from "./directives.js";
|
|
8
|
+
import { compileSpec, specProps } from "./spec.js";
|
|
9
|
+
import { generate, GRAPH_GENERATORS } from "./generators.js";
|
|
10
|
+
import { Stream } from "./rng.js";
|
|
11
|
+
import { loadSchema, shapeOf, concreteTypes, isLeaf, customScalarGen, compatible, BUILTIN_FALLBACK } from "./schema.js";
|
|
12
|
+
|
|
13
|
+
const BUILTIN_SCALARS = new Set(["Int", "Float", "String", "Boolean", "ID"]);
|
|
14
|
+
const PAGING_ARGS = new Set(["first", "last", "limit", "offset", "skip", "take", "page", "after"]);
|
|
15
|
+
/** Pass-through scalar for enum literals and other values with no better SDL type. */
|
|
16
|
+
export const ANY_SCALAR = "GraceholderAny";
|
|
17
|
+
|
|
18
|
+
export function graphqlError(error) {
|
|
19
|
+
return new GraceholderError("graphql", error.message, { cause: error, errors: [error] });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @param {string|import("graphql").DocumentNode} query */
|
|
23
|
+
export function toDocument(query) {
|
|
24
|
+
if (typeof query === "string") {
|
|
25
|
+
try {
|
|
26
|
+
return parse(query);
|
|
27
|
+
} catch (e) {
|
|
28
|
+
throw graphqlError(e);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (query && typeof query === "object" && query.kind === Kind.DOCUMENT) return query;
|
|
32
|
+
throw new GraceholderError("bad-options", "query must be a string or a DocumentNode");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function selectOperation(doc, operationName) {
|
|
36
|
+
const ops = doc.definitions.filter((d) => d.kind === Kind.OPERATION_DEFINITION);
|
|
37
|
+
let op;
|
|
38
|
+
if (operationName !== undefined) {
|
|
39
|
+
op = ops.find((o) => o.name?.value === operationName);
|
|
40
|
+
if (!op) throw new GraceholderError("bad-operation", `no operation named ${JSON.stringify(operationName)}`);
|
|
41
|
+
} else if (ops.length === 1) {
|
|
42
|
+
op = ops[0];
|
|
43
|
+
} else {
|
|
44
|
+
throw new GraceholderError("bad-operation", ops.length === 0 ? "the document has no operation" : "the document has several operations; pass operationName");
|
|
45
|
+
}
|
|
46
|
+
if (op.operation !== "query") {
|
|
47
|
+
throw new GraceholderError("bad-operation", `${op.operation} operations are not supported`, { hint: "graceholder generates data for queries only" });
|
|
48
|
+
}
|
|
49
|
+
return op;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Fragments reachable from the operation, in first-use order. Detects unknown fragments and cycles. */
|
|
53
|
+
function reachableFragments(doc, op) {
|
|
54
|
+
const all = new Map(doc.definitions.filter((d) => d.kind === Kind.FRAGMENT_DEFINITION).map((d) => [d.name.value, d]));
|
|
55
|
+
const used = new Map();
|
|
56
|
+
const stack = [];
|
|
57
|
+
const visitSet = (selSet) => {
|
|
58
|
+
for (const s of selSet.selections) {
|
|
59
|
+
if (s.kind === Kind.FRAGMENT_SPREAD) {
|
|
60
|
+
const name = s.name.value;
|
|
61
|
+
if (stack.includes(name)) throw graphqlError(new GraphQLError(`Cannot spread fragment "${name}" within itself.`));
|
|
62
|
+
const def = all.get(name);
|
|
63
|
+
if (!def) throw graphqlError(new GraphQLError(`Unknown fragment "${name}".`));
|
|
64
|
+
if (!used.has(name)) used.set(name, def);
|
|
65
|
+
stack.push(name);
|
|
66
|
+
visitSet(def.selectionSet);
|
|
67
|
+
stack.pop();
|
|
68
|
+
} else if (s.selectionSet) {
|
|
69
|
+
visitSet(s.selectionSet);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
visitSet(op.selectionSet);
|
|
74
|
+
return used;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Counts that are measures of something other than a sibling collection. */
|
|
78
|
+
const MEASURES = new Set(["view", "like", "vote", "dislike", "click", "download", "impression", "visit", "reaction", "page", "word", "character"]);
|
|
79
|
+
|
|
80
|
+
const camel = (words) => words.map((w, i) => (i === 0 ? w : w[0].toUpperCase() + w.slice(1))).join("");
|
|
81
|
+
|
|
82
|
+
const withoutBang = (t) => (t.endsWith("!") ? t.slice(0, -1) : t);
|
|
83
|
+
|
|
84
|
+
/** Field nodes directly in a selection set, looking through every fragment. */
|
|
85
|
+
function flattenFields(selSet, fragments, out = []) {
|
|
86
|
+
for (const s of selSet.selections) {
|
|
87
|
+
if (s.kind === Kind.FIELD) out.push(s);
|
|
88
|
+
else if (s.kind === Kind.INLINE_FRAGMENT) flattenFields(s.selectionSet, fragments, out);
|
|
89
|
+
else if (s.kind === Kind.FRAGMENT_SPREAD) flattenFields(fragments.get(s.name.value).selectionSet, fragments, out);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Fields selected outside any type-conditioned fragment (they belong to the abstract type itself). */
|
|
95
|
+
function plainFields(selSet, fragments, out = []) {
|
|
96
|
+
for (const s of selSet.selections) {
|
|
97
|
+
if (s.kind === Kind.FIELD && !s.name.value.startsWith("__")) out.push(s);
|
|
98
|
+
else if (s.kind === Kind.INLINE_FRAGMENT && !s.typeCondition) plainFields(s.selectionSet, fragments, out);
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const OV_SOURCE_RANK = { directive: 2, spec: 1 };
|
|
104
|
+
|
|
105
|
+
/** SDL scalar type of a generator's output. */
|
|
106
|
+
export function genSdlType(name, args, packs = []) {
|
|
107
|
+
for (const pack of packs) if (pack.types && Object.hasOwn(pack.types, name)) return pack.types[name];
|
|
108
|
+
if (name === "series.value") return "Float";
|
|
109
|
+
if (name === "series.date") return "DateTime";
|
|
110
|
+
if (["int", "person.age", "count.of"].includes(name) || (name === "id.int" && !args.string)) return "Int";
|
|
111
|
+
if (["float", "money", "percent", "place.lat", "place.lng", "order.subtotal", "order.tax", "order.total"].includes(name)) return "Float";
|
|
112
|
+
if (name === "bool") return "Boolean";
|
|
113
|
+
if (name === "datetime" || name.startsWith("time.")) return "DateTime";
|
|
114
|
+
if (name === "id.key") return "ID";
|
|
115
|
+
if (name === "oneOf" && Array.isArray(args.values)) {
|
|
116
|
+
const vals = args.values.map((v) => (v !== null && typeof v === "object" && "weight" in v ? v.value : v));
|
|
117
|
+
if (vals.every((v) => typeof v === "boolean")) return "Boolean";
|
|
118
|
+
if (vals.every((v) => Number.isInteger(v))) return "Int";
|
|
119
|
+
if (vals.every((v) => typeof v === "number")) return "Float";
|
|
120
|
+
}
|
|
121
|
+
return "String";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Run a generator once with throwaway streams so unknown names and bad arguments fail at inference time. */
|
|
125
|
+
function checkGenerator(name, args, path, packs) {
|
|
126
|
+
if (GRAPH_GENERATORS.includes(name)) return;
|
|
127
|
+
const stream = new Stream(1n);
|
|
128
|
+
try {
|
|
129
|
+
generate(name, { stream, slot: () => new Stream(1n), anchorMs: 0, args, typeName: "T", entityKey: "1", fieldName: "f", packs });
|
|
130
|
+
} catch (e) {
|
|
131
|
+
if (e instanceof GraceholderError && path) e.path = path;
|
|
132
|
+
throw e;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
class Inferer {
|
|
137
|
+
constructor(op, fragments, config) {
|
|
138
|
+
this.spec = config.spec;
|
|
139
|
+
this.packs = config.packs ?? [];
|
|
140
|
+
this.realistic = Boolean(config.realistic);
|
|
141
|
+
this.schema = config.schema; // schema mode (docs/design.md section 17) when set
|
|
142
|
+
this.rootName = this.schema ? this.schema.getQueryType().name : "Query";
|
|
143
|
+
this.byPath = new Map(); // path -> field info (first one), for specFromSamples
|
|
144
|
+
this.mentioned = new Set(); // type names used as fragment type conditions (schema mode)
|
|
145
|
+
this.abstractNames = new Set(); // abstract schema types the query reaches (schema mode)
|
|
146
|
+
this.walkedFields = new Map(); // field node -> Set of "path\0type" already walked (schema mode)
|
|
147
|
+
this.variables = config.variables;
|
|
148
|
+
this.nullableTypes = config.nullableTypes;
|
|
149
|
+
this.nullableFields = config.nullableFields;
|
|
150
|
+
this.scenario = config.scenario ?? [];
|
|
151
|
+
this.fragments = fragments;
|
|
152
|
+
this.varTypes = new Map((op.variableDefinitions ?? []).map((v) => [v.variable.name.value, print(v.type)]));
|
|
153
|
+
this.types = new Map(); // typeName -> Map(fieldName -> info)
|
|
154
|
+
this.inputs = new Map(); // inputName -> Map(field -> typeStr)
|
|
155
|
+
this.abstracts = new Map(); // name -> { kind: "union"|"interface", possible: string[] }
|
|
156
|
+
this.synthetic = new Map(); // connection/edge/pageInfo type -> { kind, item }
|
|
157
|
+
this.customScalars = [];
|
|
158
|
+
this.usesDateTime = false;
|
|
159
|
+
this.walked = new Set();
|
|
160
|
+
this.ensureType(this.rootName);
|
|
161
|
+
this.opSeed = directiveProps(op.directives, this.variables, "").seed;
|
|
162
|
+
if (!this.schema) for (const t of this.varTypes.values()) this.noteNamedType(t);
|
|
163
|
+
this.walk(op.selectionSet, this.rootName, "");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
ensureType(name) {
|
|
167
|
+
if (!this.types.has(name)) this.types.set(name, new Map());
|
|
168
|
+
return this.types.get(name);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
noteNamedType(typeStr) {
|
|
172
|
+
const named = typeStr.replace(/[[\]!]/g, "");
|
|
173
|
+
if (!BUILTIN_SCALARS.has(named)) this.noteScalar(named);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
noteScalar(name) {
|
|
177
|
+
if (!this.customScalars.includes(name)) this.customScalars.push(name);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
walk(selSet, parentType, path) {
|
|
181
|
+
for (const sel of selSet.selections) {
|
|
182
|
+
if (sel.kind === Kind.FIELD) this.field(sel, parentType, path);
|
|
183
|
+
else if (sel.kind === Kind.INLINE_FRAGMENT) {
|
|
184
|
+
const within = this.schema && sel.typeCondition ? this.narrow(parentType, sel.typeCondition.name.value) : parentType;
|
|
185
|
+
this.walk(sel.selectionSet, within, path);
|
|
186
|
+
} else if (sel.kind === Kind.FRAGMENT_SPREAD) {
|
|
187
|
+
const def = this.fragments.get(sel.name.value);
|
|
188
|
+
const within = this.schema ? this.narrow(parentType, def.typeCondition.name.value) : parentType;
|
|
189
|
+
const memo = `${within}\u0000${path}\u0000${sel.name.value}`;
|
|
190
|
+
if (this.walked.has(memo)) continue;
|
|
191
|
+
this.walked.add(memo);
|
|
192
|
+
this.walk(def.selectionSet, within, path);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Distinct type conditions applied directly to a field's selection set (N2, S4). */
|
|
198
|
+
explicitTypes(selSet) {
|
|
199
|
+
const found = new Set();
|
|
200
|
+
const look = (set) => {
|
|
201
|
+
for (const s of set.selections) {
|
|
202
|
+
if (s.kind === Kind.INLINE_FRAGMENT) {
|
|
203
|
+
if (s.typeCondition) found.add(s.typeCondition.name.value);
|
|
204
|
+
else look(s.selectionSet);
|
|
205
|
+
} else if (s.kind === Kind.FRAGMENT_SPREAD) {
|
|
206
|
+
found.add(this.fragments.get(s.name.value).typeCondition.name.value);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
look(selSet);
|
|
211
|
+
return [...found];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Combine this occurrence's directive and spec properties into the field's overrides. */
|
|
215
|
+
mergeOverrides(info, directive, spec, path) {
|
|
216
|
+
for (const [source, props] of [["spec", spec], ["directive", directive]]) {
|
|
217
|
+
for (const [prop, value] of Object.entries(props)) {
|
|
218
|
+
const have = info.ov[prop];
|
|
219
|
+
if (have === undefined) {
|
|
220
|
+
info.ov[prop] = value;
|
|
221
|
+
info.ovSrc[prop] = source;
|
|
222
|
+
} else if (JSON.stringify(have) !== JSON.stringify(value)) {
|
|
223
|
+
if (OV_SOURCE_RANK[source] > OV_SOURCE_RANK[info.ovSrc[prop]]) {
|
|
224
|
+
info.ov[prop] = value;
|
|
225
|
+
info.ovSrc[prop] = source;
|
|
226
|
+
} else if (OV_SOURCE_RANK[source] === OV_SOURCE_RANK[info.ovSrc[prop]] && source === "directive") {
|
|
227
|
+
throw new GraceholderError("conflicting-overrides", `field ${info.parentType}.${info.name} has different @${prop === "gen" ? "gen/pattern/pick" : prop} overrides in different places`, { path });
|
|
228
|
+
}
|
|
229
|
+
// equal source and spec: the first (most specific for that path) wins
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** S3: is this a Relay connection selection? */
|
|
236
|
+
isConnection(node, explicit) {
|
|
237
|
+
if (explicit.length === 1 && explicit[0].endsWith("Connection")) return true;
|
|
238
|
+
const kids = flattenFields(node.selectionSet, this.fragments);
|
|
239
|
+
const withSel = (n) => kids.some((k) => k.name.value === n && k.selectionSet);
|
|
240
|
+
return withSel("edges") || withSel("pageInfo") || (withSel("nodes") && kids.some((k) => k.name.value === "totalCount"));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
synthField(spec, name, parentType, path) {
|
|
244
|
+
const fields = this.ensureType(parentType);
|
|
245
|
+
if (fields.has(name)) return fields.get(name);
|
|
246
|
+
const info = { name, path, parentType, args: new Map(), explicit: false, synthetic: true, ...spec };
|
|
247
|
+
fields.set(name, info);
|
|
248
|
+
if (info.kind === "object") this.ensureType(info.typeName);
|
|
249
|
+
return info;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
field(node, parentType, parentPath) {
|
|
253
|
+
const name = node.name.value;
|
|
254
|
+
if (name.startsWith("__")) return;
|
|
255
|
+
const path = parentPath ? `${parentPath}.${name}` : name;
|
|
256
|
+
const synth = this.synthetic.get(parentType);
|
|
257
|
+
if (synth && SYNTH[synth.kind]?.[name]) {
|
|
258
|
+
const info = this.synthField(SYNTH[synth.kind][name](synth.item, synth), name, parentType, path);
|
|
259
|
+
if (!this.byPath.has(path)) this.byPath.set(path, info);
|
|
260
|
+
if (node.selectionSet) this.walk(node.selectionSet, info.typeName, path);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (this.schema) return this.schemaField(node, parentType, parentPath);
|
|
264
|
+
const fields = this.ensureType(parentType);
|
|
265
|
+
const hasSel = Boolean(node.selectionSet);
|
|
266
|
+
let info = fields.get(name);
|
|
267
|
+
if (!info) {
|
|
268
|
+
info = { name, path, parentType, kind: hasSel ? "object" : "scalar", args: new Map(), typeName: undefined, explicit: false, ov: {}, ovSrc: {} };
|
|
269
|
+
fields.set(name, info);
|
|
270
|
+
} else if ((info.kind === "object") !== hasSel) {
|
|
271
|
+
throw new GraceholderError("conflicting-shape", `field ${parentType}.${name} is selected both with and without a sub-selection`, { path });
|
|
272
|
+
}
|
|
273
|
+
for (const a of node.arguments ?? []) this.arg(info, a);
|
|
274
|
+
this.mergeOverrides(info, directiveProps(node.directives, this.variables, path), specProps(this.spec, path, parentType, name), path);
|
|
275
|
+
if (!this.byPath.has(path)) this.byPath.set(path, info);
|
|
276
|
+
if (!hasSel) return;
|
|
277
|
+
|
|
278
|
+
let explicit = this.explicitTypes(node.selectionSet);
|
|
279
|
+
if (info.ov.type !== undefined) explicit = [info.ov.type];
|
|
280
|
+
if (info.typeName !== undefined) {
|
|
281
|
+
if (explicit.length === 1 && explicit[0] !== info.typeName && !info.abstract) {
|
|
282
|
+
throw new GraceholderError("conflicting-shape", `field ${parentType}.${name} is typed ${info.typeName} in one place and ${explicit[0]} in another`, { path });
|
|
283
|
+
}
|
|
284
|
+
if (info.connection) return this.walk(node.selectionSet, info.typeName, path);
|
|
285
|
+
if (info.abstract) return this.walkAbstract(node.selectionSet, info, path);
|
|
286
|
+
return this.walk(node.selectionSet, info.typeName, path);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (explicit.length > 1) {
|
|
290
|
+
const plain = plainFields(node.selectionSet, this.fragments);
|
|
291
|
+
const kind = plain.length > 0 ? "interface" : "union";
|
|
292
|
+
info.typeName = typeNameFor(name) + (kind === "union" ? "Union" : "");
|
|
293
|
+
info.abstract = { kind, possible: explicit };
|
|
294
|
+
info.explicit = true;
|
|
295
|
+
this.abstracts.set(info.typeName, info.abstract);
|
|
296
|
+
for (const t of explicit) this.ensureType(t);
|
|
297
|
+
if (kind === "interface") this.ensureType(info.typeName);
|
|
298
|
+
return this.walkAbstract(node.selectionSet, info, path);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (this.isConnection(node, explicit)) {
|
|
302
|
+
let itemType;
|
|
303
|
+
let connType;
|
|
304
|
+
if (explicit.length === 1 && explicit[0].endsWith("Connection")) {
|
|
305
|
+
connType = explicit[0];
|
|
306
|
+
itemType = connType.slice(0, -"Connection".length) || "Node";
|
|
307
|
+
} else {
|
|
308
|
+
const tokens = tokenize(name);
|
|
309
|
+
if (tokens.length > 1 && tokens[tokens.length - 1] === "connection") tokens.pop();
|
|
310
|
+
itemType = info.ov.type ?? typeNameFor(tokens.join("_"));
|
|
311
|
+
connType = `${itemType}Connection`;
|
|
312
|
+
}
|
|
313
|
+
info.typeName = connType;
|
|
314
|
+
info.connection = { item: itemType };
|
|
315
|
+
info.explicit = explicit.length === 1;
|
|
316
|
+
this.synthetic.set(connType, { kind: "connection", item: itemType });
|
|
317
|
+
this.synthetic.set(`${itemType}Edge`, { kind: "edge", item: itemType });
|
|
318
|
+
this.synthetic.set("PageInfo", { kind: "pageInfo", item: itemType });
|
|
319
|
+
this.ensureType(connType);
|
|
320
|
+
this.ensureType(itemType);
|
|
321
|
+
return this.walk(node.selectionSet, connType, path);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
info.typeName = explicit.length === 1 ? explicit[0] : typeNameFor(name);
|
|
325
|
+
info.explicit = explicit.length === 1;
|
|
326
|
+
this.ensureType(info.typeName);
|
|
327
|
+
this.walk(node.selectionSet, info.typeName, path);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Within a fragment: the object type itself when it is already known, else the condition. */
|
|
331
|
+
narrow(parentType, conditionType) {
|
|
332
|
+
this.mentioned.add(conditionType);
|
|
333
|
+
return isObjectType(this.schema.getType(parentType)) ? parentType : conditionType;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* A Relay connection type: `edges { node }` (or `nodes`) with `pageInfo` or `totalCount`, or named `...Connection`.
|
|
338
|
+
* @returns {any}
|
|
339
|
+
*/
|
|
340
|
+
connectionShape(type) {
|
|
341
|
+
const fields = type.getFields();
|
|
342
|
+
/** @type {any} */
|
|
343
|
+
let item = null;
|
|
344
|
+
/** @type {any} */
|
|
345
|
+
let edge = null;
|
|
346
|
+
try {
|
|
347
|
+
if (fields.edges) {
|
|
348
|
+
const sh = shapeOf(fields.edges.type);
|
|
349
|
+
if (sh.list && isObjectType(sh.named) && sh.named.getFields().node) {
|
|
350
|
+
edge = sh.named;
|
|
351
|
+
item = getNamedType(sh.named.getFields().node.type);
|
|
352
|
+
}
|
|
353
|
+
} else if (fields.nodes) {
|
|
354
|
+
const sh = shapeOf(fields.nodes.type);
|
|
355
|
+
if (sh.list) item = sh.named;
|
|
356
|
+
}
|
|
357
|
+
} catch {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
if (!item || !(isObjectType(item) || isAbstractType(item))) return null;
|
|
361
|
+
if (!fields.pageInfo && !fields.totalCount && !/Connection$/.test(type.name)) return null;
|
|
362
|
+
const pageInfo = fields.pageInfo ? getNamedType(fields.pageInfo.type) : null;
|
|
363
|
+
return { item, edge, pageInfo: pageInfo && isObjectType(pageInfo) ? pageInfo : null };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Schema mode: register one selected field on every concrete type it can be read from. */
|
|
367
|
+
schemaField(node, parentName, parentPath) {
|
|
368
|
+
const name = node.name.value;
|
|
369
|
+
const path = parentPath ? `${parentPath}.${name}` : name;
|
|
370
|
+
const parent = this.schema.getType(parentName);
|
|
371
|
+
const directive = directiveProps(node.directives, this.variables, path);
|
|
372
|
+
for (const owner of concreteTypes(this.schema, parent)) {
|
|
373
|
+
const def = owner.getFields()[name];
|
|
374
|
+
if (!def) continue;
|
|
375
|
+
const sh = shapeOf(def.type);
|
|
376
|
+
const kind = isLeaf(sh.named) ? "scalar" : "object";
|
|
377
|
+
const fields = this.ensureType(owner.name);
|
|
378
|
+
let info = fields.get(name);
|
|
379
|
+
if (!info) {
|
|
380
|
+
info = {
|
|
381
|
+
name, path, parentType: owner.name, kind, args: new Map(), typeName: kind === "object" ? sh.named.name : undefined,
|
|
382
|
+
explicit: true, ov: {}, ovSrc: {}, schemaSourced: true, schema: { def, ...sh },
|
|
383
|
+
};
|
|
384
|
+
fields.set(name, info);
|
|
385
|
+
}
|
|
386
|
+
for (const a of node.arguments ?? []) this.arg(info, a);
|
|
387
|
+
this.mergeOverrides(info, directive, specProps(this.spec, path, owner.name, name), path);
|
|
388
|
+
if (!this.byPath.has(path)) this.byPath.set(path, info);
|
|
389
|
+
if (kind === "object" && node.selectionSet) this.schemaObject(node, info, sh.named, path);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
schemaObject(node, info, named, path) {
|
|
394
|
+
if (isObjectType(named)) {
|
|
395
|
+
this.ensureType(named.name);
|
|
396
|
+
const conn = this.connectionShape(named);
|
|
397
|
+
if (conn && !info.connection) {
|
|
398
|
+
info.connection = { item: conn.item.name };
|
|
399
|
+
const rec = { item: conn.item.name, edge: conn.edge?.name, pageInfo: conn.pageInfo?.name };
|
|
400
|
+
this.synthetic.set(named.name, { kind: "connection", ...rec });
|
|
401
|
+
if (conn.edge) this.synthetic.set(conn.edge.name, { kind: "edge", ...rec });
|
|
402
|
+
if (conn.pageInfo) this.synthetic.set(conn.pageInfo.name, { kind: "pageInfo", ...rec });
|
|
403
|
+
this.noteComposite(conn.item);
|
|
404
|
+
}
|
|
405
|
+
} else {
|
|
406
|
+
this.noteComposite(named);
|
|
407
|
+
}
|
|
408
|
+
const key = `${path}\u0000${named.name}`;
|
|
409
|
+
const seen = this.walkedFields.get(node) ?? new Set();
|
|
410
|
+
this.walkedFields.set(node, seen);
|
|
411
|
+
if (seen.has(key)) return;
|
|
412
|
+
seen.add(key);
|
|
413
|
+
this.walk(node.selectionSet, named.name, path);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Make sure every concrete type behind a composite type has a model entry. */
|
|
417
|
+
noteComposite(type) {
|
|
418
|
+
if (isAbstractType(type)) this.abstractNames.add(type.name);
|
|
419
|
+
for (const t of concreteTypes(this.schema, type)) this.ensureType(t.name);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Possible run-time types of each abstract type the query reaches (docs/design.md 17.4). */
|
|
423
|
+
resolveAbstracts() {
|
|
424
|
+
const mentioned = new Set();
|
|
425
|
+
for (const m of this.mentioned) {
|
|
426
|
+
const t = this.schema.getType(m);
|
|
427
|
+
if (t) for (const c of concreteTypes(this.schema, t)) mentioned.add(c.name);
|
|
428
|
+
}
|
|
429
|
+
for (const name of this.abstractNames) {
|
|
430
|
+
const type = this.schema.getType(name);
|
|
431
|
+
const all = concreteTypes(this.schema, type).map((t) => t.name);
|
|
432
|
+
if (all.length === 0) throw new GraceholderError("bad-schema", `${name} has no possible types`);
|
|
433
|
+
let possible = all;
|
|
434
|
+
if (isUnionType(type)) {
|
|
435
|
+
const named = all.filter((n) => mentioned.has(n));
|
|
436
|
+
if (named.length) possible = named;
|
|
437
|
+
}
|
|
438
|
+
this.abstracts.set(name, { kind: isUnionType(type) ? "union" : "interface", possible });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Schema mode: shape, type and generator come from the schema (docs/design.md 17.2 and 17.3). */
|
|
443
|
+
finalizeSchemaField(info, typeName, decisions, users) {
|
|
444
|
+
const { def, named, list, nonNull } = info.schema;
|
|
445
|
+
const conflict = (msg) => new GraceholderError("schema-conflict", `${info.parentType}.${info.name}: ${msg}`, { path: info.path });
|
|
446
|
+
const push = (kind, value, rule, source = "schema") => decisions.push({ path: info.path, field: info.name, kind, value, source, rule });
|
|
447
|
+
if (info.connection) {
|
|
448
|
+
if (info.ov.list !== undefined) throw new GraceholderError("conflicting-overrides", `${info.parentType}.${info.name} is a connection; @list/@one do not apply`, { path: info.path });
|
|
449
|
+
info.list = false;
|
|
450
|
+
} else {
|
|
451
|
+
if (info.ov.list !== undefined && info.ov.list !== list) {
|
|
452
|
+
throw conflict(`the schema declares ${list ? "a list" : "a single value"}, so ${list ? "@one" : "@list"} (L1) contradicts it`);
|
|
453
|
+
}
|
|
454
|
+
info.list = list;
|
|
455
|
+
push("list", list, "schema");
|
|
456
|
+
}
|
|
457
|
+
push("nullable", !nonNull, "schema");
|
|
458
|
+
if (info.ov.nullRate > 0 && nonNull) throw conflict(`nullRate ${info.ov.nullRate} contradicts the non-null type ${String(def.type)}`);
|
|
459
|
+
for (const argName of info.args.keys()) {
|
|
460
|
+
const arg = def.args.find((a) => a.name === argName);
|
|
461
|
+
if (arg) push("arg", `${argName}: ${String(arg.type)}`, undefined);
|
|
462
|
+
}
|
|
463
|
+
if (info.kind === "object") {
|
|
464
|
+
if (info.ov.type !== undefined && info.ov.type !== named.name) {
|
|
465
|
+
throw conflict(`@type names ${info.ov.type}, but the schema declares ${named.name} (N1)`);
|
|
466
|
+
}
|
|
467
|
+
if (isAbstractType(named)) info.abstract = this.abstracts.get(named.name);
|
|
468
|
+
push("type", named.name, info.connection ? "S3" : isAbstractType(named) ? "schema-abstract" : "schema");
|
|
469
|
+
if (!info.connection && !isAbstractType(named) && (users.get(info.typeName) ?? 0) > 1 && !info.ov.own) {
|
|
470
|
+
decisions.push({ path: info.path, field: info.name, kind: "object", value: `pool:${info.typeName}`, source: "default", rule: "E2-shared" });
|
|
471
|
+
}
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
let pick;
|
|
475
|
+
if (isEnumType(named)) {
|
|
476
|
+
pick = { gen: "oneOf", args: { values: named.getValues().map((v) => v.name) }, rule: "schema-enum" };
|
|
477
|
+
} else if (named.name in BUILTIN_FALLBACK) {
|
|
478
|
+
const c = classifyScalar(info.name, { typeName, list, packs: this.packs, realistic: this.realistic });
|
|
479
|
+
pick = compatible(c.sdl, named.name) ? { gen: c.gen, args: c.args, rule: `schema:${c.rule}` } : { ...BUILTIN_FALLBACK[named.name], rule: "schema-builtin" };
|
|
480
|
+
} else {
|
|
481
|
+
const m = customScalarGen(named.name);
|
|
482
|
+
const c = classifyScalar(info.name, { typeName, list, packs: this.packs, realistic: this.realistic });
|
|
483
|
+
// a date-time scalar keeps the coherent date generators of the field name (createdAt <= updatedAt, deletedAt)
|
|
484
|
+
if (m && !(m.gen === "datetime" && m.args.format === undefined && c.sdl === "DateTime")) pick = { gen: m.gen, args: m.args, rule: "schema-scalar" };
|
|
485
|
+
else pick = { gen: c.gen, args: c.args, rule: `schema:${c.rule}` };
|
|
486
|
+
}
|
|
487
|
+
info.gen = pick.gen;
|
|
488
|
+
info.genArgs = pick.gen === "time.deleted" && nonNull ? { ...pick.args, nullRate: 0 } : pick.args;
|
|
489
|
+
info.sdl = named.name;
|
|
490
|
+
info.decision = { path: info.path, field: info.name, kind: "scalar", value: named.name, source: "schema", rule: pick.rule };
|
|
491
|
+
decisions.push(info.decision);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Schema mode, after the overrides are applied: an override must produce what the schema declares. */
|
|
495
|
+
checkSchemaOverrides() {
|
|
496
|
+
for (const fields of this.types.values()) {
|
|
497
|
+
for (const info of fields.values()) {
|
|
498
|
+
if (!info.schemaSourced || info.synthetic || info.kind !== "scalar") continue;
|
|
499
|
+
const { named, nonNull } = info.schema;
|
|
500
|
+
const declared = named.name;
|
|
501
|
+
info.sdl = declared;
|
|
502
|
+
info.decision.value = declared;
|
|
503
|
+
const g = info.ov.gen;
|
|
504
|
+
if (g === undefined) continue;
|
|
505
|
+
const conflict = (msg) => new GraceholderError("schema-conflict", `${info.parentType}.${info.name}: ${msg}`, { path: info.path });
|
|
506
|
+
if (isEnumType(named)) {
|
|
507
|
+
const values = g.name === "oneOf" && Array.isArray(g.args.values) ? g.args.values.map((v) => (v !== null && typeof v === "object" && "weight" in v ? v.value : v)) : null;
|
|
508
|
+
const allowed = new Set(named.getValues().map((v) => v.name));
|
|
509
|
+
if (!values) throw conflict(`${g.name} cannot produce the enum ${declared}; use pick with values from the schema`);
|
|
510
|
+
const stray = values.find((v) => !allowed.has(v));
|
|
511
|
+
if (stray !== undefined) throw conflict(`${JSON.stringify(stray)} is not a value of the enum ${declared} (${[...allowed].join(", ")})`);
|
|
512
|
+
} else if (declared in BUILTIN_FALLBACK) {
|
|
513
|
+
const produced = genSdlType(g.name, g.args);
|
|
514
|
+
if (!compatible(produced, declared)) throw conflict(`${g.name} produces ${produced}, but the schema declares ${declared}`);
|
|
515
|
+
}
|
|
516
|
+
if (nonNull && g.name === "time.deleted" && (g.args.nullRate ?? 1) >= 1) throw conflict(`time.deleted is null by default, but ${String(info.schema.def.type)} is non-null (set args.nullRate below 1)`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Walk the selection of a union/interface field: plain fields to the interface and each member, fragments to their type. */
|
|
522
|
+
walkAbstract(selSet, info, path) {
|
|
523
|
+
const { kind, possible } = info.abstract;
|
|
524
|
+
const visit = (set) => {
|
|
525
|
+
for (const s of set.selections) {
|
|
526
|
+
if (s.kind === Kind.FIELD) {
|
|
527
|
+
if (kind === "interface") this.field(s, info.typeName, path);
|
|
528
|
+
for (const t of possible) this.field(s, t, path);
|
|
529
|
+
} else if (s.kind === Kind.INLINE_FRAGMENT) {
|
|
530
|
+
if (s.typeCondition) this.walk(s.selectionSet, s.typeCondition.name.value, path);
|
|
531
|
+
else visit(s.selectionSet);
|
|
532
|
+
} else if (s.kind === Kind.FRAGMENT_SPREAD) {
|
|
533
|
+
const def = this.fragments.get(s.name.value);
|
|
534
|
+
this.walk(def.selectionSet, def.typeCondition.name.value, path);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
visit(selSet);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
arg(info, a) {
|
|
542
|
+
const argName = a.name.value;
|
|
543
|
+
const list = info.args.get(argName) ?? [];
|
|
544
|
+
list.push({ type: this.valueType(a.value, `${pascal(tokenize(info.name))}${pascal(tokenize(argName))}`), isList: a.value.kind === Kind.LIST });
|
|
545
|
+
info.args.set(argName, list);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
valueType(v, inputName) {
|
|
549
|
+
switch (v.kind) {
|
|
550
|
+
case Kind.INT: return "Int";
|
|
551
|
+
case Kind.FLOAT: return "Float";
|
|
552
|
+
case Kind.STRING: return "String";
|
|
553
|
+
case Kind.BOOLEAN: return "Boolean";
|
|
554
|
+
case Kind.NULL: return "String";
|
|
555
|
+
case Kind.ENUM: this.noteScalar(ANY_SCALAR); return ANY_SCALAR;
|
|
556
|
+
case Kind.VARIABLE: {
|
|
557
|
+
const t = this.varTypes.get(v.name.value);
|
|
558
|
+
if (t) return t;
|
|
559
|
+
this.noteScalar(ANY_SCALAR);
|
|
560
|
+
return ANY_SCALAR;
|
|
561
|
+
}
|
|
562
|
+
case Kind.LIST: {
|
|
563
|
+
if (v.values.length === 0) { this.noteScalar(ANY_SCALAR); return `[${ANY_SCALAR}]`; }
|
|
564
|
+
return `[${withoutBang(this.valueType(v.values[0], inputName))}]`;
|
|
565
|
+
}
|
|
566
|
+
case Kind.OBJECT: {
|
|
567
|
+
if (v.fields.length === 0) { this.noteScalar(ANY_SCALAR); return ANY_SCALAR; }
|
|
568
|
+
const name = `${inputName}Input`;
|
|
569
|
+
const fields = this.inputs.get(name) ?? new Map();
|
|
570
|
+
this.inputs.set(name, fields);
|
|
571
|
+
for (const f of v.fields) {
|
|
572
|
+
if (!fields.has(f.name.value)) {
|
|
573
|
+
fields.set(f.name.value, withoutBang(this.valueType(f.value, `${inputName}${pascal(tokenize(f.name.value))}`)));
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return name;
|
|
577
|
+
}
|
|
578
|
+
default: return "String";
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Apply L/T rules to every field once all occurrences are merged. */
|
|
583
|
+
finalize() {
|
|
584
|
+
const decisions = [];
|
|
585
|
+
if (this.schema) this.resolveAbstracts();
|
|
586
|
+
const users = new Map(); // object type -> number of fields that reference it
|
|
587
|
+
for (const fields of this.types.values()) {
|
|
588
|
+
for (const info of fields.values()) if (info.kind === "object" && !info.synthetic) users.set(info.typeName, (users.get(info.typeName) ?? 0) + 1);
|
|
589
|
+
}
|
|
590
|
+
for (const [typeName, fields] of this.types) {
|
|
591
|
+
for (const info of fields.values()) {
|
|
592
|
+
if (info.synthetic) {
|
|
593
|
+
if (info.kind === "scalar" && info.sdl === "DateTime") this.usesDateTime = true;
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
if (info.schemaSourced) {
|
|
597
|
+
this.finalizeSchemaField(info, typeName, decisions, users);
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
const argNames = [...info.args.keys()];
|
|
601
|
+
if (info.connection) {
|
|
602
|
+
if (info.ov.list !== undefined) throw new GraceholderError("conflicting-overrides", `${info.parentType}.${info.name} is a connection; @list/@one do not apply`, { path: info.path });
|
|
603
|
+
info.list = false;
|
|
604
|
+
decisions.push({ path: info.path, field: info.name, kind: "type", value: info.typeName, source: "heuristic", rule: "S3" });
|
|
605
|
+
} else if (info.abstract) {
|
|
606
|
+
info.list = info.ov.list ?? listRule(info.name, { paging: argNames.some((n) => PAGING_ARGS.has(n)), idsArg: argNames.includes("ids"), listLiteralArg: false, realistic: this.realistic, object: true }).list;
|
|
607
|
+
decisions.push({ path: info.path, field: info.name, kind: "type", value: info.typeName, source: "heuristic", rule: "S4" });
|
|
608
|
+
} else {
|
|
609
|
+
const rule = listRule(info.name, {
|
|
610
|
+
paging: argNames.some((n) => PAGING_ARGS.has(n)),
|
|
611
|
+
idsArg: argNames.includes("ids"),
|
|
612
|
+
listLiteralArg: [...info.args.values()].some((u) => u.some((x) => x.isList || x.type.startsWith("["))),
|
|
613
|
+
realistic: this.realistic,
|
|
614
|
+
object: info.kind === "object",
|
|
615
|
+
});
|
|
616
|
+
let list = info.kind === "scalar" && countOf(info.name) ? false : rule.list; // a count is a number
|
|
617
|
+
let listRuleId = rule.rule;
|
|
618
|
+
let listSource = rule.rule === "L6" ? "default" : "heuristic";
|
|
619
|
+
if (info.ov.list !== undefined) {
|
|
620
|
+
list = info.ov.list;
|
|
621
|
+
listRuleId = "L1";
|
|
622
|
+
listSource = info.ovSrc.list;
|
|
623
|
+
}
|
|
624
|
+
info.list = list;
|
|
625
|
+
decisions.push({ path: info.path, field: info.name, kind: "list", value: list, source: listSource, rule: listRuleId });
|
|
626
|
+
if (info.kind === "object") {
|
|
627
|
+
decisions.push({ path: info.path, field: info.name, kind: "type", value: info.typeName, source: info.ov.type ? info.ovSrc.type : "heuristic", rule: info.ov.type ? "N1" : info.explicit ? "N2" : "N3" });
|
|
628
|
+
if ((users.get(info.typeName) ?? 0) > 1 && !info.ov.own) {
|
|
629
|
+
decisions.push({ path: info.path, field: info.name, kind: "object", value: `pool:${info.typeName}`, source: "default", rule: "E2-shared" });
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if (info.kind === "scalar") {
|
|
634
|
+
const c = classifyScalar(info.name, { typeName, list: info.list, packs: this.packs, realistic: this.realistic });
|
|
635
|
+
info.sdl = c.sdl;
|
|
636
|
+
info.gen = c.gen;
|
|
637
|
+
info.genArgs = c.args;
|
|
638
|
+
info.nullable = c.nullable;
|
|
639
|
+
if (c.sdl === "DateTime") this.usesDateTime = true;
|
|
640
|
+
info.decision = { path: info.path, field: info.name, kind: "scalar", value: c.sdl, source: c.rule === "T13" ? "default" : "heuristic", rule: c.rule };
|
|
641
|
+
decisions.push(info.decision);
|
|
642
|
+
}
|
|
643
|
+
for (const [argName, usages] of info.args) {
|
|
644
|
+
decisions.push({ path: info.path, field: info.name, kind: "arg", value: `${argName}: ${argType(usages)}`, source: "heuristic" });
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
this.applyOverrides(decisions);
|
|
649
|
+
this.countFields();
|
|
650
|
+
this.resolveDerives();
|
|
651
|
+
if (this.schema) this.checkSchemaOverrides();
|
|
652
|
+
this.applyScenarios();
|
|
653
|
+
for (const entry of this.spec.entries) {
|
|
654
|
+
if (!entry.used) decisions.push({ path: entry.key, field: entry.key.split(".").pop(), kind: "unused", value: null, source: "spec", rule: "unused-spec-path" });
|
|
655
|
+
}
|
|
656
|
+
return decisions;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Apply gen/pattern/pick, count, nullRate, derive, key, own, shared and seed overrides. */
|
|
660
|
+
applyOverrides(decisions) {
|
|
661
|
+
const conflict = (info, msg) => new GraceholderError("conflicting-overrides", `${info.parentType}.${info.name}: ${msg}`, { path: info.path });
|
|
662
|
+
for (const fields of this.types.values()) {
|
|
663
|
+
for (const info of fields.values()) {
|
|
664
|
+
if (info.synthetic) continue;
|
|
665
|
+
const { ov, ovSrc: src } = info;
|
|
666
|
+
const push = (kind, value, rule, prop) => decisions.push({ path: info.path, field: info.name, kind, value, source: src[prop], rule });
|
|
667
|
+
if (ov.gen !== undefined) {
|
|
668
|
+
if (info.kind !== "scalar") throw conflict(info, "@gen/@pattern/@pick apply to scalar fields only");
|
|
669
|
+
checkGenerator(ov.gen.name, ov.gen.args, info.path, this.packs);
|
|
670
|
+
info.gen = ov.gen.name;
|
|
671
|
+
info.genArgs = ov.gen.args;
|
|
672
|
+
info.sdl = genSdlType(ov.gen.name, ov.gen.args, this.packs);
|
|
673
|
+
info.nullable = ov.gen.name === "time.deleted" && (ov.gen.args.nullRate ?? 1) >= 1 ? true : undefined;
|
|
674
|
+
if (info.sdl === "DateTime") this.usesDateTime = true;
|
|
675
|
+
Object.assign(info.decision, { value: info.sdl, source: src.gen, rule: "gen" });
|
|
676
|
+
}
|
|
677
|
+
if (ov.count !== undefined) {
|
|
678
|
+
if (!info.list && !info.connection) throw conflict(info, "@count needs a list (add @list, or use a plural field name)");
|
|
679
|
+
info.count = ov.count;
|
|
680
|
+
push("count", ov.count.n !== undefined ? ov.count.n : [ov.count.min, ov.count.max], "C1", "count");
|
|
681
|
+
}
|
|
682
|
+
if (ov.nullRate !== undefined) {
|
|
683
|
+
info.nullRate = ov.nullRate;
|
|
684
|
+
info.nullable = ov.nullRate > 0 ? true : info.nullable;
|
|
685
|
+
push("null", ov.nullRate, "nullRate", "nullRate");
|
|
686
|
+
}
|
|
687
|
+
if (ov.derive !== undefined) {
|
|
688
|
+
if (info.kind !== "scalar") throw conflict(info, "@derive applies to scalar fields only");
|
|
689
|
+
info.derive = ov.derive;
|
|
690
|
+
}
|
|
691
|
+
if (ov.key !== undefined) { info.keyArgs = ov.key; push("key", ov.key, "key", "key"); }
|
|
692
|
+
if (ov.own !== undefined) info.own = ov.own;
|
|
693
|
+
if (ov.shared !== undefined) info.shared = ov.shared;
|
|
694
|
+
if (ov.seed !== undefined) { info.seed = ov.seed; push("seed", ov.seed, "seed", "seed"); }
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Scenarios (docs/design.md section 16, Scenarios): each field gets the scenario set of its spec entry,
|
|
701
|
+
* else the global one. `nulls` and `errors` make an eligible field nullable in the inferred SDL, because
|
|
702
|
+
* a field the schema says is non-null could only fail by emptying its parent. Fields that are `id`, the
|
|
703
|
+
* Relay machinery, and (for `nulls`) the fields of the operation root stay non-null. In schema mode
|
|
704
|
+
* nothing is widened: the schema's own nullability decides.
|
|
705
|
+
*/
|
|
706
|
+
applyScenarios() {
|
|
707
|
+
for (const fields of this.types.values()) {
|
|
708
|
+
for (const info of fields.values()) {
|
|
709
|
+
if (info.synthetic) continue;
|
|
710
|
+
const sc = info.ov.scenario ?? this.scenario;
|
|
711
|
+
if (!sc.length) continue;
|
|
712
|
+
info.scenario = sc;
|
|
713
|
+
// schema mode: the schema decides what is nullable, nothing is widened
|
|
714
|
+
if (info.name === "id" || this.schema) continue;
|
|
715
|
+
const root = info.parentType === this.rootName;
|
|
716
|
+
if (sc.includes("errors") || (sc.includes("nulls") && !root)) info.nullable = true;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** @derive: the source must be a scalar of the same type, and derivations must not form a cycle. */
|
|
722
|
+
resolveDerives() {
|
|
723
|
+
for (const [typeName, fields] of this.types) {
|
|
724
|
+
for (const info of fields.values()) {
|
|
725
|
+
if (info.derive === undefined) continue;
|
|
726
|
+
const seen = [info.name];
|
|
727
|
+
let cur = info;
|
|
728
|
+
while (cur?.derive !== undefined) {
|
|
729
|
+
const next = fields.get(cur.derive);
|
|
730
|
+
if (cur.derive === info.name || seen.includes(cur.derive)) {
|
|
731
|
+
throw new GraceholderError("bad-derive", `@derive cycle on ${typeName}: ${[...seen, cur.derive].join(" -> ")}`, { path: info.path });
|
|
732
|
+
}
|
|
733
|
+
if (next && next.kind !== "scalar") throw new GraceholderError("bad-derive", `${typeName}.${info.name} derives from ${cur.derive}, which is not a scalar field`, { path: info.path });
|
|
734
|
+
seen.push(cur.derive);
|
|
735
|
+
cur = next;
|
|
736
|
+
}
|
|
737
|
+
const src = fields.get(info.derive);
|
|
738
|
+
info.sdl = src ? src.sdl : classifyScalar(info.derive, { typeName, list: false, packs: this.packs, realistic: this.realistic }).sdl;
|
|
739
|
+
if (info.sdl === "DateTime") this.usesDateTime = true;
|
|
740
|
+
info.list = false;
|
|
741
|
+
info.nullable = info.nullRate > 0 ? true : undefined;
|
|
742
|
+
Object.assign(info.decision, { value: info.sdl, source: info.ovSrc.derive, rule: "derive" });
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** `<x>Count` / `total<X>s` equal the total of the sibling collection `<x>s` (docs/design.md 6.2). */
|
|
748
|
+
countFields() {
|
|
749
|
+
for (const fields of this.types.values()) {
|
|
750
|
+
for (const info of fields.values()) {
|
|
751
|
+
if (info.synthetic || info.kind !== "scalar" || info.list) continue;
|
|
752
|
+
if (info.schemaSourced && !["Int", "Float"].includes(info.sdl)) continue;
|
|
753
|
+
const words = countOf(info.name);
|
|
754
|
+
if (!words) continue;
|
|
755
|
+
const want = words.join("_");
|
|
756
|
+
let sibling;
|
|
757
|
+
for (const other of fields.values()) {
|
|
758
|
+
if (other === info || other.synthetic || !other.list) continue;
|
|
759
|
+
const t = tokenize(other.name);
|
|
760
|
+
t[t.length - 1] = singularize(t[t.length - 1]);
|
|
761
|
+
if (t.join("_") === want) sibling = other;
|
|
762
|
+
}
|
|
763
|
+
if (info.ov.gen !== undefined || info.ov.derive !== undefined) continue;
|
|
764
|
+
if (!sibling && MEASURES.has(words[words.length - 1])) continue;
|
|
765
|
+
const target = sibling ? sibling.name : camel([...words.slice(0, -1), pluralize(words[words.length - 1])]);
|
|
766
|
+
info.sdl = "Int";
|
|
767
|
+
info.gen = "count.of";
|
|
768
|
+
info.genArgs = { of: target };
|
|
769
|
+
Object.assign(info.decision, { value: "Int", rule: "C-count", source: "heuristic" });
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
sdl() {
|
|
775
|
+
if (this.schema) return printSchema(this.schema);
|
|
776
|
+
const out = [];
|
|
777
|
+
const scalars = [];
|
|
778
|
+
if (this.usesDateTime) scalars.push("DateTime");
|
|
779
|
+
scalars.push(...this.customScalars);
|
|
780
|
+
if (scalars.length) out.push(scalars.map((s) => `scalar ${s}`).join("\n"));
|
|
781
|
+
const implementsOf = new Map();
|
|
782
|
+
for (const [name, a] of this.abstracts) {
|
|
783
|
+
if (a.kind === "interface") for (const t of a.possible) implementsOf.set(t, [...(implementsOf.get(t) ?? []), name]);
|
|
784
|
+
}
|
|
785
|
+
for (const [typeName, fields] of this.types) {
|
|
786
|
+
const lines = [];
|
|
787
|
+
for (const info of fields.values()) {
|
|
788
|
+
const args = [...info.args.entries()].map(([n, u]) => `${n}: ${argType(u)}`);
|
|
789
|
+
const argText = args.length ? `(${args.join(", ")})` : "";
|
|
790
|
+
const base = info.kind === "object" ? info.typeName : info.sdl;
|
|
791
|
+
let type = info.list ? `[${base}!]!` : `${base}!`;
|
|
792
|
+
// a world with deletions: a singular reference to a deleted entity resolves to null
|
|
793
|
+
if (info.nullable || this.nullableFields?.has(`${typeName}.${info.name}`) || (this.nullableTypes && info.kind === "object" && !info.list && !info.connection && !info.abstract && this.nullableTypes.has(info.typeName))) type = type.replace(/!$/, "");
|
|
794
|
+
lines.push(` ${info.name}${argText}: ${type}`);
|
|
795
|
+
}
|
|
796
|
+
if (lines.length === 0) lines.push(" _empty: String");
|
|
797
|
+
const abstract = this.abstracts.get(typeName);
|
|
798
|
+
const impl = implementsOf.get(typeName);
|
|
799
|
+
const head = abstract?.kind === "interface" ? `interface ${typeName}` : `type ${typeName}${impl ? ` implements ${impl.join(" & ")}` : ""}`;
|
|
800
|
+
out.push(`${head} {\n${lines.join("\n")}\n}`);
|
|
801
|
+
}
|
|
802
|
+
for (const [name, a] of this.abstracts) {
|
|
803
|
+
if (a.kind === "union") out.push(`union ${name} = ${a.possible.join(" | ")}`);
|
|
804
|
+
}
|
|
805
|
+
for (const [name, fields] of this.inputs) {
|
|
806
|
+
out.push(`input ${name} {\n${[...fields].map(([n, t]) => ` ${n}: ${t}`).join("\n")}\n}`);
|
|
807
|
+
}
|
|
808
|
+
return out.join("\n\n") + "\n";
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** Fixed shapes of the Relay types (docs/design.md 3.4). */
|
|
813
|
+
const SYNTH = {
|
|
814
|
+
connection: {
|
|
815
|
+
edges: (item, s) => ({ kind: "object", list: true, typeName: s?.edge ?? `${item}Edge`, special: "edges" }),
|
|
816
|
+
nodes: (item) => ({ kind: "object", list: true, typeName: item, special: "nodes" }),
|
|
817
|
+
pageInfo: (_item, s) => ({ kind: "object", list: false, typeName: s?.pageInfo ?? "PageInfo", special: "pageInfo" }),
|
|
818
|
+
totalCount: () => ({ kind: "scalar", list: false, sdl: "Int", special: "totalCount" }),
|
|
819
|
+
},
|
|
820
|
+
edge: {
|
|
821
|
+
cursor: () => ({ kind: "scalar", list: false, sdl: "String", special: "cursor" }),
|
|
822
|
+
node: (item) => ({ kind: "object", list: false, typeName: item, special: "edgeNode" }),
|
|
823
|
+
},
|
|
824
|
+
pageInfo: {
|
|
825
|
+
hasNextPage: () => ({ kind: "scalar", list: false, sdl: "Boolean", special: "pageInfoField" }),
|
|
826
|
+
hasPreviousPage: () => ({ kind: "scalar", list: false, sdl: "Boolean", special: "pageInfoField" }),
|
|
827
|
+
startCursor: () => ({ kind: "scalar", list: false, sdl: "String", nullable: true, special: "pageInfoField" }),
|
|
828
|
+
endCursor: () => ({ kind: "scalar", list: false, sdl: "String", nullable: true, special: "pageInfoField" }),
|
|
829
|
+
},
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
/** One type for an argument seen several times: identical, else the nullable form of the first. */
|
|
833
|
+
function argType(usages) {
|
|
834
|
+
const first = usages[0].type;
|
|
835
|
+
return usages.every((u) => u.type === first) ? first : withoutBang(first);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* @param {string|import("graphql").DocumentNode} query
|
|
840
|
+
* @param {{ operationName?: string, spec?: any, compiledSpec?: any, variables?: Record<string, unknown>, strict?: boolean, nullableTypes?: Set<string>, nullableFields?: Set<string>, packs?: any[], realistic?: boolean, schema?: any, scenario?: string[] }} [options]
|
|
841
|
+
*/
|
|
842
|
+
export function infer(query, options = {}) {
|
|
843
|
+
const doc = toDocument(query);
|
|
844
|
+
const op = selectOperation(doc, options.operationName);
|
|
845
|
+
const fragments = reachableFragments(doc, op);
|
|
846
|
+
checkDirectives([op, ...fragments.values()]);
|
|
847
|
+
const spec = options.compiledSpec ?? compileSpec(options.spec);
|
|
848
|
+
const variables = operationVariables(op, options.variables);
|
|
849
|
+
const schema = options.schema === undefined || options.schema === null ? undefined : loadSchema(options.schema);
|
|
850
|
+
/** @type {import("graphql").DocumentNode} */
|
|
851
|
+
const document = { kind: Kind.DOCUMENT, definitions: [dropDirectiveOnlyVariables(op, fragments, stripDirectives(op)), ...[...fragments.values()].map(stripDirectives)] };
|
|
852
|
+
if (schema) {
|
|
853
|
+
// the query is validated against the real schema before anything is inferred from it
|
|
854
|
+
const invalid = validate(schema, document);
|
|
855
|
+
if (invalid.length) throw new GraceholderError("graphql", invalid[0].message, { cause: invalid[0], errors: invalid });
|
|
856
|
+
}
|
|
857
|
+
const inf = new Inferer(op, fragments, { spec, variables, schema, nullableTypes: options.nullableTypes, nullableFields: options.nullableFields, packs: options.packs, realistic: options.realistic, scenario: options.scenario });
|
|
858
|
+
const decisions = inf.finalize();
|
|
859
|
+
if (options.strict) {
|
|
860
|
+
const unused = decisions.find((d) => d.kind === "unused");
|
|
861
|
+
if (unused) throw new GraceholderError("unused-spec-path", `spec path ${JSON.stringify(unused.path)} matches no field in the operation`, { path: unused.path });
|
|
862
|
+
}
|
|
863
|
+
const model = new Map();
|
|
864
|
+
for (const [typeName, fields] of inf.types) model.set(typeName, fields);
|
|
865
|
+
return { sdl: inf.sdl(), decisions, document, model, abstracts: inf.abstracts, opSeed: inf.opSeed, spec, schema, rootType: inf.rootName, byPath: inf.byPath };
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/** Variables that only graceholder directives referenced are not variables of the stripped document. */
|
|
869
|
+
function dropDirectiveOnlyVariables(op, fragments, stripped) {
|
|
870
|
+
const used = new Set();
|
|
871
|
+
const collect = (node) => visit(node, { VariableDefinition: () => false, Variable(v) { used.add(v.name.value); } });
|
|
872
|
+
collect(stripped);
|
|
873
|
+
for (const f of fragments.values()) collect(stripDirectives(f));
|
|
874
|
+
const defs = (stripped.variableDefinitions ?? []).filter((d) => used.has(d.variable.name.value));
|
|
875
|
+
return { ...stripped, variableDefinitions: defs };
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/** Variable values for directive arguments: provided values over declared defaults. */
|
|
879
|
+
function operationVariables(op, provided = {}) {
|
|
880
|
+
const out = {};
|
|
881
|
+
for (const def of op.variableDefinitions ?? []) {
|
|
882
|
+
if (def.defaultValue) {
|
|
883
|
+
// defaults are plain literals here; richer coercion happens at execution
|
|
884
|
+
out[def.variable.name.value] = JSON.parse(JSON.stringify(valueFromASTUntyped(def.defaultValue)));
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
return { ...out, ...provided };
|
|
888
|
+
}
|