@znight/graphql 0.2.0 → 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/README.md +288 -5
- package/dist/compiler.d.mts +24 -3
- package/dist/compiler.mjs +1876 -109
- package/dist/compiler.mjs.map +1 -1
- package/dist/index.d.mts +49 -8
- package/dist/index.mjs +10 -4
- package/dist/index.mjs.map +1 -1
- package/dist/names-BlzDM5cF.mjs.map +1 -1
- package/dist/{plugin-BdBxr5NQ.mjs → plugin-CR_wVfRU.mjs} +247 -24
- package/dist/plugin-CR_wVfRU.mjs.map +1 -0
- package/dist/testing.d.mts +1 -1
- package/dist/testing.mjs +1 -1
- package/dist/{types-CgbV9LjD.d.mts → types-DQ713UgI.d.mts} +34 -2
- package/package.json +8 -8
- package/src/compiler/analyze.ts +129 -10
- package/src/compiler/builder.ts +6 -85
- package/src/compiler/checks.ts +105 -0
- package/src/compiler/conformance.ts +1068 -0
- package/src/compiler/constants.ts +6 -0
- package/src/compiler/data.ts +10 -0
- package/src/compiler/generate.ts +41 -27
- package/src/compiler/graphql-js.ts +42 -0
- package/src/compiler/index.ts +8 -3
- package/src/compiler/locate.ts +39 -1
- package/src/compiler/model.ts +16 -1
- package/src/compiler/options.ts +39 -8
- package/src/compiler/schema-first.ts +595 -0
- package/src/compiler/sdl-source.ts +529 -0
- package/src/compiler/sdl-view.ts +3 -1
- package/src/compiler/suggest.ts +39 -0
- package/src/decorators.ts +9 -3
- package/src/index.ts +5 -0
- package/src/metadata.ts +28 -1
- package/src/options.ts +23 -1
- package/src/pipeline.ts +66 -5
- package/src/plugin.ts +27 -5
- package/src/results.ts +206 -0
- package/src/scalars.ts +10 -3
- package/src/schema.ts +90 -12
- package/src/sdl.ts +17 -2
- package/src/types.ts +35 -0
- package/dist/plugin-BdBxr5NQ.mjs.map +0 -1
|
@@ -0,0 +1,1068 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type conformance of schema-first resolvers (RFC 0007 §5.4): the TypeScript signatures of the
|
|
3
|
+
* resolver methods, read through the type shapes API, against the SDL types. Output positions
|
|
4
|
+
* (return types, parent types, the properties of the returned objects) must be assignable to the
|
|
5
|
+
* SDL type; input positions (`@Args` parameters, input objects) must accept every value graphql-js
|
|
6
|
+
* passes. The walk follows the object fields without resolver through the properties of the
|
|
7
|
+
* TypeScript types (each SDL type and TypeScript declaration pair once), which is also where an
|
|
8
|
+
* object field that nothing provides is found (coverage, §5.3).
|
|
9
|
+
*
|
|
10
|
+
* Every mismatch is a ZNIGHT068 located on the TypeScript annotation, the SDL definition related.
|
|
11
|
+
* `any`, `unknown`, opaque types and missing annotations are not checked. The walk also decides
|
|
12
|
+
* `nullAsUndefined` of arguments and input fields and the discriminators of abstract types.
|
|
13
|
+
*/
|
|
14
|
+
import type {
|
|
15
|
+
Diagnostic,
|
|
16
|
+
Location,
|
|
17
|
+
MethodInfo,
|
|
18
|
+
NamedShape,
|
|
19
|
+
ParameterInfo,
|
|
20
|
+
Shape,
|
|
21
|
+
ShapeOptions,
|
|
22
|
+
ShapeProperty,
|
|
23
|
+
ShapeResult,
|
|
24
|
+
ValidateContext,
|
|
25
|
+
} from '@znight/compiler'
|
|
26
|
+
import type {
|
|
27
|
+
ASTNode,
|
|
28
|
+
GraphQLAbstractType,
|
|
29
|
+
GraphQLField,
|
|
30
|
+
GraphQLInputObjectType,
|
|
31
|
+
GraphQLInputType,
|
|
32
|
+
GraphQLObjectType,
|
|
33
|
+
GraphQLOutputType,
|
|
34
|
+
GraphQLSchema,
|
|
35
|
+
} from 'graphql'
|
|
36
|
+
import type { FieldData, GraphQLData, ResolverData } from './data.ts'
|
|
37
|
+
import type { DiscriminatorModel } from './model.ts'
|
|
38
|
+
import type { ScalarDeclaration } from './scalars.ts'
|
|
39
|
+
|
|
40
|
+
import { ASYNC_ITERABLES, BASE_KEEP, Codes, compareStrings, OUTPUT_ITERABLES } from './constants.ts'
|
|
41
|
+
import { graphqlModules } from './graphql-js.ts'
|
|
42
|
+
import { GraphQLIds } from './ids.ts'
|
|
43
|
+
import { annotationLocation, returnTypeLocation } from './locate.ts'
|
|
44
|
+
|
|
45
|
+
type Context = ValidateContext<GraphQLData>
|
|
46
|
+
|
|
47
|
+
/** A resolver method bound to an SDL field. */
|
|
48
|
+
export interface Binding {
|
|
49
|
+
readonly resolver: ResolverData
|
|
50
|
+
readonly field: FieldData
|
|
51
|
+
readonly method: MethodInfo
|
|
52
|
+
/** The type declaring the field (a root type for root fields). */
|
|
53
|
+
readonly type: GraphQLObjectType
|
|
54
|
+
readonly sdlField: GraphQLField<unknown, unknown>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Inputs of {@link checkConformance}. */
|
|
58
|
+
export interface ConformanceInput {
|
|
59
|
+
readonly ctx: Context
|
|
60
|
+
readonly schema: GraphQLSchema
|
|
61
|
+
readonly locate: (node: ASTNode | null | undefined) => Location | undefined
|
|
62
|
+
/** `scalar<T>()` declarations by GraphQL name. */
|
|
63
|
+
readonly scalars: ReadonlyMap<string, ScalarDeclaration>
|
|
64
|
+
/** Sorted by resolver identity, then source order. */
|
|
65
|
+
readonly bindings: readonly Binding[]
|
|
66
|
+
/** Coordinates (`Event.owner`) bound to a resolver. */
|
|
67
|
+
readonly bound: ReadonlySet<string>
|
|
68
|
+
readonly report: (diagnostic: Diagnostic) => void
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** What the walk decides for the model. */
|
|
72
|
+
export interface ConformanceResult {
|
|
73
|
+
/** Coordinates of the arguments (`Query.books(limit:)`) and input fields (`BookFilter.genre`) whose `null` becomes `undefined`. */
|
|
74
|
+
readonly nullAsUndefined: ReadonlySet<string>
|
|
75
|
+
/** Discriminators of the abstract types; absent: `typename`. */
|
|
76
|
+
readonly discriminators: ReadonlyMap<string, DiscriminatorModel>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Where a TypeScript type is checked. */
|
|
80
|
+
interface Site {
|
|
81
|
+
/** What the type is, in messages: `EventResolver.event returns`, `User.email is`. */
|
|
82
|
+
readonly label: string
|
|
83
|
+
/** Where the TypeScript type comes from, in messages: `returned by EventResolver.event`. */
|
|
84
|
+
readonly origin: string
|
|
85
|
+
/** The SDL coordinate, in messages: `Query.event`, `Event.owner`, `Query.events(limit:)`. */
|
|
86
|
+
readonly coordinate: string
|
|
87
|
+
/** The TypeScript annotation (called only when a diagnostic is reported). */
|
|
88
|
+
readonly locate: () => Location | undefined
|
|
89
|
+
/** The SDL definition. */
|
|
90
|
+
readonly sdl: Location | undefined
|
|
91
|
+
/** An optional property (output: may be `undefined`). */
|
|
92
|
+
readonly optional: boolean
|
|
93
|
+
/** The type of the field or argument, when `type` is the type of its list items. */
|
|
94
|
+
readonly list?: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Options of an input position. */
|
|
98
|
+
interface InputPosition {
|
|
99
|
+
/** The TypeScript value may be `undefined`: optional parameter or property, parameter default. */
|
|
100
|
+
readonly optional: boolean
|
|
101
|
+
/** The SDL argument or input field has a default value: never absent. */
|
|
102
|
+
readonly sdlDefault: boolean
|
|
103
|
+
/** Coordinate whose `nullAsUndefined` this position votes for. */
|
|
104
|
+
readonly vote?: string
|
|
105
|
+
/** A list item: never absent, and a `null` item stays `null` (no `nullAsUndefined`). */
|
|
106
|
+
readonly item?: true
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A shape split into its non-nullish members. */
|
|
110
|
+
interface Parts {
|
|
111
|
+
readonly members: Shape[]
|
|
112
|
+
hasNull: boolean
|
|
113
|
+
hasUndefined: boolean
|
|
114
|
+
/** `unknown`, `any` or an opaque part: not checked. */
|
|
115
|
+
unchecked: boolean
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const MAX_DEPTH = 64
|
|
119
|
+
|
|
120
|
+
/** Walks every binding; reports the mismatches. */
|
|
121
|
+
export function checkConformance(input: ConformanceInput): ConformanceResult {
|
|
122
|
+
return new Conformance(input).run()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
class Conformance {
|
|
126
|
+
readonly #config: ConformanceInput
|
|
127
|
+
readonly #ctx: Context
|
|
128
|
+
readonly #options: ShapeOptions
|
|
129
|
+
readonly #named = new Map<string, NamedShape>()
|
|
130
|
+
readonly #visited = new Set<string>()
|
|
131
|
+
readonly #anonymous = new WeakMap<object, Set<string>>()
|
|
132
|
+
readonly #reported = new Set<string>()
|
|
133
|
+
readonly #votes = new Map<string, { accepts: number; rejects: Site[] }>()
|
|
134
|
+
/** Abstract type → member object type → class identities (`''`: not an exported class) returned for it. */
|
|
135
|
+
readonly #members = new Map<string, Map<string, Set<string>>>()
|
|
136
|
+
/** TypeScript members of abstract types that cannot be told apart (ZNIGHT064 unless every member is a class). */
|
|
137
|
+
readonly #untellable = new Map<
|
|
138
|
+
string,
|
|
139
|
+
{ readonly name: string; readonly site: Site; readonly location?: Location }[]
|
|
140
|
+
>()
|
|
141
|
+
readonly #predicates = graphqlModules().type
|
|
142
|
+
/** Identities of the `T` of the `scalar<T>()` declarations. */
|
|
143
|
+
readonly #scalarTypes: ReadonlySet<string>
|
|
144
|
+
|
|
145
|
+
constructor(input: ConformanceInput) {
|
|
146
|
+
this.#config = input
|
|
147
|
+
this.#ctx = input.ctx
|
|
148
|
+
const keep = new Set<string>(BASE_KEEP)
|
|
149
|
+
for (const scalar of input.scalars.values()) keep.add(scalar.type)
|
|
150
|
+
this.#scalarTypes = new Set([...input.scalars.values()].map(scalar => scalar.type))
|
|
151
|
+
this.#options = Object.freeze({
|
|
152
|
+
keep: Object.freeze([...keep].sort(compareStrings)),
|
|
153
|
+
members: 'instance',
|
|
154
|
+
jsdoc: false,
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
run(): ConformanceResult {
|
|
159
|
+
for (const binding of this.#config.bindings) this.#binding(binding)
|
|
160
|
+
const nullAsUndefined = new Set<string>()
|
|
161
|
+
for (const [coordinate, vote] of [...this.#votes].sort(([a], [b]) => compareStrings(a, b))) {
|
|
162
|
+
if (vote.rejects.length === 0) continue
|
|
163
|
+
if (vote.accepts === 0) {
|
|
164
|
+
nullAsUndefined.add(coordinate)
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
for (const site of vote.rejects) {
|
|
168
|
+
this.#mismatch(
|
|
169
|
+
site,
|
|
170
|
+
`${site.label} does not accept null, but another use of ${coordinate} does: null is passed as is`,
|
|
171
|
+
['Accept null (| null) in every TypeScript type of the value, or none of them'],
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return { nullAsUndefined, discriminators: this.#discriminators() }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ------------------------------------------------------------------------------------ bindings
|
|
179
|
+
|
|
180
|
+
#binding(binding: Binding): void {
|
|
181
|
+
const { resolver, field, method, type, sdlField } = binding
|
|
182
|
+
const where = `${resolver.host.displayName}.${method.name}`
|
|
183
|
+
const coordinate = `${type.name}.${sdlField.name}`
|
|
184
|
+
const sdl = this.#config.locate(sdlField.astNode)
|
|
185
|
+
if (method.returnType !== undefined) {
|
|
186
|
+
const result = this.#shape({ returnOf: method })
|
|
187
|
+
const site: Site = {
|
|
188
|
+
label: `${where} returns`,
|
|
189
|
+
origin: `returned by ${where}`,
|
|
190
|
+
coordinate,
|
|
191
|
+
locate: () => returnTypeLocation(this.#ctx.project, method) ?? method.location,
|
|
192
|
+
sdl,
|
|
193
|
+
optional: false,
|
|
194
|
+
}
|
|
195
|
+
const root = result.root
|
|
196
|
+
const iterable = root.kind === 'reference' && ASYNC_ITERABLES.has(root.identity) ? root : undefined
|
|
197
|
+
if (field.operation === 'subscription') {
|
|
198
|
+
if (iterable !== undefined) {
|
|
199
|
+
this.#output(sdlField.type, iterable.typeArgs[0] ?? { kind: 'unknown' }, site)
|
|
200
|
+
} else if (!this.#parts(root).unchecked) {
|
|
201
|
+
this.#report({
|
|
202
|
+
code: Codes.InvalidResolver,
|
|
203
|
+
severity: 'error',
|
|
204
|
+
message: `@Subscription ${where} must return an async iterable (${coordinate})`,
|
|
205
|
+
location: site.locate() ?? method.location,
|
|
206
|
+
hints: [
|
|
207
|
+
`Declare it as an async generator (async *${method.name}(): AsyncGenerator<Event>) or return pubsub.subscribe(...) (AsyncIterable<Event>)`,
|
|
208
|
+
],
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
} else if (iterable === undefined) {
|
|
212
|
+
this.#output(sdlField.type, root, site)
|
|
213
|
+
} else {
|
|
214
|
+
this.#mismatch(site, `${where} returns an async iterable: only Subscription fields stream values`, [
|
|
215
|
+
`Use @Subscription() for a stream of events, or return the value of ${coordinate}`,
|
|
216
|
+
])
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
field.params.forEach((data, index) => {
|
|
220
|
+
const param = method.params[index] as ParameterInfo | undefined
|
|
221
|
+
if (param === undefined) return
|
|
222
|
+
if (data?.kind === 'args' && data.schema === undefined) this.#arguments(binding, data.name, param, where)
|
|
223
|
+
else if (data?.kind === 'parent') this.#parent(type, param, where)
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** `@Args('name')` (one argument) or `@Args()` (every argument, an object type). */
|
|
228
|
+
#arguments(binding: Binding, name: string | undefined, param: ParameterInfo, where: string): void {
|
|
229
|
+
const { type, sdlField } = binding
|
|
230
|
+
const coordinate = `${type.name}.${sdlField.name}`
|
|
231
|
+
const result = this.#shape({ parameter: param })
|
|
232
|
+
if (name !== undefined) {
|
|
233
|
+
const arg = sdlField.args.find(candidate => candidate.name === name)
|
|
234
|
+
if (arg === undefined) return // coverage (schema-first.ts)
|
|
235
|
+
this.#input(
|
|
236
|
+
arg.type,
|
|
237
|
+
result.root,
|
|
238
|
+
{
|
|
239
|
+
optional: param.optional || param.hasDefault,
|
|
240
|
+
sdlDefault: arg.default !== undefined,
|
|
241
|
+
vote: `${coordinate}(${name}:)`,
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
label: `parameter '${param.name}' of ${where}`,
|
|
245
|
+
origin: `parameter '${param.name}' of ${where}`,
|
|
246
|
+
coordinate: `${coordinate}(${name}:)`,
|
|
247
|
+
locate: () => param.typeLocation,
|
|
248
|
+
sdl: this.#config.locate(arg.astNode),
|
|
249
|
+
optional: false,
|
|
250
|
+
},
|
|
251
|
+
)
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
const site: Site = {
|
|
255
|
+
label: `@Args() parameter '${param.name}' of ${where}`,
|
|
256
|
+
origin: `@Args() parameter '${param.name}' of ${where}`,
|
|
257
|
+
coordinate,
|
|
258
|
+
locate: () => param.typeLocation,
|
|
259
|
+
sdl: this.#config.locate(sdlField.astNode),
|
|
260
|
+
optional: false,
|
|
261
|
+
}
|
|
262
|
+
const parts = this.#parts(result.root)
|
|
263
|
+
if (parts.unchecked) return
|
|
264
|
+
const object = parts.members.length === 1 ? (parts.members[0] as Shape) : undefined
|
|
265
|
+
const properties = object === undefined ? undefined : this.#propertiesOf(object)
|
|
266
|
+
if (properties === 'record') return
|
|
267
|
+
if (properties === undefined) {
|
|
268
|
+
this.#mismatch(site, `${site.label} must be an object type: it receives every argument of ${coordinate}`, [
|
|
269
|
+
`@Args('name') receives one argument`,
|
|
270
|
+
])
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
for (const property of properties) {
|
|
274
|
+
const arg = sdlField.args.find(candidate => candidate.name === property.name)
|
|
275
|
+
const propertySite: Site = {
|
|
276
|
+
label: `property '${property.name}' of the @Args() parameter of ${where}`,
|
|
277
|
+
origin: site.origin,
|
|
278
|
+
coordinate: `${coordinate}(${property.name}:)`,
|
|
279
|
+
locate: () => annotationLocation(this.#ctx.project, property.location) ?? param.typeLocation,
|
|
280
|
+
sdl: this.#config.locate(arg?.astNode ?? sdlField.astNode),
|
|
281
|
+
optional: false,
|
|
282
|
+
}
|
|
283
|
+
if (arg === undefined) {
|
|
284
|
+
const names = sdlField.args.map(candidate => candidate.name)
|
|
285
|
+
this.#mismatch(
|
|
286
|
+
{ ...propertySite, locate: () => property.location ?? param.typeLocation },
|
|
287
|
+
`${coordinate} has no argument '${property.name}' (${propertySite.label})`,
|
|
288
|
+
[names.length === 0 ? `${coordinate} takes no argument` : `Its arguments: ${names.join(', ')}`],
|
|
289
|
+
)
|
|
290
|
+
continue
|
|
291
|
+
}
|
|
292
|
+
this.#input(
|
|
293
|
+
arg.type,
|
|
294
|
+
property.shape,
|
|
295
|
+
{ optional: property.optional, sdlDefault: arg.default !== undefined, vote: propertySite.coordinate },
|
|
296
|
+
propertySite,
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** `@Parent()`: its properties named like the unbound fields of the type conform to them. */
|
|
302
|
+
#parent(type: GraphQLObjectType, param: ParameterInfo, where: string): void {
|
|
303
|
+
const result = this.#shape({ parameter: param })
|
|
304
|
+
const site: Site = {
|
|
305
|
+
label: `@Parent() parameter '${param.name}' of ${where}`,
|
|
306
|
+
origin: `@Parent() parameter of ${where}`,
|
|
307
|
+
coordinate: type.name,
|
|
308
|
+
locate: () => param.typeLocation,
|
|
309
|
+
sdl: this.#config.locate(type.astNode),
|
|
310
|
+
optional: false,
|
|
311
|
+
}
|
|
312
|
+
const parts = this.#parts(result.root)
|
|
313
|
+
if (parts.unchecked) return
|
|
314
|
+
for (const member of parts.members) {
|
|
315
|
+
const properties = this.#propertiesOf(member)
|
|
316
|
+
if (properties === 'record') continue
|
|
317
|
+
if (properties === undefined) {
|
|
318
|
+
this.#mismatch(site, `${site.label} is ${this.#describe(member)}, not an object type (${type.name})`, [
|
|
319
|
+
`Type it with the TypeScript type of ${type.name}`,
|
|
320
|
+
])
|
|
321
|
+
continue
|
|
322
|
+
}
|
|
323
|
+
const fields = type.getFields()
|
|
324
|
+
for (const property of properties) {
|
|
325
|
+
const field = fields[property.name]
|
|
326
|
+
if (field === undefined || this.#config.bound.has(`${type.name}.${field.name}`)) continue
|
|
327
|
+
this.#output(field.type, property.shape, this.#propertySite(site, type, field, property, member))
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// -------------------------------------------------------------------------------------- output
|
|
333
|
+
|
|
334
|
+
#output(type: GraphQLOutputType, shape: Shape, site: Site, depth = 0): void {
|
|
335
|
+
if (depth > MAX_DEPTH) return
|
|
336
|
+
const parts = this.#parts(shape)
|
|
337
|
+
if (parts.unchecked) return
|
|
338
|
+
const { isNonNullType, isListType, isScalarType, isEnumType, isObjectType } = this.#predicates
|
|
339
|
+
let inner = type
|
|
340
|
+
if (isNonNullType(inner)) {
|
|
341
|
+
if (parts.hasNull || parts.hasUndefined || site.optional) {
|
|
342
|
+
const what = parts.hasNull ? 'null' : 'undefined'
|
|
343
|
+
const described = `${site.label} ${this.#describeParts(parts, site.optional)}, which may be ${what}`
|
|
344
|
+
if (site.list === undefined) {
|
|
345
|
+
this.#mismatch(site, `${described}, but ${site.coordinate} is ${String(type)} (non-null)`, [
|
|
346
|
+
`Return a value in every case, or make ${site.coordinate} nullable in the SDL (${String(inner.ofType)})`,
|
|
347
|
+
])
|
|
348
|
+
} else {
|
|
349
|
+
this.#mismatch(
|
|
350
|
+
site,
|
|
351
|
+
`${described}, but the items of ${site.coordinate} (${site.list}) are ${String(type)} (non-null)`,
|
|
352
|
+
[`Return non-null items, or make the items of ${site.coordinate} nullable in the SDL`],
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
inner = inner.ofType
|
|
357
|
+
}
|
|
358
|
+
if (parts.members.length === 0) return
|
|
359
|
+
if (isListType(inner)) {
|
|
360
|
+
for (const member of parts.members) {
|
|
361
|
+
const item = this.#listItem(member, 'output')
|
|
362
|
+
if (item === undefined) {
|
|
363
|
+
this.#mismatch(
|
|
364
|
+
site,
|
|
365
|
+
`${site.label} ${this.#describe(member)}, not a list, but ${site.coordinate} is ${String(type)}`,
|
|
366
|
+
['Return an array (readonly T[]), a Set or an Iterable'],
|
|
367
|
+
)
|
|
368
|
+
continue
|
|
369
|
+
}
|
|
370
|
+
for (const shape of item) {
|
|
371
|
+
this.#output(
|
|
372
|
+
inner.ofType,
|
|
373
|
+
shape,
|
|
374
|
+
{ ...site, label: `${site.label} a list of`, optional: false, list: site.list ?? String(type) },
|
|
375
|
+
depth + 1,
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
if (isScalarType(inner)) {
|
|
382
|
+
for (const member of parts.members) {
|
|
383
|
+
if (this.#scalarAccepts(inner.name, member, 'output')) continue
|
|
384
|
+
this.#mismatch(
|
|
385
|
+
site,
|
|
386
|
+
`${site.label} ${this.#describe(member)}, which does not conform to ${inner.name} (${site.coordinate})`,
|
|
387
|
+
[this.#scalarHint(inner.name)],
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
if (isEnumType(inner)) {
|
|
393
|
+
const values = new Set(inner.getValues().map(value => value.name))
|
|
394
|
+
for (const member of parts.members) {
|
|
395
|
+
const literals = this.#literals(member)
|
|
396
|
+
if (literals === undefined) {
|
|
397
|
+
this.#mismatch(
|
|
398
|
+
site,
|
|
399
|
+
`${site.label} ${this.#describe(member)}, wider than the enum ${inner.name} (${site.coordinate})`,
|
|
400
|
+
[
|
|
401
|
+
`Return the values of ${inner.name}: ${[...values].join(', ')} (a TypeScript enum or a union of literals)`,
|
|
402
|
+
],
|
|
403
|
+
)
|
|
404
|
+
continue
|
|
405
|
+
}
|
|
406
|
+
const unknown = literals.filter(literal => !values.has(String(literal)))
|
|
407
|
+
if (unknown.length > 0) {
|
|
408
|
+
this.#mismatch(
|
|
409
|
+
site,
|
|
410
|
+
`${site.label} ${unknown.map(literal => JSON.stringify(literal)).join(', ')}, not ${unknown.length === 1 ? 'a value' : 'values'} of the enum ${inner.name} (${site.coordinate})`,
|
|
411
|
+
[`Enum values are compared by value: ${[...values].join(', ')}`],
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
if (isObjectType(inner)) {
|
|
418
|
+
for (const member of parts.members) this.#object(inner, member, site, depth)
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
this.#abstract(inner, parts.members, site, depth)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** An object type returned as `shape`: every field without resolver is a conforming property. */
|
|
425
|
+
#object(type: GraphQLObjectType, shape: Shape, site: Site, depth: number): void {
|
|
426
|
+
const properties = this.#propertiesOf(shape)
|
|
427
|
+
if (properties === 'record') return
|
|
428
|
+
if (properties === undefined) {
|
|
429
|
+
this.#mismatch(
|
|
430
|
+
site,
|
|
431
|
+
`${site.label} ${this.#describe(shape)}, not an object type, but ${site.coordinate} is ${type.name}`,
|
|
432
|
+
[`Return the TypeScript type of ${type.name} (an interface, a class or an object type)`],
|
|
433
|
+
)
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
if (!this.#visit(`output\0${type.name}`, shape)) return
|
|
437
|
+
const named = shape.kind === 'named' ? this.#named.get(shape.key) : undefined
|
|
438
|
+
const tsName = named?.name ?? 'the returned object type'
|
|
439
|
+
const byName = new Map(properties.map(property => [property.name, property]))
|
|
440
|
+
for (const field of Object.values(type.getFields())) {
|
|
441
|
+
const coordinate = `${type.name}.${field.name}`
|
|
442
|
+
if (this.#config.bound.has(coordinate)) continue
|
|
443
|
+
const property = byName.get(field.name)
|
|
444
|
+
if (property === undefined) {
|
|
445
|
+
const location = this.#config.locate(field.astNode)
|
|
446
|
+
const declaration = named?.location ?? site.locate()
|
|
447
|
+
this.#reportOnce(`provide\0${coordinate}\0${named?.key ?? site.label}`, {
|
|
448
|
+
code: Codes.SchemaMismatch,
|
|
449
|
+
severity: 'error',
|
|
450
|
+
message: `${coordinate} has no resolver, and ${tsName} (${site.origin}) has no property '${field.name}'`,
|
|
451
|
+
...(location === undefined ? {} : { location }),
|
|
452
|
+
hints: [
|
|
453
|
+
`Add a field resolver: @ResolveField('${field.name}') in a @Resolver('${type.name}') class`,
|
|
454
|
+
`Or add '${field.name}' to ${tsName}`,
|
|
455
|
+
],
|
|
456
|
+
...(declaration === undefined
|
|
457
|
+
? {}
|
|
458
|
+
: { related: [{ message: `${tsName} is declared here`, location: declaration }] }),
|
|
459
|
+
})
|
|
460
|
+
continue
|
|
461
|
+
}
|
|
462
|
+
this.#output(field.type, property.shape, this.#propertySite(site, type, field, property, shape), depth + 1)
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** A union or an interface: each TypeScript member is matched to a possible type. */
|
|
467
|
+
#abstract(type: GraphQLAbstractType, members: readonly Shape[], site: Site, depth: number): void {
|
|
468
|
+
const possible = this.#config.schema.getPossibleTypes(type)
|
|
469
|
+
const names = possible.map(object => object.name)
|
|
470
|
+
for (const member of members) {
|
|
471
|
+
const named = member.kind === 'named' ? this.#named.get(member.key) : undefined
|
|
472
|
+
const properties = this.#propertiesOf(member)
|
|
473
|
+
if (properties === undefined) {
|
|
474
|
+
this.#mismatch(
|
|
475
|
+
site,
|
|
476
|
+
`${site.label} ${this.#describe(member)}, not an object type, but ${site.coordinate} is ${type.name}`,
|
|
477
|
+
[`Return one of ${names.join(', ')}`],
|
|
478
|
+
)
|
|
479
|
+
continue
|
|
480
|
+
}
|
|
481
|
+
if (properties === 'record') continue
|
|
482
|
+
// graphql-codegen's interface types (`Node`): checked against the fields of the interface
|
|
483
|
+
if (named?.name === type.name && this.#predicates.isInterfaceType(type)) {
|
|
484
|
+
if (!this.#visit(`interface\0${type.name}`, member)) continue
|
|
485
|
+
const byName = new Map(properties.map(property => [property.name, property]))
|
|
486
|
+
for (const field of Object.values(type.getFields())) {
|
|
487
|
+
const property = byName.get(field.name)
|
|
488
|
+
if (property !== undefined) {
|
|
489
|
+
this.#output(field.type, property.shape, this.#propertySite(site, type, field, property, member), depth + 1)
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
continue
|
|
493
|
+
}
|
|
494
|
+
const typename = properties.find(property => property.name === '__typename')
|
|
495
|
+
const literal = typename?.shape.kind === 'literal' ? typename.shape.value : undefined
|
|
496
|
+
// a member is recognised by its `__typename` literal, else by its name
|
|
497
|
+
let target = typeof literal === 'string' ? literal : undefined
|
|
498
|
+
if (target === undefined && named !== undefined && names.includes(named.name)) target = named.name
|
|
499
|
+
if (target === undefined || !names.includes(target)) {
|
|
500
|
+
const what = named?.name ?? this.#describe(member)
|
|
501
|
+
if (names.length === 0) {
|
|
502
|
+
this.#mismatch(site, `${site.label} ${what}, but no object type implements ${type.name} in the SDL`, [
|
|
503
|
+
`Declare an implementation: type ${what} implements ${type.name} { ... }`,
|
|
504
|
+
])
|
|
505
|
+
continue
|
|
506
|
+
}
|
|
507
|
+
this.#mismatch(
|
|
508
|
+
site,
|
|
509
|
+
`${site.label} ${what}, which is not ${names.length === 1 ? 'the member' : 'a member'} of ${type.name} (${names.join(', ')})`,
|
|
510
|
+
[
|
|
511
|
+
`Return ${names.join(' | ')}: a member is recognised by its __typename literal (__typename: '${names[0] ?? 'Member'}'), else by its name`,
|
|
512
|
+
],
|
|
513
|
+
)
|
|
514
|
+
continue
|
|
515
|
+
}
|
|
516
|
+
const object = this.#config.schema.getType(target) as GraphQLObjectType
|
|
517
|
+
this.#object(object, member, site, depth + 1)
|
|
518
|
+
const identity =
|
|
519
|
+
named?.declaration === 'class' &&
|
|
520
|
+
named.typeArgs.length === 0 &&
|
|
521
|
+
this.#ctx.classInfo(named.identity)?.exported === true
|
|
522
|
+
? named.identity
|
|
523
|
+
: ''
|
|
524
|
+
let byMember = this.#members.get(type.name)
|
|
525
|
+
if (byMember === undefined) {
|
|
526
|
+
byMember = new Map()
|
|
527
|
+
this.#members.set(type.name, byMember)
|
|
528
|
+
}
|
|
529
|
+
let identities = byMember.get(target)
|
|
530
|
+
if (identities === undefined) {
|
|
531
|
+
identities = new Set()
|
|
532
|
+
byMember.set(target, identities)
|
|
533
|
+
}
|
|
534
|
+
identities.add(identity)
|
|
535
|
+
if (typename === undefined && identity === '') {
|
|
536
|
+
let list = this.#untellable.get(type.name)
|
|
537
|
+
if (list === undefined) {
|
|
538
|
+
list = []
|
|
539
|
+
this.#untellable.set(type.name, list)
|
|
540
|
+
}
|
|
541
|
+
list.push({ name: named?.name ?? target, site, ...(named === undefined ? {} : { location: named.location }) })
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// --------------------------------------------------------------------------------------- input
|
|
547
|
+
|
|
548
|
+
#input(type: GraphQLInputType, shape: Shape, position: InputPosition, site: Site, depth = 0): void {
|
|
549
|
+
if (depth > MAX_DEPTH) return
|
|
550
|
+
const parts = this.#parts(shape)
|
|
551
|
+
const { isNonNullType, isListType, isScalarType, isEnumType } = this.#predicates
|
|
552
|
+
let inner = type
|
|
553
|
+
if (isNonNullType(inner)) inner = inner.ofType
|
|
554
|
+
else {
|
|
555
|
+
const acceptsNull = parts.hasNull || parts.unchecked
|
|
556
|
+
// a list item is never absent, and a null item is never turned into undefined
|
|
557
|
+
const acceptsUndefined = position.item !== true && (parts.hasUndefined || position.optional || parts.unchecked)
|
|
558
|
+
if (position.item === true) {
|
|
559
|
+
if (!acceptsNull) {
|
|
560
|
+
this.#mismatch(
|
|
561
|
+
site,
|
|
562
|
+
`${site.label} does not accept null, but the items of ${site.coordinate} (${site.list ?? String(type)}) may be null`,
|
|
563
|
+
[`Accept null items ((T | null)[]), or make the items non-null in the SDL (${String(type)}!)`],
|
|
564
|
+
)
|
|
565
|
+
}
|
|
566
|
+
} else if (!position.sdlDefault && !acceptsUndefined) {
|
|
567
|
+
this.#mismatch(
|
|
568
|
+
site,
|
|
569
|
+
`${site.label} does not accept undefined, but ${site.coordinate} (${String(type)}) may be omitted`,
|
|
570
|
+
['Make it optional (?: or | undefined), or make the SDL argument non-null or give it a default value'],
|
|
571
|
+
)
|
|
572
|
+
} else if (!acceptsNull && !acceptsUndefined) {
|
|
573
|
+
this.#mismatch(
|
|
574
|
+
site,
|
|
575
|
+
`${site.label} does not accept null, but ${site.coordinate} (${String(type)}) may be null`,
|
|
576
|
+
[`Accept null (| null) or undefined, or make it non-null in the SDL (${String(type)}!)`],
|
|
577
|
+
)
|
|
578
|
+
}
|
|
579
|
+
if (position.vote !== undefined && !parts.unchecked) {
|
|
580
|
+
let vote = this.#votes.get(position.vote)
|
|
581
|
+
if (vote === undefined) {
|
|
582
|
+
vote = { accepts: 0, rejects: [] }
|
|
583
|
+
this.#votes.set(position.vote, vote)
|
|
584
|
+
}
|
|
585
|
+
if (acceptsNull) vote.accepts++
|
|
586
|
+
else if (acceptsUndefined) vote.rejects.push(site)
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (parts.unchecked) return
|
|
590
|
+
if (parts.members.length === 0) {
|
|
591
|
+
this.#mismatch(site, `${site.label} accepts no ${String(inner)} value (${site.coordinate})`, [
|
|
592
|
+
`Type it with the TypeScript type of ${String(inner)}`,
|
|
593
|
+
])
|
|
594
|
+
return
|
|
595
|
+
}
|
|
596
|
+
if (isListType(inner)) {
|
|
597
|
+
const lists = parts.members.map(member => this.#listItem(member, 'input')).filter(item => item !== undefined)
|
|
598
|
+
if (lists.length === 0) {
|
|
599
|
+
this.#mismatch(
|
|
600
|
+
site,
|
|
601
|
+
`${site.label} is ${this.#describeParts(parts, false)}, not an array, but ${site.coordinate} is ${String(type)}`,
|
|
602
|
+
['Accept an array (readonly T[]): graphql-js always passes lists as arrays'],
|
|
603
|
+
)
|
|
604
|
+
return
|
|
605
|
+
}
|
|
606
|
+
for (const items of lists) {
|
|
607
|
+
for (const item of items) {
|
|
608
|
+
this.#input(
|
|
609
|
+
inner.ofType,
|
|
610
|
+
item,
|
|
611
|
+
{ optional: false, sdlDefault: true, item: true },
|
|
612
|
+
{ ...site, label: `${site.label} (items)`, list: site.list ?? String(type) },
|
|
613
|
+
depth + 1,
|
|
614
|
+
)
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return
|
|
618
|
+
}
|
|
619
|
+
if (isScalarType(inner)) {
|
|
620
|
+
if (parts.members.some(member => this.#scalarAccepts(inner.name, member, 'input'))) return
|
|
621
|
+
this.#mismatch(
|
|
622
|
+
site,
|
|
623
|
+
`${site.label} is ${this.#describeParts(parts, false)}, which does not accept every ${inner.name} (${site.coordinate})`,
|
|
624
|
+
[this.#scalarHint(inner.name)],
|
|
625
|
+
)
|
|
626
|
+
return
|
|
627
|
+
}
|
|
628
|
+
if (isEnumType(inner)) {
|
|
629
|
+
const accepted = new Set<string>()
|
|
630
|
+
for (const member of parts.members) {
|
|
631
|
+
if (member.kind === 'string') return
|
|
632
|
+
for (const literal of this.#literals(member) ?? []) accepted.add(String(literal))
|
|
633
|
+
}
|
|
634
|
+
const missing = inner.getValues().filter(value => !accepted.has(value.name))
|
|
635
|
+
if (missing.length > 0) {
|
|
636
|
+
this.#mismatch(
|
|
637
|
+
site,
|
|
638
|
+
`${site.label} is ${this.#describeParts(parts, false)}, which does not accept ${missing.map(value => value.name).join(', ')} of the enum ${inner.name} (${site.coordinate})`,
|
|
639
|
+
[`Accept every value of ${inner.name} (graphql-codegen's enum type, or a union of every value)`],
|
|
640
|
+
)
|
|
641
|
+
}
|
|
642
|
+
return
|
|
643
|
+
}
|
|
644
|
+
const objects = parts.members.filter(member => this.#propertiesOf(member) !== undefined)
|
|
645
|
+
if (objects.length === 0) {
|
|
646
|
+
this.#mismatch(
|
|
647
|
+
site,
|
|
648
|
+
`${site.label} is ${this.#describeParts(parts, false)}, not an object type, but ${site.coordinate} is ${inner.name}`,
|
|
649
|
+
[`Type it with the TypeScript type of the input ${inner.name}`],
|
|
650
|
+
)
|
|
651
|
+
return
|
|
652
|
+
}
|
|
653
|
+
for (const object of objects) this.#inputObject(inner as GraphQLInputObjectType, object, site, depth)
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** An input object received as `shape`: each property is a field of the input, accepting its values. */
|
|
657
|
+
#inputObject(type: GraphQLInputObjectType, shape: Shape, site: Site, depth: number): void {
|
|
658
|
+
const properties = this.#propertiesOf(shape)
|
|
659
|
+
if (properties === undefined || properties === 'record') return
|
|
660
|
+
if (!this.#visit(`input\0${type.name}`, shape)) return
|
|
661
|
+
const fields = type.getFields()
|
|
662
|
+
for (const property of properties) {
|
|
663
|
+
if (property.name === '__typename') continue
|
|
664
|
+
const field = fields[property.name]
|
|
665
|
+
const propertySite: Site = {
|
|
666
|
+
label:
|
|
667
|
+
this.#tsName(shape) === undefined ? `property '${property.name}'` : `${this.#tsName(shape)}.${property.name}`,
|
|
668
|
+
origin: site.origin,
|
|
669
|
+
coordinate: `${type.name}.${property.name}`,
|
|
670
|
+
locate: () => annotationLocation(this.#ctx.project, property.location) ?? site.locate(),
|
|
671
|
+
sdl: this.#config.locate(field?.astNode ?? type.astNode),
|
|
672
|
+
optional: false,
|
|
673
|
+
}
|
|
674
|
+
if (field === undefined) {
|
|
675
|
+
this.#mismatch(
|
|
676
|
+
{ ...propertySite, locate: () => property.location ?? site.locate() },
|
|
677
|
+
`${propertySite.label} is not a field of the input ${type.name}: it is always undefined`,
|
|
678
|
+
[`Remove it, or declare it in the SDL (input ${type.name} { ${property.name}: ... })`],
|
|
679
|
+
)
|
|
680
|
+
continue
|
|
681
|
+
}
|
|
682
|
+
this.#input(
|
|
683
|
+
field.type,
|
|
684
|
+
property.shape,
|
|
685
|
+
{ optional: property.optional, sdlDefault: field.default !== undefined, vote: propertySite.coordinate },
|
|
686
|
+
propertySite,
|
|
687
|
+
depth + 1,
|
|
688
|
+
)
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ------------------------------------------------------------------------------------- scalars
|
|
693
|
+
|
|
694
|
+
/** A member (not nullish, not a union) conforms to a scalar: output, or accepts every value: input. */
|
|
695
|
+
#scalarAccepts(name: string, shape: Shape, position: 'input' | 'output'): boolean {
|
|
696
|
+
const custom = this.#config.scalars.get(name)
|
|
697
|
+
if (custom !== undefined) return this.#hasReference(shape, custom.type)
|
|
698
|
+
// the type of another scalar<T>() declaration (a branded id for an ID or a String): its
|
|
699
|
+
// representation is not known, string-like scalars only
|
|
700
|
+
if ((name === 'String' || name === 'ID') && shape.kind === 'reference' && this.#scalarTypes.has(shape.identity)) {
|
|
701
|
+
return true
|
|
702
|
+
}
|
|
703
|
+
const output = position === 'output'
|
|
704
|
+
switch (name) {
|
|
705
|
+
case 'String':
|
|
706
|
+
return this.#isString(shape, output)
|
|
707
|
+
case 'ID':
|
|
708
|
+
return this.#isString(shape, output) || (output && this.#isNumber(shape, true))
|
|
709
|
+
case 'Int':
|
|
710
|
+
case 'Float':
|
|
711
|
+
return this.#isNumber(shape, output)
|
|
712
|
+
case 'Boolean':
|
|
713
|
+
return shape.kind === 'boolean' || (output && shape.kind === 'literal' && typeof shape.value === 'boolean')
|
|
714
|
+
case 'DateTime':
|
|
715
|
+
return shape.kind === 'date'
|
|
716
|
+
case 'BigInt':
|
|
717
|
+
return shape.kind === 'bigint' || (output && this.#isNumber(shape, true))
|
|
718
|
+
default:
|
|
719
|
+
// JSON and the built-in scalars without TypeScript counterpart
|
|
720
|
+
return true
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
#scalarHint(name: string): string {
|
|
725
|
+
const custom = this.#config.scalars.get(name)
|
|
726
|
+
if (custom !== undefined) {
|
|
727
|
+
return `${name} is declared with scalar<${custom.type.slice(custom.type.indexOf('#') + 1)}>(): use that type (graphql-codegen: scalars: { ${name}: '...' })`
|
|
728
|
+
}
|
|
729
|
+
switch (name) {
|
|
730
|
+
case 'Int':
|
|
731
|
+
case 'Float':
|
|
732
|
+
return 'Use number (or the Int / Float aliases of @znight/graphql)'
|
|
733
|
+
case 'Boolean':
|
|
734
|
+
return 'Use boolean'
|
|
735
|
+
case 'DateTime':
|
|
736
|
+
return 'The built-in DateTime scalar is a Date'
|
|
737
|
+
case 'BigInt':
|
|
738
|
+
return 'The built-in BigInt scalar is a bigint'
|
|
739
|
+
default:
|
|
740
|
+
return 'Use string (string literals, string enums and branded strings conform in output positions)'
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** `string`, a branded string, the `ID` alias; output: also string literals and string enums. */
|
|
745
|
+
#isString(shape: Shape, output: boolean): boolean {
|
|
746
|
+
switch (shape.kind) {
|
|
747
|
+
case 'string':
|
|
748
|
+
return true
|
|
749
|
+
case 'literal':
|
|
750
|
+
return output && typeof shape.value === 'string'
|
|
751
|
+
case 'reference':
|
|
752
|
+
return shape.identity === GraphQLIds.ID
|
|
753
|
+
case 'intersection':
|
|
754
|
+
return shape.members.some(
|
|
755
|
+
member => member.kind === 'string' || (member.kind === 'reference' && member.identity === GraphQLIds.ID),
|
|
756
|
+
)
|
|
757
|
+
case 'named': {
|
|
758
|
+
const named = this.#named.get(shape.key)
|
|
759
|
+
return (
|
|
760
|
+
output &&
|
|
761
|
+
named?.declaration === 'enum' &&
|
|
762
|
+
(this.#literals(shape) ?? []).every(value => typeof value === 'string')
|
|
763
|
+
)
|
|
764
|
+
}
|
|
765
|
+
default:
|
|
766
|
+
return false
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** `number`, a branded number, the `Int` / `Float` aliases; output: also number literals and numeric enums. */
|
|
771
|
+
#isNumber(shape: Shape, output: boolean): boolean {
|
|
772
|
+
const alias = (candidate: Shape): boolean =>
|
|
773
|
+
candidate.kind === 'reference' &&
|
|
774
|
+
(candidate.identity === GraphQLIds.Int || candidate.identity === GraphQLIds.Float)
|
|
775
|
+
switch (shape.kind) {
|
|
776
|
+
case 'number':
|
|
777
|
+
return true
|
|
778
|
+
case 'literal':
|
|
779
|
+
return output && typeof shape.value === 'number'
|
|
780
|
+
case 'reference':
|
|
781
|
+
return alias(shape)
|
|
782
|
+
case 'intersection':
|
|
783
|
+
return shape.members.some(member => member.kind === 'number' || alias(member))
|
|
784
|
+
case 'named': {
|
|
785
|
+
const named = this.#named.get(shape.key)
|
|
786
|
+
return (
|
|
787
|
+
output &&
|
|
788
|
+
named?.declaration === 'enum' &&
|
|
789
|
+
(this.#literals(shape) ?? []).every(value => typeof value === 'number')
|
|
790
|
+
)
|
|
791
|
+
}
|
|
792
|
+
default:
|
|
793
|
+
return false
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
#hasReference(shape: Shape, identity: string): boolean {
|
|
798
|
+
if (shape.kind === 'reference') return shape.identity === identity
|
|
799
|
+
if (shape.kind === 'intersection') return shape.members.some(member => this.#hasReference(member, identity))
|
|
800
|
+
return false
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// -------------------------------------------------------------------------------------- shapes
|
|
804
|
+
|
|
805
|
+
#shape(target: { readonly parameter: ParameterInfo } | { readonly returnOf: MethodInfo }): ShapeResult {
|
|
806
|
+
const result = this.#ctx.types.shape(target, this.#options)
|
|
807
|
+
for (const named of result.named.values()) this.#named.set(named.key, named)
|
|
808
|
+
return result
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** The non-nullish members of a shape, through unions and aliases (object aliases stay named). */
|
|
812
|
+
#parts(shape: Shape): Parts {
|
|
813
|
+
const parts: Parts = { members: [], hasNull: false, hasUndefined: false, unchecked: false }
|
|
814
|
+
const visit = (current: Shape, depth: number): void => {
|
|
815
|
+
if (depth > MAX_DEPTH) return
|
|
816
|
+
switch (current.kind) {
|
|
817
|
+
case 'null':
|
|
818
|
+
parts.hasNull = true
|
|
819
|
+
return
|
|
820
|
+
case 'undefined':
|
|
821
|
+
parts.hasUndefined = true
|
|
822
|
+
return
|
|
823
|
+
case 'unknown':
|
|
824
|
+
case 'opaque':
|
|
825
|
+
case 'schema':
|
|
826
|
+
parts.unchecked = true
|
|
827
|
+
return
|
|
828
|
+
case 'never':
|
|
829
|
+
return
|
|
830
|
+
case 'union':
|
|
831
|
+
for (const member of current.members) visit(member, depth + 1)
|
|
832
|
+
return
|
|
833
|
+
case 'named': {
|
|
834
|
+
const named = this.#named.get(current.key)
|
|
835
|
+
if (named === undefined) {
|
|
836
|
+
parts.unchecked = true
|
|
837
|
+
return
|
|
838
|
+
}
|
|
839
|
+
const target = named.shape
|
|
840
|
+
const isObject =
|
|
841
|
+
target.kind === 'object' ||
|
|
842
|
+
(target.kind === 'intersection' && this.#propertiesOf(target) !== undefined) ||
|
|
843
|
+
(target.kind === 'named' && this.#named.get(target.key)?.declaration !== 'alias')
|
|
844
|
+
if (named.declaration !== 'alias' || isObject) parts.members.push(current)
|
|
845
|
+
else visit(target, depth + 1)
|
|
846
|
+
return
|
|
847
|
+
}
|
|
848
|
+
default:
|
|
849
|
+
parts.members.push(current)
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
visit(shape, 0)
|
|
853
|
+
return parts
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/** The item shapes of a list member (array, tuple, iterable); `undefined`: not a list. */
|
|
857
|
+
#listItem(shape: Shape, position: 'input' | 'output'): readonly Shape[] | undefined {
|
|
858
|
+
if (shape.kind === 'array') return [shape.items]
|
|
859
|
+
if (shape.kind === 'tuple')
|
|
860
|
+
return position === 'output' ? [...shape.items, ...(shape.rest === undefined ? [] : [shape.rest])] : undefined
|
|
861
|
+
if (shape.kind === 'reference' && position === 'output' && OUTPUT_ITERABLES.has(shape.identity)) {
|
|
862
|
+
return [shape.typeArgs[0] ?? { kind: 'unknown' }]
|
|
863
|
+
}
|
|
864
|
+
return undefined
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** The literal values of a member: a literal, an enum; `undefined`: not literals. */
|
|
868
|
+
#literals(shape: Shape): readonly (string | number | boolean)[] | undefined {
|
|
869
|
+
if (shape.kind === 'literal') return [shape.value]
|
|
870
|
+
if (shape.kind !== 'named') return undefined
|
|
871
|
+
const named = this.#named.get(shape.key)
|
|
872
|
+
if (named?.declaration !== 'enum') return undefined
|
|
873
|
+
if (named.enumMembers !== undefined) return named.enumMembers.map(member => member.value)
|
|
874
|
+
const target = named.shape
|
|
875
|
+
if (target.kind === 'literal') return [target.value]
|
|
876
|
+
if (target.kind === 'union' && target.members.every(member => member.kind === 'literal')) {
|
|
877
|
+
return target.members.map(member => (member as { value: string | number | boolean }).value)
|
|
878
|
+
}
|
|
879
|
+
return undefined
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** The properties of an object structure (intersections merged, later members win), `'record'` for index signatures. */
|
|
883
|
+
#propertiesOf(shape: Shape, depth = 0): readonly ShapeProperty[] | 'record' | undefined {
|
|
884
|
+
if (depth > MAX_DEPTH) return undefined
|
|
885
|
+
switch (shape.kind) {
|
|
886
|
+
case 'object':
|
|
887
|
+
return shape.additional === undefined ? shape.properties : 'record'
|
|
888
|
+
case 'named': {
|
|
889
|
+
const named = this.#named.get(shape.key)
|
|
890
|
+
return named === undefined || named.declaration === 'enum'
|
|
891
|
+
? undefined
|
|
892
|
+
: this.#propertiesOf(named.shape, depth + 1)
|
|
893
|
+
}
|
|
894
|
+
case 'intersection': {
|
|
895
|
+
const merged = new Map<string, ShapeProperty>()
|
|
896
|
+
for (const member of shape.members) {
|
|
897
|
+
const properties = this.#propertiesOf(member, depth + 1)
|
|
898
|
+
if (properties === undefined || properties === 'record') return properties
|
|
899
|
+
for (const property of properties) {
|
|
900
|
+
merged.delete(property.name)
|
|
901
|
+
merged.set(property.name, property)
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
return [...merged.values()]
|
|
905
|
+
}
|
|
906
|
+
default:
|
|
907
|
+
return undefined
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** `true` the first time `(namespace, shape)` is visited: named shapes by key, anonymous ones by identity. */
|
|
912
|
+
#visit(namespace: string, shape: Shape): boolean {
|
|
913
|
+
if (shape.kind === 'named') {
|
|
914
|
+
const key = `${namespace}\0${shape.key}`
|
|
915
|
+
if (this.#visited.has(key)) return false
|
|
916
|
+
this.#visited.add(key)
|
|
917
|
+
return true
|
|
918
|
+
}
|
|
919
|
+
let seen = this.#anonymous.get(shape)
|
|
920
|
+
if (seen === undefined) {
|
|
921
|
+
seen = new Set()
|
|
922
|
+
this.#anonymous.set(shape, seen)
|
|
923
|
+
}
|
|
924
|
+
if (seen.has(namespace)) return false
|
|
925
|
+
seen.add(namespace)
|
|
926
|
+
return true
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
#propertySite(
|
|
930
|
+
site: Site,
|
|
931
|
+
type: GraphQLObjectType | GraphQLAbstractType,
|
|
932
|
+
field: GraphQLField<unknown, unknown>,
|
|
933
|
+
property: ShapeProperty,
|
|
934
|
+
owner: Shape,
|
|
935
|
+
): Site {
|
|
936
|
+
const name = this.#tsName(owner)
|
|
937
|
+
return {
|
|
938
|
+
label: `${name === undefined ? `property '${property.name}'` : `${name}.${property.name}`} is`,
|
|
939
|
+
origin: `type of ${name === undefined ? `property '${property.name}'` : `${name}.${property.name}`}, ${site.origin}`,
|
|
940
|
+
coordinate: `${type.name}.${field.name}`,
|
|
941
|
+
locate: () => annotationLocation(this.#ctx.project, property.location) ?? site.locate(),
|
|
942
|
+
sdl: this.#config.locate(field.astNode),
|
|
943
|
+
optional: property.optional,
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
#tsName(shape: Shape): string | undefined {
|
|
948
|
+
return shape.kind === 'named' ? this.#named.get(shape.key)?.name : undefined
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// ---------------------------------------------------------------------------------- discriminators
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* `instanceof` when every TypeScript member returned for an abstract type is an exported class
|
|
955
|
+
* (one per member), else `__typename`; members that are neither are ZNIGHT064.
|
|
956
|
+
*/
|
|
957
|
+
#discriminators(): ReadonlyMap<string, DiscriminatorModel> {
|
|
958
|
+
const result = new Map<string, DiscriminatorModel>()
|
|
959
|
+
for (const [name, byMember] of [...this.#members].sort(([a], [b]) => compareStrings(a, b))) {
|
|
960
|
+
// every exported class returned for a member: instanceof first, __typename as the fallback
|
|
961
|
+
const classes: { identity: string; name: string; depth: number }[] = []
|
|
962
|
+
for (const [member, identities] of byMember) {
|
|
963
|
+
for (const identity of identities) {
|
|
964
|
+
if (identity !== '') classes.push({ identity, name: member, depth: this.#classDepth(identity) })
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
if (classes.length > 0) {
|
|
968
|
+
// subclasses first: `instanceof` matches the most derived class
|
|
969
|
+
classes.sort(
|
|
970
|
+
(a, b) => b.depth - a.depth || compareStrings(a.name, b.name) || compareStrings(a.identity, b.identity),
|
|
971
|
+
)
|
|
972
|
+
result.set(name, {
|
|
973
|
+
by: 'class',
|
|
974
|
+
classes: classes.map(({ identity, name: member }) => ({ identity, name: member })),
|
|
975
|
+
})
|
|
976
|
+
}
|
|
977
|
+
for (const entry of this.#untellable.get(name) ?? []) {
|
|
978
|
+
const location = entry.site.locate()
|
|
979
|
+
this.#reportOnce(`untellable\0${name}\0${entry.name}`, {
|
|
980
|
+
code: Codes.UnresolvableAbstractType,
|
|
981
|
+
severity: 'error',
|
|
982
|
+
message: `${entry.name} cannot be told apart among the members of ${name} at runtime: it declares no __typename and is not an exported class`,
|
|
983
|
+
...(location === undefined ? {} : { location }),
|
|
984
|
+
hints: [
|
|
985
|
+
`Return objects carrying __typename (declare __typename: '${entry.name}' in the TypeScript type; graphql-codegen declares it), or return exported class instances`,
|
|
986
|
+
],
|
|
987
|
+
...(entry.location === undefined
|
|
988
|
+
? {}
|
|
989
|
+
: { related: [{ message: `${entry.name} is declared here`, location: entry.location }] }),
|
|
990
|
+
})
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
return result
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
#classDepth(identity: string): number {
|
|
997
|
+
let depth = 0
|
|
998
|
+
let current = this.#ctx.classInfo(identity)?.superClass?.identity
|
|
999
|
+
const seen = new Set<string>()
|
|
1000
|
+
while (current !== undefined && !seen.has(current) && depth < MAX_DEPTH) {
|
|
1001
|
+
seen.add(current)
|
|
1002
|
+
depth++
|
|
1003
|
+
current = this.#ctx.classInfo(current)?.superClass?.identity
|
|
1004
|
+
}
|
|
1005
|
+
return depth
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// ------------------------------------------------------------------------------------ reporting
|
|
1009
|
+
|
|
1010
|
+
#report(diagnostic: Diagnostic): void {
|
|
1011
|
+
this.#config.report(diagnostic)
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
#reportOnce(key: string, diagnostic: Diagnostic): void {
|
|
1015
|
+
if (this.#reported.has(key)) return
|
|
1016
|
+
this.#reported.add(key)
|
|
1017
|
+
this.#report(diagnostic)
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
#mismatch(site: Site, message: string, hints: readonly string[]): void {
|
|
1021
|
+
const location = site.locate()
|
|
1022
|
+
const key = `${location?.file}:${location?.start}\0${message}`
|
|
1023
|
+
this.#reportOnce(key, {
|
|
1024
|
+
code: Codes.SchemaMismatch,
|
|
1025
|
+
severity: 'error',
|
|
1026
|
+
message,
|
|
1027
|
+
...(location === undefined ? {} : { location }),
|
|
1028
|
+
...(hints.length === 0 ? {} : { hints }),
|
|
1029
|
+
...(site.sdl === undefined
|
|
1030
|
+
? {}
|
|
1031
|
+
: { related: [{ message: `${site.coordinate} is declared here`, location: site.sdl }] }),
|
|
1032
|
+
})
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// ----------------------------------------------------------------------------------- messages
|
|
1036
|
+
|
|
1037
|
+
#describeParts(parts: Parts, optional: boolean): string {
|
|
1038
|
+
const members = parts.members.map(member => this.#describe(member))
|
|
1039
|
+
if (parts.hasNull) members.push('null')
|
|
1040
|
+
if (parts.hasUndefined || optional) members.push('undefined')
|
|
1041
|
+
return members.join(' | ')
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/** Short description of a shape for messages (`string`, `Book`, `'a'`, `Book[]`). */
|
|
1045
|
+
#describe(shape: Shape): string {
|
|
1046
|
+
switch (shape.kind) {
|
|
1047
|
+
case 'literal':
|
|
1048
|
+
return JSON.stringify(shape.value)
|
|
1049
|
+
case 'named':
|
|
1050
|
+
return this.#named.get(shape.key)?.name ?? shape.key.slice(shape.key.indexOf('#') + 1)
|
|
1051
|
+
case 'reference':
|
|
1052
|
+
return shape.name
|
|
1053
|
+
case 'opaque':
|
|
1054
|
+
return shape.text
|
|
1055
|
+
case 'array':
|
|
1056
|
+
return `${this.#describe(shape.items)}[]`
|
|
1057
|
+
case 'union':
|
|
1058
|
+
case 'intersection':
|
|
1059
|
+
return shape.members.map(member => this.#describe(member)).join(shape.kind === 'union' ? ' | ' : ' & ')
|
|
1060
|
+
case 'object':
|
|
1061
|
+
return 'an object literal type'
|
|
1062
|
+
case 'tuple':
|
|
1063
|
+
return 'a tuple'
|
|
1064
|
+
default:
|
|
1065
|
+
return shape.kind
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
}
|