@znight/graphql 0.0.0 → 0.2.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 +1063 -0
- package/dist/compiler.d.mts +93 -0
- package/dist/compiler.mjs +2832 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/index.d.mts +542 -0
- package/dist/index.mjs +128 -0
- package/dist/index.mjs.map +1 -0
- package/dist/names-BlzDM5cF.mjs +111 -0
- package/dist/names-BlzDM5cF.mjs.map +1 -0
- package/dist/plugin-BdBxr5NQ.mjs +3222 -0
- package/dist/plugin-BdBxr5NQ.mjs.map +1 -0
- package/dist/testing.d.mts +101 -0
- package/dist/testing.mjs +306 -0
- package/dist/testing.mjs.map +1 -0
- package/dist/types-CgbV9LjD.d.mts +234 -0
- package/package.json +97 -1
- package/src/compiler/analyze.ts +608 -0
- package/src/compiler/builder.ts +1640 -0
- package/src/compiler/constants.ts +74 -0
- package/src/compiler/data.ts +73 -0
- package/src/compiler/defaults.ts +92 -0
- package/src/compiler/discriminators.ts +53 -0
- package/src/compiler/docs.ts +55 -0
- package/src/compiler/generate.ts +405 -0
- package/src/compiler/ids.ts +128 -0
- package/src/compiler/index.ts +93 -0
- package/src/compiler/locate.ts +44 -0
- package/src/compiler/model.ts +122 -0
- package/src/compiler/options.ts +54 -0
- package/src/compiler/scalars.ts +138 -0
- package/src/compiler/sdl-view.ts +59 -0
- package/src/context.ts +181 -0
- package/src/decisions.ts +169 -0
- package/src/decorators.ts +196 -0
- package/src/errors.ts +89 -0
- package/src/execution.ts +1064 -0
- package/src/format.ts +285 -0
- package/src/index.ts +84 -0
- package/src/limits.ts +177 -0
- package/src/loaders.ts +149 -0
- package/src/memory-pubsub.ts +133 -0
- package/src/metadata.ts +195 -0
- package/src/names.ts +47 -0
- package/src/options.ts +309 -0
- package/src/pipeline.ts +277 -0
- package/src/plugin.ts +557 -0
- package/src/pubsub.ts +36 -0
- package/src/scalars.ts +131 -0
- package/src/schema-hash.ts +18 -0
- package/src/schema.ts +557 -0
- package/src/sdl.ts +515 -0
- package/src/testing.ts +454 -0
- package/src/tokens.ts +22 -0
- package/src/transport/abort.ts +80 -0
- package/src/transport/accept.ts +155 -0
- package/src/transport/graphiql.ts +106 -0
- package/src/transport/http.ts +373 -0
- package/src/transport/sse.ts +45 -0
- package/src/type-ref.ts +89 -0
- package/src/types.ts +190 -0
- package/src/validate-args.ts +48 -0
|
@@ -0,0 +1,1640 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The schema model (RFC 0005 §6.2–§6.6): starting from the root fields, every reachable TypeScript
|
|
3
|
+
* type is read through the type shapes API (`ctx.types.shape()`, members `'instance'`, aliases kept
|
|
4
|
+
* as named shapes) and mapped to a GraphQL type, row by row of the §6.2 table. Names follow §6.4,
|
|
5
|
+
* objects, inputs and arguments §6.3; every problem is a diagnostic with a location, a hint and the
|
|
6
|
+
* path from the root field ("reached from Query.book → Book").
|
|
7
|
+
*
|
|
8
|
+
* Built once per compile (`validate`), read by `generate`. Shapes are cached by the compiler per
|
|
9
|
+
* options for the whole compile, so the walk only pays for what it visits once.
|
|
10
|
+
*/
|
|
11
|
+
import type {
|
|
12
|
+
ClassInfo,
|
|
13
|
+
Diagnostic,
|
|
14
|
+
Location,
|
|
15
|
+
MethodInfo,
|
|
16
|
+
NamedShape,
|
|
17
|
+
ParameterInfo,
|
|
18
|
+
Shape,
|
|
19
|
+
ShapeOptions,
|
|
20
|
+
ShapeProperty,
|
|
21
|
+
ShapeResult,
|
|
22
|
+
ValidateContext,
|
|
23
|
+
} from '@znight/compiler'
|
|
24
|
+
import type { MetaEntry } from '@znight/http/compiler'
|
|
25
|
+
import type { GraphQLTypeRef } from '../type-ref.ts'
|
|
26
|
+
import type { FieldData, GraphQLData, ResolverData } from './data.ts'
|
|
27
|
+
import type {
|
|
28
|
+
ArgModel,
|
|
29
|
+
DiscriminantProperty,
|
|
30
|
+
DiscriminatorModel,
|
|
31
|
+
EnumValueModel,
|
|
32
|
+
FieldModel,
|
|
33
|
+
GraphQLModel,
|
|
34
|
+
InputModel,
|
|
35
|
+
ObjectModel,
|
|
36
|
+
ScalarModel,
|
|
37
|
+
TypeModel,
|
|
38
|
+
UnionModel,
|
|
39
|
+
} from './model.ts'
|
|
40
|
+
import type { ResolvedGraphQLCompilerOptions } from './options.ts'
|
|
41
|
+
|
|
42
|
+
import { requiredRouteGroups } from '@znight/http/compiler'
|
|
43
|
+
|
|
44
|
+
import { isGraphQLName, RESERVED_ENUM_VALUES, ROOT_TYPE_NAMES, SPECIFIED_SCALARS } from '../names.ts'
|
|
45
|
+
import { schemaHash } from '../schema-hash.ts'
|
|
46
|
+
import { printSdl } from '../sdl.ts'
|
|
47
|
+
import { printTypeRef } from '../type-ref.ts'
|
|
48
|
+
import { ASYNC_ITERABLES, BASE_KEEP, Codes, compareStrings, OUTPUT_ITERABLES } from './constants.ts'
|
|
49
|
+
import { externalValue } from './defaults.ts'
|
|
50
|
+
import { discriminatorOf } from './discriminators.ts'
|
|
51
|
+
import { defaultTagValue, deprecationOf, descriptionOf, isHidden, paramDescription, tagOf } from './docs.ts'
|
|
52
|
+
import { GraphQLIds, SCALAR_ALIASES } from './ids.ts'
|
|
53
|
+
import { annotationLocation } from './locate.ts'
|
|
54
|
+
import { readScalars, type ScalarDeclaration } from './scalars.ts'
|
|
55
|
+
import { sdlType } from './sdl-view.ts'
|
|
56
|
+
|
|
57
|
+
type Context = ValidateContext<GraphQLData>
|
|
58
|
+
type Position = 'input' | 'output'
|
|
59
|
+
type Operation = 'query' | 'mutation' | 'subscription'
|
|
60
|
+
|
|
61
|
+
/** Where a type is used: what diagnostics say and where they point. */
|
|
62
|
+
interface Use {
|
|
63
|
+
readonly position: Position
|
|
64
|
+
/** `Book.cover`, `Query.book`, `Query.books(limit:)`. */
|
|
65
|
+
readonly coordinate: string
|
|
66
|
+
/** Prefix of the trail of the types reached from here (`Query.book → Book.cover`). */
|
|
67
|
+
readonly trail: string
|
|
68
|
+
/** The trail of the owner of the coordinate (`Query.book → Book`), for "reached from". */
|
|
69
|
+
readonly reached: string | undefined
|
|
70
|
+
/** Location of the root field the walk started from. */
|
|
71
|
+
readonly root: Location | undefined
|
|
72
|
+
/** Where the type is written; called only when a diagnostic is reported. */
|
|
73
|
+
readonly locate: () => Location | undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A converted type: the reference (non-null when nothing made it nullable) and whether TS accepts `null`. */
|
|
77
|
+
interface Converted {
|
|
78
|
+
readonly ref: GraphQLTypeRef
|
|
79
|
+
readonly acceptsNull: boolean
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A converted inner type: its reference and whether the type itself admits `null` (`JSONValue`). */
|
|
83
|
+
interface Inner {
|
|
84
|
+
readonly ref: GraphQLTypeRef
|
|
85
|
+
readonly nullable: boolean
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A `@ResolveField` waiting for its parent type to be reached. */
|
|
89
|
+
interface PendingFieldResolver {
|
|
90
|
+
readonly resolver: ResolverData
|
|
91
|
+
readonly field: FieldData
|
|
92
|
+
readonly method: MethodInfo
|
|
93
|
+
attached: boolean
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A default value to check once every type is complete. */
|
|
97
|
+
interface PendingDefault {
|
|
98
|
+
readonly target: ArgModel
|
|
99
|
+
readonly value: unknown
|
|
100
|
+
readonly use: Use
|
|
101
|
+
readonly location: Location | undefined
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The first use of a declaration in a position (ZNIGHT063 "input and output"). */
|
|
105
|
+
interface PositionUse {
|
|
106
|
+
readonly coordinate: string
|
|
107
|
+
readonly location: Location | undefined
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const NAME_HINT = 'GraphQL names match /^[_A-Za-z][_0-9A-Za-z]*$/ and do not start with __'
|
|
111
|
+
const RECORD_HINT =
|
|
112
|
+
"GraphQL objects have fixed fields: declare an interface, or use JSONValue (import type { JSONValue } from '@znight/graphql')"
|
|
113
|
+
const ROOT_NAMES: ReadonlySet<string> = new Set(Object.values(ROOT_TYPE_NAMES))
|
|
114
|
+
|
|
115
|
+
/** Builds the model; `undefined` when the application has no resolver. */
|
|
116
|
+
export function buildModel(ctx: Context, options: ResolvedGraphQLCompilerOptions): GraphQLModel | undefined {
|
|
117
|
+
const resolvers = [...ctx.stored].sort((a, b) => compareStrings(a.host.identity, b.host.identity))
|
|
118
|
+
if (resolvers.length === 0) return undefined
|
|
119
|
+
return new SchemaBuilder(ctx, options).build(resolvers)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
class SchemaBuilder {
|
|
123
|
+
readonly #ctx: Context
|
|
124
|
+
readonly #number: 'Float' | 'Int'
|
|
125
|
+
readonly #shapeOptions: ShapeOptions
|
|
126
|
+
/** `scalar<T>()` declarations by the identity of `T`. */
|
|
127
|
+
readonly #scalars: ReadonlyMap<string, ScalarDeclaration>
|
|
128
|
+
/** Every named shape seen, by key. */
|
|
129
|
+
readonly #named = new Map<string, NamedShape>()
|
|
130
|
+
/** Models by GraphQL name (the first claimant). */
|
|
131
|
+
readonly #types = new Map<string, TypeModel>()
|
|
132
|
+
/** Models by declaration key. */
|
|
133
|
+
readonly #models = new Map<string, TypeModel>()
|
|
134
|
+
/** GraphQL names by declaration key. */
|
|
135
|
+
readonly #names = new Map<string, string | undefined>()
|
|
136
|
+
readonly #positions = new Map<string, { output?: PositionUse; input?: PositionUse }>()
|
|
137
|
+
readonly #reported = new Set<string>()
|
|
138
|
+
readonly #fieldResolvers = new Map<string, PendingFieldResolver[]>()
|
|
139
|
+
readonly #defaults: PendingDefault[] = []
|
|
140
|
+
readonly #methods = new Map<string, MethodInfo | undefined>()
|
|
141
|
+
#errors = 0
|
|
142
|
+
|
|
143
|
+
constructor(ctx: Context, options: ResolvedGraphQLCompilerOptions) {
|
|
144
|
+
this.#ctx = ctx
|
|
145
|
+
this.#number = options.number
|
|
146
|
+
const declarations = readScalars(ctx, diagnostic => this.#report(diagnostic))
|
|
147
|
+
this.#scalars = declarations
|
|
148
|
+
// shapes keep the scalar types as references: `T` of `scalar<T>()` maps to its scalar wherever it appears
|
|
149
|
+
const keep = [...new Set([...BASE_KEEP, ...declarations.keys()])].sort(compareStrings)
|
|
150
|
+
this.#shapeOptions = Object.freeze({ keep: Object.freeze(keep), members: 'instance', jsdoc: true })
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
build(resolvers: readonly ResolverData[]): GraphQLModel | undefined {
|
|
154
|
+
this.#checkLoaders(resolvers)
|
|
155
|
+
this.#indexFieldResolvers(resolvers)
|
|
156
|
+
const roots = new Map<Operation, ObjectModel>()
|
|
157
|
+
const rootFields = new Map<string, { readonly field: FieldData; readonly resolver: ResolverData }>()
|
|
158
|
+
let hasQuery = false
|
|
159
|
+
for (const resolver of resolvers) {
|
|
160
|
+
for (const field of resolver.fields) {
|
|
161
|
+
if (field.operation === 'field') continue
|
|
162
|
+
if (field.operation === 'query') hasQuery = true
|
|
163
|
+
if (!field.complete) continue
|
|
164
|
+
const method = this.#method(resolver, field)
|
|
165
|
+
if (method === undefined) continue
|
|
166
|
+
const root = this.#rootModel(roots, field.operation)
|
|
167
|
+
const coordinate = `${root.name}.${field.name}`
|
|
168
|
+
const previous = rootFields.get(coordinate)
|
|
169
|
+
if (previous !== undefined) {
|
|
170
|
+
this.#report({
|
|
171
|
+
code: Codes.NameConflict,
|
|
172
|
+
severity: 'error',
|
|
173
|
+
message: `Two root fields are named ${coordinate}: ${resolver.host.displayName}.${field.method} and ${previous.resolver.host.displayName}.${previous.field.method}`,
|
|
174
|
+
location: field.location,
|
|
175
|
+
related: [{ message: 'the other field', location: previous.field.location }],
|
|
176
|
+
hints: [`Rename one of them: @${decoratorName(field.operation)}('otherName')`],
|
|
177
|
+
})
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
rootFields.set(coordinate, { field, resolver })
|
|
181
|
+
root.fields.push(this.#resolverField(root, resolver, field, method))
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (!hasQuery) {
|
|
185
|
+
const first = resolvers[0] as ResolverData
|
|
186
|
+
this.#report({
|
|
187
|
+
code: Codes.InvalidResolver,
|
|
188
|
+
severity: 'error',
|
|
189
|
+
message: 'The application has GraphQL resolvers but no @Query field: GraphQL requires a query root type',
|
|
190
|
+
location: first.location,
|
|
191
|
+
hints: [`Add a @Query() method to a resolver (${first.host.displayName}, for instance)`],
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
this.#reportUnreachableFieldResolvers()
|
|
195
|
+
this.#checkDefaults()
|
|
196
|
+
this.#checkInputCycles()
|
|
197
|
+
this.#checkDecisions(resolvers)
|
|
198
|
+
if (this.#errors > 0 || !hasQuery) return undefined
|
|
199
|
+
return this.#assemble(roots, resolvers)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ------------------------------------------------------------------------------------- reporting
|
|
203
|
+
|
|
204
|
+
#report(diagnostic: Diagnostic): void {
|
|
205
|
+
if (diagnostic.severity === 'error') this.#errors++
|
|
206
|
+
this.#ctx.report(diagnostic)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Reports once per `key`. */
|
|
210
|
+
#reportOnce(key: string, diagnostic: Diagnostic): void {
|
|
211
|
+
if (this.#reported.has(key)) return
|
|
212
|
+
this.#reported.add(key)
|
|
213
|
+
this.#report(diagnostic)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
#fail(code: Diagnostic['code'], use: Use, message: string, hints: readonly string[]): undefined {
|
|
217
|
+
const location = use.locate()
|
|
218
|
+
this.#report({
|
|
219
|
+
code,
|
|
220
|
+
severity: 'error',
|
|
221
|
+
message,
|
|
222
|
+
...(location === undefined ? {} : { location }),
|
|
223
|
+
...(hints.length === 0 ? {} : { hints }),
|
|
224
|
+
related: reachedFrom(use),
|
|
225
|
+
})
|
|
226
|
+
return undefined
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ------------------------------------------------------------------------------------ resolvers
|
|
230
|
+
|
|
231
|
+
#method(resolver: ResolverData, field: FieldData): MethodInfo | undefined {
|
|
232
|
+
const key = `${resolver.host.identity}#${field.method}`
|
|
233
|
+
if (!this.#methods.has(key)) {
|
|
234
|
+
const cls: ClassInfo | undefined = this.#ctx.classInfo(resolver.host.identity)
|
|
235
|
+
this.#methods.set(
|
|
236
|
+
key,
|
|
237
|
+
cls?.methods.find(method => method.name === field.method && method.kind === 'method'),
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
return this.#methods.get(key)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
#shape(target: { readonly parameter: ParameterInfo } | { readonly returnOf: MethodInfo }): ShapeResult {
|
|
244
|
+
const result = this.#ctx.types.shape(target, this.#shapeOptions)
|
|
245
|
+
for (const named of result.named.values()) this.#named.set(named.key, named)
|
|
246
|
+
return result
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#rootModel(roots: Map<Operation, ObjectModel>, operation: Operation): ObjectModel {
|
|
250
|
+
let root = roots.get(operation)
|
|
251
|
+
if (root === undefined) {
|
|
252
|
+
const name = ROOT_TYPE_NAMES[operation]
|
|
253
|
+
root = {
|
|
254
|
+
kind: 'object',
|
|
255
|
+
name,
|
|
256
|
+
key: `#root:${operation}`,
|
|
257
|
+
description: undefined,
|
|
258
|
+
location: undefined,
|
|
259
|
+
fields: [],
|
|
260
|
+
trail: name,
|
|
261
|
+
root: undefined,
|
|
262
|
+
classIdentity: undefined,
|
|
263
|
+
properties: [],
|
|
264
|
+
}
|
|
265
|
+
roots.set(operation, root)
|
|
266
|
+
this.#claim(root, undefined)
|
|
267
|
+
}
|
|
268
|
+
return root
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** A field backed by a resolver method (root field or field resolver). */
|
|
272
|
+
#resolverField(owner: ObjectModel, resolver: ResolverData, field: FieldData, method: MethodInfo): FieldModel {
|
|
273
|
+
const coordinate = `${owner.name}.${field.name}`
|
|
274
|
+
const isRoot = field.operation !== 'field'
|
|
275
|
+
const use: Use = {
|
|
276
|
+
position: 'output',
|
|
277
|
+
coordinate,
|
|
278
|
+
trail: isRoot ? coordinate : `${owner.trail}.${field.name}`,
|
|
279
|
+
reached: isRoot ? undefined : owner.trail,
|
|
280
|
+
root: isRoot ? method.location : owner.root,
|
|
281
|
+
locate: () => method.location,
|
|
282
|
+
}
|
|
283
|
+
const type = this.#returnType(resolver, field, method, use)
|
|
284
|
+
return {
|
|
285
|
+
name: field.name,
|
|
286
|
+
type: type === undefined ? 'String' : printTypeRef(type.ref),
|
|
287
|
+
description: descriptionOf(method.doc),
|
|
288
|
+
deprecationReason: deprecationOf(method.doc),
|
|
289
|
+
args: this.#arguments(field, method, use),
|
|
290
|
+
binding: { resolver, field },
|
|
291
|
+
location: field.location,
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
#returnType(resolver: ResolverData, field: FieldData, method: MethodInfo, use: Use): Converted | undefined {
|
|
296
|
+
const where = `${resolver.host.displayName}.${field.method}`
|
|
297
|
+
if (method.returnType === undefined) {
|
|
298
|
+
return this.#fail(Codes.InvalidType, use, `${where} has no return type annotation (${use.coordinate})`, [
|
|
299
|
+
`Annotate the return type: the schema is derived from it (${field.method}(...): Promise<Book>)`,
|
|
300
|
+
])
|
|
301
|
+
}
|
|
302
|
+
const result = this.#shape({ returnOf: method })
|
|
303
|
+
const rootIssue = result.issues.find(issue => issue.named === undefined)
|
|
304
|
+
const located: Use = { ...use, locate: () => rootIssue?.location ?? method.location }
|
|
305
|
+
const root = result.root
|
|
306
|
+
const iterable = root.kind === 'reference' && ASYNC_ITERABLES.has(root.identity) ? root : undefined
|
|
307
|
+
if (field.operation === 'subscription') {
|
|
308
|
+
if (iterable === undefined) {
|
|
309
|
+
this.#report({
|
|
310
|
+
code: Codes.InvalidResolver,
|
|
311
|
+
severity: 'error',
|
|
312
|
+
message: `@Subscription ${where} must return an async iterable, not ${describeShape(root)}`,
|
|
313
|
+
location: method.location,
|
|
314
|
+
hints: [
|
|
315
|
+
`Declare it as an async generator (async *${field.method}(): AsyncGenerator<Event>) or return pubsub.subscribe(...) (AsyncIterable<Event>)`,
|
|
316
|
+
],
|
|
317
|
+
})
|
|
318
|
+
return undefined
|
|
319
|
+
}
|
|
320
|
+
const item = iterable.typeArgs[0]
|
|
321
|
+
if (item === undefined) {
|
|
322
|
+
return this.#fail(
|
|
323
|
+
Codes.InvalidType,
|
|
324
|
+
located,
|
|
325
|
+
`${where} must declare the type of its events (${use.coordinate})`,
|
|
326
|
+
['AsyncIterable<Event>'],
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
return this.#convert(item, false, located)
|
|
330
|
+
}
|
|
331
|
+
if (iterable !== undefined) {
|
|
332
|
+
return this.#fail(
|
|
333
|
+
Codes.InvalidType,
|
|
334
|
+
located,
|
|
335
|
+
`${where} returns an async iterable: only @Subscription fields stream values (${use.coordinate})`,
|
|
336
|
+
['Use @Subscription() for a stream of events, or return a list (Promise<readonly Book[]>)'],
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
if (root.kind === 'undefined') {
|
|
340
|
+
return this.#fail(
|
|
341
|
+
Codes.InvalidType,
|
|
342
|
+
located,
|
|
343
|
+
`${where} returns void: GraphQL fields must return a value (${use.coordinate})`,
|
|
344
|
+
['Return a payload type (the changed object) or a boolean'],
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
return this.#convert(root, false, located)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Arguments of a resolver field: `@Args('name')` parameters and the properties of `@Args()` types. */
|
|
351
|
+
#arguments(field: FieldData, method: MethodInfo, owner: Use): ArgModel[] {
|
|
352
|
+
const args: ArgModel[] = []
|
|
353
|
+
const seen = new Map<string, Location | undefined>()
|
|
354
|
+
const add = (arg: ArgModel, location: Location | undefined): void => {
|
|
355
|
+
if (seen.has(arg.name)) {
|
|
356
|
+
const first = seen.get(arg.name)
|
|
357
|
+
this.#report({
|
|
358
|
+
code: Codes.NameConflict,
|
|
359
|
+
severity: 'error',
|
|
360
|
+
message: `Argument '${arg.name}' of ${owner.coordinate} is declared twice`,
|
|
361
|
+
...(location === undefined ? {} : { location }),
|
|
362
|
+
...(first === undefined ? {} : { related: [{ message: 'first declared here', location: first }] }),
|
|
363
|
+
hints: ['Argument names must be unique: rename one of them'],
|
|
364
|
+
})
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
seen.set(arg.name, location)
|
|
368
|
+
args.push(arg)
|
|
369
|
+
}
|
|
370
|
+
field.params.forEach((data, index) => {
|
|
371
|
+
if (data?.kind !== 'args') return
|
|
372
|
+
const param = method.params[index] as ParameterInfo
|
|
373
|
+
const result = this.#shape({ parameter: param })
|
|
374
|
+
if (data.name !== undefined) {
|
|
375
|
+
const coordinate = `${owner.coordinate}(${data.name}:)`
|
|
376
|
+
const use: Use = {
|
|
377
|
+
position: 'input',
|
|
378
|
+
coordinate,
|
|
379
|
+
trail: `${owner.trail}(${data.name})`,
|
|
380
|
+
reached: owner.reached,
|
|
381
|
+
root: owner.root,
|
|
382
|
+
locate: () => param.typeLocation,
|
|
383
|
+
}
|
|
384
|
+
const arg = this.#parameterArgument(data.name, param, method, result, use)
|
|
385
|
+
if (arg !== undefined) add(arg, param.location)
|
|
386
|
+
return
|
|
387
|
+
}
|
|
388
|
+
const properties = this.#propertiesOf(result.root)
|
|
389
|
+
if (properties === 'record') {
|
|
390
|
+
this.#fail(
|
|
391
|
+
Codes.InvalidType,
|
|
392
|
+
{ ...owner, locate: () => param.typeLocation },
|
|
393
|
+
`@Args() parameter '${param.name}' of ${owner.coordinate} cannot be a record or an index signature: GraphQL arguments are fixed`,
|
|
394
|
+
[RECORD_HINT, `@Args('${param.name}') declares one argument named '${param.name}'`],
|
|
395
|
+
)
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
if (properties === undefined) {
|
|
399
|
+
this.#fail(
|
|
400
|
+
Codes.InvalidType,
|
|
401
|
+
{ ...owner, locate: () => param.typeLocation },
|
|
402
|
+
`@Args() parameter '${param.name}' of ${owner.coordinate} must have an object type: its properties are the arguments`,
|
|
403
|
+
[`@Args('${param.name}') declares one argument named '${param.name}'`],
|
|
404
|
+
)
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
for (const property of properties) {
|
|
408
|
+
if (isHidden(property.doc)) continue
|
|
409
|
+
const coordinate = `${owner.coordinate}(${property.name}:)`
|
|
410
|
+
const use: Use = {
|
|
411
|
+
position: 'input',
|
|
412
|
+
coordinate,
|
|
413
|
+
trail: `${owner.trail}(${property.name})`,
|
|
414
|
+
reached: owner.reached,
|
|
415
|
+
root: owner.root,
|
|
416
|
+
locate: () => annotationLocation(this.#ctx.project, property.location) ?? param.typeLocation,
|
|
417
|
+
}
|
|
418
|
+
const arg = this.#inputValue(property, use)
|
|
419
|
+
if (arg !== undefined) add(arg, property.location ?? param.location)
|
|
420
|
+
}
|
|
421
|
+
})
|
|
422
|
+
return args
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** `@Args('limit') limit: Int = 20`. */
|
|
426
|
+
#parameterArgument(
|
|
427
|
+
name: string,
|
|
428
|
+
param: ParameterInfo,
|
|
429
|
+
method: MethodInfo,
|
|
430
|
+
result: ShapeResult,
|
|
431
|
+
use: Use,
|
|
432
|
+
): ArgModel | undefined {
|
|
433
|
+
if (!isGraphQLName(name)) {
|
|
434
|
+
this.#fail(Codes.NameConflict, use, `'${name}' is not a valid GraphQL argument name (${use.coordinate})`, [
|
|
435
|
+
NAME_HINT,
|
|
436
|
+
])
|
|
437
|
+
return undefined
|
|
438
|
+
}
|
|
439
|
+
const converted = this.#convert(result.root, param.optional, use)
|
|
440
|
+
if (converted === undefined) return undefined
|
|
441
|
+
let ref = converted.ref
|
|
442
|
+
const arg: ArgModel = {
|
|
443
|
+
name,
|
|
444
|
+
type: '',
|
|
445
|
+
ref,
|
|
446
|
+
description: descriptionOf(param.doc) ?? paramDescription(method.doc, param.name),
|
|
447
|
+
deprecationReason: deprecationOf(param.doc),
|
|
448
|
+
defaultValue: undefined,
|
|
449
|
+
nullAsUndefined: false,
|
|
450
|
+
location: param.location,
|
|
451
|
+
}
|
|
452
|
+
const initializer = param.initializer
|
|
453
|
+
let hasDefault = false
|
|
454
|
+
if (param.hasDefault) {
|
|
455
|
+
if (initializer?.evaluated === true && initializer.ref === undefined && !containsRef(initializer.value)) {
|
|
456
|
+
this.#defaults.push({ target: arg, value: initializer.value, use, location: initializer.location })
|
|
457
|
+
hasDefault = true
|
|
458
|
+
} else {
|
|
459
|
+
// a default the compiler cannot read: the argument becomes optional, the TypeScript default applies
|
|
460
|
+
ref = { ...ref, nonNull: false }
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
arg.ref = ref
|
|
464
|
+
arg.type = printTypeRef(ref)
|
|
465
|
+
arg.nullAsUndefined = !ref.nonNull && !converted.acceptsNull
|
|
466
|
+
this.#checkDeprecatedInput(arg, hasDefault, use)
|
|
467
|
+
return arg
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** A property as an argument or an input field (§6.3): `@default`, `@deprecated`, null handling. */
|
|
471
|
+
#inputValue(property: ShapeProperty, use: Use): ArgModel | undefined {
|
|
472
|
+
if (!isGraphQLName(property.name)) {
|
|
473
|
+
this.#fail(
|
|
474
|
+
Codes.NameConflict,
|
|
475
|
+
{ ...use, locate: () => property.location },
|
|
476
|
+
`'${property.name}' is not a valid GraphQL name (${use.coordinate})`,
|
|
477
|
+
[NAME_HINT, 'Hide the property with /** @gqlHidden */'],
|
|
478
|
+
)
|
|
479
|
+
return undefined
|
|
480
|
+
}
|
|
481
|
+
if (property.kind === 'getter') {
|
|
482
|
+
this.#fail(Codes.InvalidInput, use, `${use.coordinate} is a getter: input values are plain data`, [
|
|
483
|
+
'Declare a property instead, or hide the getter with /** @gqlHidden */',
|
|
484
|
+
])
|
|
485
|
+
return undefined
|
|
486
|
+
}
|
|
487
|
+
const converted = this.#convert(property.shape, property.optional, use)
|
|
488
|
+
if (converted === undefined) return undefined
|
|
489
|
+
const arg: ArgModel = {
|
|
490
|
+
name: property.name,
|
|
491
|
+
type: printTypeRef(converted.ref),
|
|
492
|
+
ref: converted.ref,
|
|
493
|
+
description: descriptionOf(property.doc),
|
|
494
|
+
deprecationReason: deprecationOf(property.doc),
|
|
495
|
+
defaultValue: undefined,
|
|
496
|
+
nullAsUndefined: !converted.ref.nonNull && !converted.acceptsNull,
|
|
497
|
+
location: property.location,
|
|
498
|
+
}
|
|
499
|
+
const tag = tagOf(property.doc, 'default')
|
|
500
|
+
if (tag !== undefined)
|
|
501
|
+
this.#defaults.push({ target: arg, value: defaultTagValue(tag), use, location: tag.location })
|
|
502
|
+
this.#checkDeprecatedInput(arg, tag !== undefined, use)
|
|
503
|
+
return arg
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** graphql-js rejects a deprecated argument or input field that is required. */
|
|
507
|
+
#checkDeprecatedInput(arg: ArgModel, hasDefault: boolean, use: Use): void {
|
|
508
|
+
if (arg.deprecationReason === undefined || !arg.ref.nonNull || hasDefault) return
|
|
509
|
+
this.#fail(Codes.InvalidInput, use, `${use.coordinate} is required and cannot be deprecated`, [
|
|
510
|
+
'Make it optional (?: T or T | undefined) or give it a default value before deprecating it',
|
|
511
|
+
])
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** `Loader<L>`: `L` must be a loader provider, implementing `BatchLoad<K, V>` (ZNIGHT065). */
|
|
515
|
+
#checkLoaders(resolvers: readonly ResolverData[]): void {
|
|
516
|
+
const seen = new Set<number>()
|
|
517
|
+
for (const resolver of resolvers) {
|
|
518
|
+
for (const field of resolver.fields) {
|
|
519
|
+
for (const param of field.params) {
|
|
520
|
+
if (param?.kind !== 'loader' || seen.has(param.binding.id)) continue
|
|
521
|
+
seen.add(param.binding.id)
|
|
522
|
+
const ref = this.#ctx.binding(param.binding)
|
|
523
|
+
const token = tokenOfRef(ref)
|
|
524
|
+
const provider = token === undefined ? undefined : this.#ctx.provider(token)
|
|
525
|
+
// an unbound loader is ZNIGHT001 (reported by the binding)
|
|
526
|
+
if (provider === undefined || provider.heritage.some(type => type.identity === GraphQLIds.BatchLoad)) continue
|
|
527
|
+
if (provider.kind !== 'class' || hasLoadMethod(this.#ctx, provider.classIdentity)) continue
|
|
528
|
+
this.#report({
|
|
529
|
+
code: Codes.InvalidLoader,
|
|
530
|
+
severity: 'error',
|
|
531
|
+
message: `${provider.displayName} is not a loader: Loader<${provider.displayName}> needs a provider implementing BatchLoad<K, V> (${resolver.host.displayName}.${field.method})`,
|
|
532
|
+
location: param.location,
|
|
533
|
+
hints: [
|
|
534
|
+
`export class ${provider.displayName} implements BatchLoad<ID, Value> { async load(keys: readonly ID[]): Promise<readonly (Value | Error)[]> { ... } }`,
|
|
535
|
+
],
|
|
536
|
+
})
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ------------------------------------------------------------------------------ field resolvers
|
|
543
|
+
|
|
544
|
+
#indexFieldResolvers(resolvers: readonly ResolverData[]): void {
|
|
545
|
+
for (const resolver of resolvers) {
|
|
546
|
+
for (const field of resolver.fields) {
|
|
547
|
+
if (field.operation !== 'field' || !field.complete) continue
|
|
548
|
+
const method = this.#method(resolver, field)
|
|
549
|
+
if (method === undefined) continue
|
|
550
|
+
const index = field.params.findIndex(param => param?.kind === 'parent')
|
|
551
|
+
const param = method.params[index]
|
|
552
|
+
if (param === undefined) continue
|
|
553
|
+
const result = this.#shape({ parameter: param })
|
|
554
|
+
const key = this.#objectKeyOf(result.root)
|
|
555
|
+
if (key === undefined) {
|
|
556
|
+
this.#report({
|
|
557
|
+
code: Codes.InvalidResolver,
|
|
558
|
+
severity: 'error',
|
|
559
|
+
message: `The @Parent() parameter '${param.name}' of ${resolver.host.displayName}.${field.method} must have an object type, not ${describeShape(result.root)}`,
|
|
560
|
+
location: param.typeLocation,
|
|
561
|
+
hints: [
|
|
562
|
+
'The parent type is the object type the field is added to: an interface, a class or a named alias of an object type',
|
|
563
|
+
],
|
|
564
|
+
})
|
|
565
|
+
continue
|
|
566
|
+
}
|
|
567
|
+
let list = this.#fieldResolvers.get(key)
|
|
568
|
+
if (list === undefined) {
|
|
569
|
+
list = []
|
|
570
|
+
this.#fieldResolvers.set(key, list)
|
|
571
|
+
}
|
|
572
|
+
list.push({ resolver, field, method, attached: false })
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
// field resolvers are appended by resolver token, then source order (§7.3)
|
|
576
|
+
for (const list of this.#fieldResolvers.values()) {
|
|
577
|
+
list.sort(
|
|
578
|
+
(a, b) =>
|
|
579
|
+
compareStrings(a.resolver.host.identity, b.resolver.host.identity) ||
|
|
580
|
+
a.field.location.start - b.field.location.start,
|
|
581
|
+
)
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
#attachFieldResolvers(model: ObjectModel): void {
|
|
586
|
+
const pending = this.#fieldResolvers.get(model.key)
|
|
587
|
+
if (pending === undefined) return
|
|
588
|
+
for (const entry of pending) {
|
|
589
|
+
entry.attached = true
|
|
590
|
+
const field = this.#resolverField(model, entry.resolver, entry.field, entry.method)
|
|
591
|
+
const index = model.fields.findIndex(existing => existing.name === field.name)
|
|
592
|
+
if (index === -1) {
|
|
593
|
+
model.fields.push(field)
|
|
594
|
+
continue
|
|
595
|
+
}
|
|
596
|
+
const existing = model.fields[index] as FieldModel
|
|
597
|
+
const where = `${entry.resolver.host.displayName}.${entry.field.method}`
|
|
598
|
+
if (existing.binding !== undefined) {
|
|
599
|
+
this.#report({
|
|
600
|
+
code: Codes.NameConflict,
|
|
601
|
+
severity: 'error',
|
|
602
|
+
message: `Two field resolvers add ${model.name}.${field.name}: ${where} and ${existing.binding.resolver.host.displayName}.${existing.binding.field.method}`,
|
|
603
|
+
location: entry.field.location,
|
|
604
|
+
related: [{ message: 'the other field resolver', location: existing.binding.field.location }],
|
|
605
|
+
hints: ["Keep one of them, or rename one: @ResolveField('otherName')"],
|
|
606
|
+
})
|
|
607
|
+
continue
|
|
608
|
+
}
|
|
609
|
+
if (existing.type !== field.type) {
|
|
610
|
+
this.#report({
|
|
611
|
+
code: Codes.NameConflict,
|
|
612
|
+
severity: 'error',
|
|
613
|
+
message: `@ResolveField ${where} returns ${field.type}, but the property ${model.name}.${field.name} is ${existing.type}`,
|
|
614
|
+
location: entry.field.location,
|
|
615
|
+
...(existing.location === undefined
|
|
616
|
+
? {}
|
|
617
|
+
: { related: [{ message: 'the property', location: existing.location }] }),
|
|
618
|
+
hints: [
|
|
619
|
+
'A field resolver replacing a property must resolve the same GraphQL type: change its return type, or rename the field',
|
|
620
|
+
],
|
|
621
|
+
})
|
|
622
|
+
continue
|
|
623
|
+
}
|
|
624
|
+
// replaces the property field in place: same position, the method's documentation first
|
|
625
|
+
model.fields[index] = {
|
|
626
|
+
...field,
|
|
627
|
+
description: field.description ?? existing.description,
|
|
628
|
+
deprecationReason: field.deprecationReason ?? existing.deprecationReason,
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
#reportUnreachableFieldResolvers(): void {
|
|
634
|
+
for (const [key, list] of this.#fieldResolvers) {
|
|
635
|
+
for (const entry of list) {
|
|
636
|
+
if (entry.attached) continue
|
|
637
|
+
const name = this.#named.get(key)?.name ?? key
|
|
638
|
+
this.#report({
|
|
639
|
+
code: Codes.InvalidResolver,
|
|
640
|
+
severity: 'warning',
|
|
641
|
+
message: `@ResolveField ${entry.resolver.host.displayName}.${entry.field.method} adds a field to ${name}, which no root field reaches as an output type: it is not part of the schema`,
|
|
642
|
+
location: entry.field.location,
|
|
643
|
+
hints: [`Return ${name} from a @Query (or another reachable field), or remove the field resolver`],
|
|
644
|
+
})
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ----------------------------------------------------------------------------------- conversion
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* The GraphQL type of a shape: `null`, `undefined` and `optional` make it nullable, everything
|
|
653
|
+
* else is non-null (§6.2).
|
|
654
|
+
*/
|
|
655
|
+
#convert(shape: Shape, optional: boolean, use: Use): Converted | undefined {
|
|
656
|
+
let nullable = optional
|
|
657
|
+
let acceptsNull = false
|
|
658
|
+
let inner = shape
|
|
659
|
+
if (shape.kind === 'union') {
|
|
660
|
+
const rest = shape.members.filter(member => member.kind !== 'null' && member.kind !== 'undefined')
|
|
661
|
+
if (rest.length !== shape.members.length) {
|
|
662
|
+
nullable = true
|
|
663
|
+
acceptsNull = shape.members.some(member => member.kind === 'null')
|
|
664
|
+
}
|
|
665
|
+
if (rest.length === 0) {
|
|
666
|
+
return this.#fail(Codes.InvalidType, use, `${use.coordinate} is always null or undefined`, [
|
|
667
|
+
'Declare the type of the value',
|
|
668
|
+
])
|
|
669
|
+
}
|
|
670
|
+
inner = rest.length === 1 ? (rest[0] as Shape) : { kind: 'union', members: rest }
|
|
671
|
+
}
|
|
672
|
+
const converted = this.#inner(inner, use)
|
|
673
|
+
if (converted === undefined) return undefined
|
|
674
|
+
const isNullable = nullable || converted.nullable
|
|
675
|
+
return { ref: { ...converted.ref, nonNull: !isNullable }, acceptsNull: acceptsNull || converted.nullable }
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
#inner(shape: Shape, use: Use): Inner | undefined {
|
|
679
|
+
switch (shape.kind) {
|
|
680
|
+
case 'string':
|
|
681
|
+
return refTo('String')
|
|
682
|
+
case 'number':
|
|
683
|
+
return refTo(this.#number)
|
|
684
|
+
case 'boolean':
|
|
685
|
+
return refTo('Boolean')
|
|
686
|
+
case 'literal':
|
|
687
|
+
if (typeof shape.value === 'string') return refTo('String')
|
|
688
|
+
return refTo(typeof shape.value === 'number' ? this.#number : 'Boolean')
|
|
689
|
+
case 'date':
|
|
690
|
+
return this.#builtin('DateTime', use)
|
|
691
|
+
case 'bigint':
|
|
692
|
+
return this.#builtin('BigInt', use)
|
|
693
|
+
case 'array': {
|
|
694
|
+
const item = this.#convert(shape.items, false, use)
|
|
695
|
+
return item === undefined ? undefined : { ref: { kind: 'list', of: item.ref, nonNull: false }, nullable: false }
|
|
696
|
+
}
|
|
697
|
+
case 'named':
|
|
698
|
+
return this.#namedType(shape.key, use)
|
|
699
|
+
case 'reference':
|
|
700
|
+
return this.#reference(shape, use)
|
|
701
|
+
case 'url':
|
|
702
|
+
return this.#fail(Codes.InvalidType, use, `Cannot map 'URL' to a GraphQL type (${use.coordinate})`, [
|
|
703
|
+
"Expose a string (url.href), or declare a scalar: export const UrlScalar = scalar<URL>('URL', { ... })",
|
|
704
|
+
])
|
|
705
|
+
case 'binary':
|
|
706
|
+
return this.#fail(Codes.InvalidType, use, `Cannot map binary data to a GraphQL type (${use.coordinate})`, [
|
|
707
|
+
"Expose a URL (coverUrl: string), hide the property with /** @gqlHidden */, or declare a scalar: export const Base64 = scalar<Uint8Array>('Base64', { ... })",
|
|
708
|
+
])
|
|
709
|
+
case 'unknown':
|
|
710
|
+
return this.#fail(
|
|
711
|
+
Codes.InvalidType,
|
|
712
|
+
use,
|
|
713
|
+
`Cannot map 'unknown' or 'any' to a GraphQL type (${use.coordinate})`,
|
|
714
|
+
["Declare the type, or use JSONValue (import type { JSONValue } from '@znight/graphql') for arbitrary JSON"],
|
|
715
|
+
)
|
|
716
|
+
case 'never':
|
|
717
|
+
case 'null':
|
|
718
|
+
case 'undefined':
|
|
719
|
+
return this.#fail(Codes.InvalidType, use, `Cannot map '${shape.kind}' to a GraphQL type (${use.coordinate})`, [
|
|
720
|
+
'Declare the type of the value',
|
|
721
|
+
])
|
|
722
|
+
case 'tuple':
|
|
723
|
+
return this.#fail(Codes.InvalidType, use, `Cannot map a tuple to a GraphQL type (${use.coordinate})`, [
|
|
724
|
+
'Use an array (readonly T[]) or a named object type',
|
|
725
|
+
])
|
|
726
|
+
case 'object':
|
|
727
|
+
if (shape.additional !== undefined) {
|
|
728
|
+
return this.#fail(
|
|
729
|
+
Codes.InvalidType,
|
|
730
|
+
use,
|
|
731
|
+
`Cannot map a record or an index signature to a GraphQL type (${use.coordinate})`,
|
|
732
|
+
[RECORD_HINT],
|
|
733
|
+
)
|
|
734
|
+
}
|
|
735
|
+
return this.#fail(
|
|
736
|
+
Codes.InvalidType,
|
|
737
|
+
use,
|
|
738
|
+
`Cannot map an anonymous object type to a GraphQL type (${use.coordinate})`,
|
|
739
|
+
['GraphQL types have names: declare it (export interface BookStats { ... }) and use it'],
|
|
740
|
+
)
|
|
741
|
+
case 'intersection':
|
|
742
|
+
return this.#fail(
|
|
743
|
+
Codes.InvalidType,
|
|
744
|
+
use,
|
|
745
|
+
`Cannot map an anonymous intersection to a GraphQL type (${use.coordinate})`,
|
|
746
|
+
['Name it: export type BookWithAuthor = Book & { readonly author: Author }'],
|
|
747
|
+
)
|
|
748
|
+
case 'union':
|
|
749
|
+
return this.#anonymousUnion(shape.members, use)
|
|
750
|
+
case 'schema':
|
|
751
|
+
return this.#fail(Codes.InvalidType, use, `Cannot map a schema type to a GraphQL type (${use.coordinate})`, [
|
|
752
|
+
'Declare the TypeScript type: the annotation is the GraphQL type',
|
|
753
|
+
])
|
|
754
|
+
case 'opaque':
|
|
755
|
+
return this.#opaque(shape, use)
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
#anonymousUnion(members: readonly Shape[], use: Use): undefined {
|
|
760
|
+
if (members.every(member => member.kind === 'literal' && typeof member.value === 'string')) {
|
|
761
|
+
const values = members.map(member => `'${(member as { value: string }).value}'`).join(' | ')
|
|
762
|
+
return this.#fail(
|
|
763
|
+
Codes.InvalidType,
|
|
764
|
+
use,
|
|
765
|
+
`Cannot map the anonymous union ${values} to a GraphQL type (${use.coordinate})`,
|
|
766
|
+
[`Name it to make it an enum: export type Sort = ${values}`],
|
|
767
|
+
)
|
|
768
|
+
}
|
|
769
|
+
if (members.every(member => member.kind === 'named')) {
|
|
770
|
+
return this.#fail(Codes.InvalidType, use, `Cannot map an anonymous union to a GraphQL type (${use.coordinate})`, [
|
|
771
|
+
`GraphQL unions have names: export type SearchResult = ${members.map(member => this.#named.get((member as { key: string }).key)?.name ?? '?').join(' | ')}`,
|
|
772
|
+
])
|
|
773
|
+
}
|
|
774
|
+
return this.#fail(
|
|
775
|
+
Codes.InvalidType,
|
|
776
|
+
use,
|
|
777
|
+
`Cannot map the union ${members.map(describeShape).join(' | ')} to a GraphQL type (${use.coordinate})`,
|
|
778
|
+
['GraphQL unions only contain object types: declare one type, or an object type per case'],
|
|
779
|
+
)
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
#opaque(shape: Extract<Shape, { kind: 'opaque' }>, use: Use): undefined {
|
|
783
|
+
const text = shape.text
|
|
784
|
+
let hints: string[]
|
|
785
|
+
switch (shape.reason) {
|
|
786
|
+
case 'non-json':
|
|
787
|
+
if (text.startsWith('Promise')) {
|
|
788
|
+
hints = ['A Promise is only unwrapped at the top of a resolver return type: declare the resolved type']
|
|
789
|
+
} else if (text.startsWith('Map') || text.startsWith('WeakMap')) {
|
|
790
|
+
hints = [
|
|
791
|
+
"Use an array of entries, a named object type, or JSONValue (import type { JSONValue } from '@znight/graphql')",
|
|
792
|
+
]
|
|
793
|
+
} else {
|
|
794
|
+
hints = ['Declare a plain data type (an interface, an array, a scalar)']
|
|
795
|
+
}
|
|
796
|
+
break
|
|
797
|
+
case 'missing-annotation':
|
|
798
|
+
hints = ['Annotate the type: the compiler reads annotations only']
|
|
799
|
+
break
|
|
800
|
+
case 'function':
|
|
801
|
+
hints = ['Functions are not data: return the value, or hide the member with /** @gqlHidden */']
|
|
802
|
+
break
|
|
803
|
+
case 'type-parameter':
|
|
804
|
+
hints = ['Give the type arguments explicitly']
|
|
805
|
+
break
|
|
806
|
+
default:
|
|
807
|
+
hints = ['Declare the structure with an interface or a named alias']
|
|
808
|
+
}
|
|
809
|
+
return this.#fail(Codes.InvalidType, use, `Cannot map '${text}' to a GraphQL type (${use.coordinate})`, hints)
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
#reference(shape: Extract<Shape, { kind: 'reference' }>, use: Use): Inner | undefined {
|
|
813
|
+
const alias = SCALAR_ALIASES.get(shape.identity)
|
|
814
|
+
if (alias === 'JSON') {
|
|
815
|
+
// JSONValue includes null: the field is nullable
|
|
816
|
+
const json = this.#builtin('JSON', use)
|
|
817
|
+
return json === undefined ? undefined : { ref: json.ref, nullable: true }
|
|
818
|
+
}
|
|
819
|
+
if (alias !== undefined) return refTo(alias === 'Float' ? 'Float' : alias)
|
|
820
|
+
const scalar = this.#scalars.get(shape.identity)
|
|
821
|
+
if (scalar !== undefined) return this.#customScalar(scalar, use)
|
|
822
|
+
if (OUTPUT_ITERABLES.has(shape.identity)) {
|
|
823
|
+
if (use.position === 'input') {
|
|
824
|
+
return this.#fail(Codes.InvalidInput, use, `${use.coordinate} is a ${shape.name}: input lists are arrays`, [
|
|
825
|
+
'Declare an array (readonly T[]) for input values',
|
|
826
|
+
])
|
|
827
|
+
}
|
|
828
|
+
const item = shape.typeArgs[0]
|
|
829
|
+
if (item === undefined)
|
|
830
|
+
return this.#fail(Codes.InvalidType, use, `${use.coordinate}: ${shape.name} needs a type argument`, [])
|
|
831
|
+
const converted = this.#convert(item, false, use)
|
|
832
|
+
return converted === undefined
|
|
833
|
+
? undefined
|
|
834
|
+
: { ref: { kind: 'list', of: converted.ref, nonNull: false }, nullable: false }
|
|
835
|
+
}
|
|
836
|
+
if (ASYNC_ITERABLES.has(shape.identity)) {
|
|
837
|
+
return this.#fail(Codes.InvalidType, use, `Cannot map '${shape.name}' to a GraphQL type (${use.coordinate})`, [
|
|
838
|
+
'Async iterables are only the return type of @Subscription methods',
|
|
839
|
+
])
|
|
840
|
+
}
|
|
841
|
+
return this.#fail(Codes.InvalidType, use, `Cannot map '${shape.name}' to a GraphQL type (${use.coordinate})`, [
|
|
842
|
+
'Declare a plain data type (an interface, an array, a scalar)',
|
|
843
|
+
])
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// ------------------------------------------------------------------------------------- scalars
|
|
847
|
+
|
|
848
|
+
#builtin(name: 'DateTime' | 'BigInt' | 'JSON', use: Use): Inner | undefined {
|
|
849
|
+
const key = `#builtin:${name}`
|
|
850
|
+
if (!this.#models.has(key)) {
|
|
851
|
+
const model: ScalarModel = {
|
|
852
|
+
kind: 'scalar',
|
|
853
|
+
name,
|
|
854
|
+
key,
|
|
855
|
+
description: undefined,
|
|
856
|
+
location: undefined,
|
|
857
|
+
definition: undefined,
|
|
858
|
+
specifiedByURL: undefined,
|
|
859
|
+
}
|
|
860
|
+
this.#models.set(key, model)
|
|
861
|
+
if (!this.#claim(model, use)) return undefined
|
|
862
|
+
}
|
|
863
|
+
return refTo(name)
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
#customScalar(scalar: ScalarDeclaration, use: Use): Inner | undefined {
|
|
867
|
+
const key = `#scalar:${scalar.identity}`
|
|
868
|
+
if (!this.#models.has(key)) {
|
|
869
|
+
const model: ScalarModel = {
|
|
870
|
+
kind: 'scalar',
|
|
871
|
+
name: scalar.name,
|
|
872
|
+
key,
|
|
873
|
+
description: scalar.description,
|
|
874
|
+
location: scalar.location,
|
|
875
|
+
definition: scalar.identity,
|
|
876
|
+
specifiedByURL: scalar.specifiedByURL,
|
|
877
|
+
}
|
|
878
|
+
this.#models.set(key, model)
|
|
879
|
+
if (!this.#claim(model, use)) return undefined
|
|
880
|
+
}
|
|
881
|
+
return refTo(scalar.name)
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// ------------------------------------------------------------------------------- named types
|
|
885
|
+
|
|
886
|
+
#namedType(key: string, use: Use): Inner | undefined {
|
|
887
|
+
const named = this.#named.get(key)
|
|
888
|
+
if (named === undefined)
|
|
889
|
+
return this.#fail(Codes.InvalidType, use, `Cannot resolve the type of ${use.coordinate}`, [])
|
|
890
|
+
switch (named.declaration) {
|
|
891
|
+
case 'enum':
|
|
892
|
+
return this.#enum(named, use)
|
|
893
|
+
case 'alias':
|
|
894
|
+
return this.#alias(named, use)
|
|
895
|
+
default:
|
|
896
|
+
return this.#object(named, named, use)
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* A named alias: an object type (object literal, intersection, utility type, generic instance),
|
|
902
|
+
* an enum (string literals), a union (object types), or transparent (`type Email = string`,
|
|
903
|
+
* `type Books = Book[]`, `type Author = Person`).
|
|
904
|
+
*/
|
|
905
|
+
#alias(alias: NamedShape, use: Use): Inner | undefined {
|
|
906
|
+
const shape = alias.shape
|
|
907
|
+
switch (shape.kind) {
|
|
908
|
+
case 'object':
|
|
909
|
+
case 'intersection':
|
|
910
|
+
return this.#object(alias, alias, use)
|
|
911
|
+
case 'named': {
|
|
912
|
+
const target = this.#named.get(shape.key)
|
|
913
|
+
if (
|
|
914
|
+
target !== undefined &&
|
|
915
|
+
target.declaration !== 'alias' &&
|
|
916
|
+
target.declaration !== 'enum' &&
|
|
917
|
+
target.typeArgs.length > 0
|
|
918
|
+
) {
|
|
919
|
+
// `type BookList = Page<Book>`: the alias names the instance
|
|
920
|
+
return this.#object(alias, target, use)
|
|
921
|
+
}
|
|
922
|
+
return this.#namedType(shape.key, use)
|
|
923
|
+
}
|
|
924
|
+
case 'union': {
|
|
925
|
+
const rest = shape.members.filter(member => member.kind !== 'null' && member.kind !== 'undefined')
|
|
926
|
+
const nullable = rest.length !== shape.members.length
|
|
927
|
+
if (rest.length === 1) {
|
|
928
|
+
const inner = this.#inner(rest[0] as Shape, use)
|
|
929
|
+
return inner === undefined ? undefined : { ref: inner.ref, nullable: inner.nullable || nullable }
|
|
930
|
+
}
|
|
931
|
+
let converted: Inner | undefined
|
|
932
|
+
if (rest.every(member => member.kind === 'literal')) converted = this.#literalEnum(alias, rest, use)
|
|
933
|
+
else if (rest.length > 0 && rest.every(member => member.kind === 'named'))
|
|
934
|
+
converted = this.#union(alias, rest, use)
|
|
935
|
+
else converted = this.#anonymousUnion(rest, use)
|
|
936
|
+
return converted === undefined ? undefined : { ref: converted.ref, nullable }
|
|
937
|
+
}
|
|
938
|
+
default:
|
|
939
|
+
return this.#inner(shape, use)
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* The declaration key of the object type a shape maps to in output position (the parent of a
|
|
945
|
+
* field resolver), `undefined` when it is not an object type. Mirrors {@link #alias}.
|
|
946
|
+
*/
|
|
947
|
+
#objectKeyOf(shape: Shape): string | undefined {
|
|
948
|
+
if (shape.kind === 'union') {
|
|
949
|
+
const rest = shape.members.filter(member => member.kind !== 'null' && member.kind !== 'undefined')
|
|
950
|
+
return rest.length === 1 ? this.#objectKeyOf(rest[0] as Shape) : undefined
|
|
951
|
+
}
|
|
952
|
+
if (shape.kind !== 'named') return undefined
|
|
953
|
+
const named = this.#named.get(shape.key)
|
|
954
|
+
if (named === undefined || named.declaration === 'enum') return undefined
|
|
955
|
+
if (named.declaration !== 'alias') return named.key
|
|
956
|
+
const target = named.shape
|
|
957
|
+
if (target.kind === 'object' || target.kind === 'intersection') return named.key
|
|
958
|
+
if (target.kind === 'named') {
|
|
959
|
+
const instance = this.#named.get(target.key)
|
|
960
|
+
if (
|
|
961
|
+
instance !== undefined &&
|
|
962
|
+
instance.declaration !== 'alias' &&
|
|
963
|
+
instance.declaration !== 'enum' &&
|
|
964
|
+
instance.typeArgs.length > 0
|
|
965
|
+
) {
|
|
966
|
+
return named.key
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
return this.#objectKeyOf(target)
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/** An object type (output) or an input object type (input) for `named`, structured as `structure`. */
|
|
973
|
+
#object(named: NamedShape, structure: NamedShape, use: Use): Inner | undefined {
|
|
974
|
+
const key = named.key
|
|
975
|
+
if (!this.#checkPosition(named, use)) return undefined
|
|
976
|
+
const existing = this.#models.get(key)
|
|
977
|
+
if (existing !== undefined) return this.#known(existing)
|
|
978
|
+
const name = this.#typeName(named, use)
|
|
979
|
+
if (name === undefined) return undefined
|
|
980
|
+
const properties = this.#propertiesOf(structure.shape)
|
|
981
|
+
if (properties === 'record') {
|
|
982
|
+
return this.#fail(
|
|
983
|
+
Codes.InvalidType,
|
|
984
|
+
use,
|
|
985
|
+
`Cannot map ${named.name}, a record or an index signature, to a GraphQL type (${use.coordinate})`,
|
|
986
|
+
[RECORD_HINT],
|
|
987
|
+
)
|
|
988
|
+
}
|
|
989
|
+
if (properties === undefined) {
|
|
990
|
+
return this.#fail(
|
|
991
|
+
Codes.InvalidType,
|
|
992
|
+
use,
|
|
993
|
+
`Cannot map ${named.name} to a GraphQL object type (${use.coordinate})`,
|
|
994
|
+
['Declare an interface, a class or a named object type'],
|
|
995
|
+
)
|
|
996
|
+
}
|
|
997
|
+
const description = descriptionOf(named.doc) ?? (structure === named ? undefined : descriptionOf(structure.doc))
|
|
998
|
+
const trail = `${use.trail} → ${name}`
|
|
999
|
+
if (use.position === 'input') {
|
|
1000
|
+
const model: InputModel = { kind: 'input', name, key, description, location: named.location, fields: [] }
|
|
1001
|
+
this.#models.set(key, model)
|
|
1002
|
+
if (!this.#claim(model, use)) return undefined
|
|
1003
|
+
for (const property of properties) {
|
|
1004
|
+
if (isHidden(property.doc)) continue
|
|
1005
|
+
const fieldUse: Use = {
|
|
1006
|
+
position: 'input',
|
|
1007
|
+
coordinate: `${name}.${property.name}`,
|
|
1008
|
+
trail: `${trail}.${property.name}`,
|
|
1009
|
+
reached: trail,
|
|
1010
|
+
root: use.root,
|
|
1011
|
+
locate: () => annotationLocation(this.#ctx.project, property.location),
|
|
1012
|
+
}
|
|
1013
|
+
const field = this.#inputValue(property, fieldUse)
|
|
1014
|
+
if (field !== undefined) model.fields.push(field)
|
|
1015
|
+
}
|
|
1016
|
+
return refTo(name)
|
|
1017
|
+
}
|
|
1018
|
+
const model: ObjectModel = {
|
|
1019
|
+
kind: 'object',
|
|
1020
|
+
name,
|
|
1021
|
+
key,
|
|
1022
|
+
description,
|
|
1023
|
+
location: named.location,
|
|
1024
|
+
fields: [],
|
|
1025
|
+
trail,
|
|
1026
|
+
root: use.root,
|
|
1027
|
+
classIdentity:
|
|
1028
|
+
structure.declaration === 'class' && structure.typeArgs.length === 0 && structure === named
|
|
1029
|
+
? structure.identity
|
|
1030
|
+
: undefined,
|
|
1031
|
+
properties: properties.map(property => this.#discriminant(property)),
|
|
1032
|
+
}
|
|
1033
|
+
this.#models.set(key, model)
|
|
1034
|
+
if (!this.#claim(model, use)) return undefined
|
|
1035
|
+
for (const property of properties) {
|
|
1036
|
+
const field = this.#outputField(model, property)
|
|
1037
|
+
if (field !== undefined) model.fields.push(field)
|
|
1038
|
+
}
|
|
1039
|
+
this.#attachFieldResolvers(model)
|
|
1040
|
+
return refTo(name)
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/** A property as a union discriminator candidate: its literal value, through aliases (`kind: BookKind`). */
|
|
1044
|
+
#discriminant(property: ShapeProperty): DiscriminantProperty {
|
|
1045
|
+
let shape = property.shape
|
|
1046
|
+
// `type BookKind = 'book'`, `type Kind = BookKind`; the bound only guards against alias cycles
|
|
1047
|
+
for (let depth = 0; shape.kind === 'named' && depth < 32; depth++) {
|
|
1048
|
+
const named = this.#named.get(shape.key)
|
|
1049
|
+
if (named?.declaration !== 'alias') break
|
|
1050
|
+
shape = named.shape
|
|
1051
|
+
}
|
|
1052
|
+
return {
|
|
1053
|
+
name: property.name,
|
|
1054
|
+
optional: property.optional,
|
|
1055
|
+
literal: shape.kind === 'literal' ? shape.value : undefined,
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
#outputField(model: ObjectModel, property: ShapeProperty): FieldModel | undefined {
|
|
1060
|
+
// hidden members, functions (methods stored as properties) and the union discriminator `__typename`
|
|
1061
|
+
if (isHidden(property.doc) || property.name === '__typename') return undefined
|
|
1062
|
+
if (property.shape.kind === 'opaque' && property.shape.reason === 'function') return undefined
|
|
1063
|
+
const use: Use = {
|
|
1064
|
+
position: 'output',
|
|
1065
|
+
coordinate: `${model.name}.${property.name}`,
|
|
1066
|
+
trail: `${model.trail}.${property.name}`,
|
|
1067
|
+
reached: model.trail,
|
|
1068
|
+
root: model.root,
|
|
1069
|
+
locate: () => annotationLocation(this.#ctx.project, property.location),
|
|
1070
|
+
}
|
|
1071
|
+
if (!isGraphQLName(property.name)) {
|
|
1072
|
+
return this.#fail(
|
|
1073
|
+
Codes.NameConflict,
|
|
1074
|
+
{ ...use, locate: () => property.location },
|
|
1075
|
+
`'${property.name}' is not a valid GraphQL field name (${use.coordinate})`,
|
|
1076
|
+
[NAME_HINT, 'Hide the property with /** @gqlHidden */'],
|
|
1077
|
+
)
|
|
1078
|
+
}
|
|
1079
|
+
const converted = this.#convert(property.shape, property.optional, use)
|
|
1080
|
+
if (converted === undefined) return undefined
|
|
1081
|
+
return {
|
|
1082
|
+
name: property.name,
|
|
1083
|
+
type: printTypeRef(converted.ref),
|
|
1084
|
+
description: descriptionOf(property.doc),
|
|
1085
|
+
deprecationReason: deprecationOf(property.doc),
|
|
1086
|
+
args: [],
|
|
1087
|
+
binding: undefined,
|
|
1088
|
+
location: property.location,
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* The properties of an object structure (intersections merged, later members win), `'record'` for
|
|
1094
|
+
* a record or an index signature (ZNIGHT060: GraphQL objects have fixed fields).
|
|
1095
|
+
*/
|
|
1096
|
+
#propertiesOf(shape: Shape): readonly ShapeProperty[] | 'record' | undefined {
|
|
1097
|
+
switch (shape.kind) {
|
|
1098
|
+
case 'object':
|
|
1099
|
+
return shape.additional === undefined ? shape.properties : 'record'
|
|
1100
|
+
case 'named': {
|
|
1101
|
+
const named = this.#named.get(shape.key)
|
|
1102
|
+
return named === undefined || named.declaration === 'enum' ? undefined : this.#propertiesOf(named.shape)
|
|
1103
|
+
}
|
|
1104
|
+
case 'intersection': {
|
|
1105
|
+
const merged = new Map<string, ShapeProperty>()
|
|
1106
|
+
for (const member of shape.members) {
|
|
1107
|
+
const properties = this.#propertiesOf(member)
|
|
1108
|
+
if (properties === undefined || properties === 'record') return properties
|
|
1109
|
+
for (const property of properties) {
|
|
1110
|
+
merged.delete(property.name)
|
|
1111
|
+
merged.set(property.name, property)
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
return [...merged.values()]
|
|
1115
|
+
}
|
|
1116
|
+
default:
|
|
1117
|
+
return undefined
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/** A declaration is used in one position: input or output (ZNIGHT063 otherwise). */
|
|
1122
|
+
#checkPosition(named: NamedShape, use: Use): boolean {
|
|
1123
|
+
let uses = this.#positions.get(named.key)
|
|
1124
|
+
if (uses === undefined) {
|
|
1125
|
+
uses = {}
|
|
1126
|
+
this.#positions.set(named.key, uses)
|
|
1127
|
+
}
|
|
1128
|
+
const other = use.position === 'input' ? uses.output : uses.input
|
|
1129
|
+
if (uses[use.position] === undefined) uses[use.position] = { coordinate: use.coordinate, location: use.locate() }
|
|
1130
|
+
if (other === undefined) return true
|
|
1131
|
+
const output = use.position === 'output' ? use.coordinate : other.coordinate
|
|
1132
|
+
const input = use.position === 'input' ? use.coordinate : other.coordinate
|
|
1133
|
+
const inputOwner = input.includes('(') ? input.slice(0, input.indexOf('(')) : input.split('.')[0]
|
|
1134
|
+
this.#reportOnce(`position\0${named.key}`, {
|
|
1135
|
+
code: Codes.InvalidInput,
|
|
1136
|
+
severity: 'error',
|
|
1137
|
+
message: `'${named.name}' is used as an output type (${output}) and as an input type (${input})`,
|
|
1138
|
+
location: named.location,
|
|
1139
|
+
hints: [
|
|
1140
|
+
`GraphQL needs two types: declare \`export interface ${named.name}Input extends ${named.name} {}\` and use it in ${inputOwner}`,
|
|
1141
|
+
],
|
|
1142
|
+
related: [
|
|
1143
|
+
...(uses.output?.location === undefined
|
|
1144
|
+
? []
|
|
1145
|
+
: [{ message: `used as output: ${output}`, location: uses.output.location }]),
|
|
1146
|
+
...(uses.input?.location === undefined
|
|
1147
|
+
? []
|
|
1148
|
+
: [{ message: `used as input: ${input}`, location: uses.input.location }]),
|
|
1149
|
+
],
|
|
1150
|
+
})
|
|
1151
|
+
return false
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* The GraphQL name of a declaration (§6.4): `@gqlName`, the declaration name, or for a generic
|
|
1156
|
+
* instance the names of its type arguments followed by the declaration name (`BookPage`).
|
|
1157
|
+
*/
|
|
1158
|
+
#typeName(named: NamedShape, use: Use): string | undefined {
|
|
1159
|
+
if (this.#names.has(named.key)) return this.#names.get(named.key)
|
|
1160
|
+
let name: string | undefined
|
|
1161
|
+
const explicit = tagOf(named.doc, 'gqlName')?.text.trim()
|
|
1162
|
+
if (explicit !== undefined && explicit !== '') name = explicit
|
|
1163
|
+
else if (named.typeArgs.length === 0) name = named.name
|
|
1164
|
+
else {
|
|
1165
|
+
const args = named.typeArgs.map(arg => this.#argumentName(arg))
|
|
1166
|
+
const missing = args.findIndex(arg => arg === undefined)
|
|
1167
|
+
if (missing === -1) name = `${args.join('')}${named.name}`
|
|
1168
|
+
else {
|
|
1169
|
+
this.#reportOnce(`argument\0${named.key}`, {
|
|
1170
|
+
code: Codes.InvalidType,
|
|
1171
|
+
severity: 'error',
|
|
1172
|
+
message: `Cannot name the GraphQL type of ${named.name}<...> (${use.coordinate}): its type argument ${describeShape(named.typeArgs[missing] as Shape)} has no GraphQL name`,
|
|
1173
|
+
location: named.location,
|
|
1174
|
+
related: reachedFrom(use),
|
|
1175
|
+
hints: [
|
|
1176
|
+
`Name the instance with an alias (export type BookPages = ${named.name}<...>) or with /** @gqlName Name */`,
|
|
1177
|
+
],
|
|
1178
|
+
})
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
this.#names.set(named.key, name)
|
|
1182
|
+
return name
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/** The GraphQL name a type argument contributes to a generic instance name. */
|
|
1186
|
+
#argumentName(shape: Shape): string | undefined {
|
|
1187
|
+
switch (shape.kind) {
|
|
1188
|
+
case 'string':
|
|
1189
|
+
return 'String'
|
|
1190
|
+
case 'number':
|
|
1191
|
+
return this.#number
|
|
1192
|
+
case 'boolean':
|
|
1193
|
+
return 'Boolean'
|
|
1194
|
+
case 'date':
|
|
1195
|
+
return 'DateTime'
|
|
1196
|
+
case 'bigint':
|
|
1197
|
+
return 'BigInt'
|
|
1198
|
+
case 'reference': {
|
|
1199
|
+
const alias = SCALAR_ALIASES.get(shape.identity)
|
|
1200
|
+
if (alias !== undefined) return alias
|
|
1201
|
+
return this.#scalars.get(shape.identity)?.name
|
|
1202
|
+
}
|
|
1203
|
+
case 'union': {
|
|
1204
|
+
const rest = shape.members.filter(member => member.kind !== 'null' && member.kind !== 'undefined')
|
|
1205
|
+
return rest.length === 1 ? this.#argumentName(rest[0] as Shape) : undefined
|
|
1206
|
+
}
|
|
1207
|
+
case 'named': {
|
|
1208
|
+
const named = this.#named.get(shape.key)
|
|
1209
|
+
if (named === undefined) return undefined
|
|
1210
|
+
if (named.declaration === 'alias') {
|
|
1211
|
+
const target = named.shape
|
|
1212
|
+
const instance = target.kind === 'named' ? this.#named.get(target.key) : undefined
|
|
1213
|
+
const isObject =
|
|
1214
|
+
target.kind === 'object' ||
|
|
1215
|
+
target.kind === 'intersection' ||
|
|
1216
|
+
(instance !== undefined &&
|
|
1217
|
+
instance.declaration !== 'alias' &&
|
|
1218
|
+
instance.declaration !== 'enum' &&
|
|
1219
|
+
instance.typeArgs.length > 0)
|
|
1220
|
+
const isEnum = target.kind === 'union' && target.members.every(member => member.kind === 'literal')
|
|
1221
|
+
const isUnion = target.kind === 'union' && target.members.filter(member => member.kind === 'named').length > 1
|
|
1222
|
+
if (!isObject && !isEnum && !isUnion) return this.#argumentName(target)
|
|
1223
|
+
}
|
|
1224
|
+
return this.#typeName(named, {
|
|
1225
|
+
position: 'output',
|
|
1226
|
+
coordinate: named.name,
|
|
1227
|
+
trail: named.name,
|
|
1228
|
+
reached: undefined,
|
|
1229
|
+
root: undefined,
|
|
1230
|
+
locate: () => named.location,
|
|
1231
|
+
})
|
|
1232
|
+
}
|
|
1233
|
+
default:
|
|
1234
|
+
return undefined
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* Registers a model under its GraphQL name: the name must be valid, not reserved, and not taken
|
|
1240
|
+
* by another declaration (ZNIGHT061).
|
|
1241
|
+
*/
|
|
1242
|
+
#claim(model: TypeModel, use: Use | undefined): boolean {
|
|
1243
|
+
const name = model.name
|
|
1244
|
+
const isRoot = model.key.startsWith('#root:')
|
|
1245
|
+
const location = model.location ?? use?.locate()
|
|
1246
|
+
let problem: string | undefined
|
|
1247
|
+
if (!isGraphQLName(name)) problem = `'${name}' is not a valid GraphQL type name`
|
|
1248
|
+
else if (!isRoot && ROOT_NAMES.has(name)) problem = `'${name}' is the name of a root operation type`
|
|
1249
|
+
else if (SPECIFIED_SCALARS.has(name)) problem = `'${name}' is the name of a specified GraphQL scalar`
|
|
1250
|
+
if (problem !== undefined) {
|
|
1251
|
+
this.#reportOnce(`name\0${model.key}`, {
|
|
1252
|
+
code: Codes.NameConflict,
|
|
1253
|
+
severity: 'error',
|
|
1254
|
+
message: `${problem} (${model.key.startsWith('#') ? name : model.key})`,
|
|
1255
|
+
...(location === undefined ? {} : { location }),
|
|
1256
|
+
related: use === undefined ? [] : reachedFrom(use),
|
|
1257
|
+
hints: [isGraphQLName(name) ? 'Rename the declaration, or name it with /** @gqlName OtherName */' : NAME_HINT],
|
|
1258
|
+
})
|
|
1259
|
+
return false
|
|
1260
|
+
}
|
|
1261
|
+
const existing = this.#types.get(name)
|
|
1262
|
+
if (existing === undefined) {
|
|
1263
|
+
this.#types.set(name, model)
|
|
1264
|
+
return true
|
|
1265
|
+
}
|
|
1266
|
+
if (existing === model) return true
|
|
1267
|
+
const pair = [existing.key, model.key].sort(compareStrings).join('\0')
|
|
1268
|
+
this.#reportOnce(`conflict\0${pair}`, {
|
|
1269
|
+
code: Codes.NameConflict,
|
|
1270
|
+
severity: 'error',
|
|
1271
|
+
message: `Two types are named '${name}' in the GraphQL schema: ${describeKey(model)} and ${describeKey(existing)}`,
|
|
1272
|
+
...(location === undefined ? {} : { location }),
|
|
1273
|
+
related: [
|
|
1274
|
+
...(existing.location === undefined ? [] : [{ message: 'the other declaration', location: existing.location }]),
|
|
1275
|
+
...(use === undefined ? [] : reachedFrom(use)),
|
|
1276
|
+
],
|
|
1277
|
+
hints: ['Rename one of them, or name one with /** @gqlName OtherName */'],
|
|
1278
|
+
})
|
|
1279
|
+
return false
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// -------------------------------------------------------------------------------------- enums
|
|
1283
|
+
|
|
1284
|
+
/** A model met again: its reference, unless its name was refused (already reported). */
|
|
1285
|
+
#known(model: TypeModel): Inner | undefined {
|
|
1286
|
+
return this.#types.get(model.name) === model ? refTo(model.name) : undefined
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/** A TypeScript `enum`: names = member names, internal values = member values. */
|
|
1290
|
+
#enum(named: NamedShape, use: Use): Inner | undefined {
|
|
1291
|
+
const existing = this.#models.get(named.key)
|
|
1292
|
+
if (existing !== undefined) return this.#known(existing)
|
|
1293
|
+
const name = this.#typeName(named, use)
|
|
1294
|
+
if (name === undefined) return undefined
|
|
1295
|
+
const members = named.enumMembers
|
|
1296
|
+
if (members === undefined || members.length === 0) {
|
|
1297
|
+
return this.#fail(
|
|
1298
|
+
Codes.InvalidType,
|
|
1299
|
+
use,
|
|
1300
|
+
`The values of the enum ${named.name} cannot be evaluated (${use.coordinate})`,
|
|
1301
|
+
['Use literal member values (A = "a", B = 1)'],
|
|
1302
|
+
)
|
|
1303
|
+
}
|
|
1304
|
+
const values: EnumValueModel[] = []
|
|
1305
|
+
for (const member of members) {
|
|
1306
|
+
if (!this.#checkEnumValueName(member.name, named, member.location, use)) continue
|
|
1307
|
+
values.push({
|
|
1308
|
+
name: member.name,
|
|
1309
|
+
value: member.value,
|
|
1310
|
+
description: descriptionOf(member.doc),
|
|
1311
|
+
deprecationReason: deprecationOf(member.doc),
|
|
1312
|
+
})
|
|
1313
|
+
}
|
|
1314
|
+
return this.#registerEnum(named, name, values, use)
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
/** A named alias of a union of string literals: names = values = the literals, in source order. */
|
|
1318
|
+
#literalEnum(named: NamedShape, members: readonly Shape[], use: Use): Inner | undefined {
|
|
1319
|
+
const existing = this.#models.get(named.key)
|
|
1320
|
+
if (existing !== undefined) return this.#known(existing)
|
|
1321
|
+
const literals = named.literals ?? members.map(member => (member as Extract<Shape, { kind: 'literal' }>).value)
|
|
1322
|
+
if (!literals.every(value => typeof value === 'string')) {
|
|
1323
|
+
return this.#fail(
|
|
1324
|
+
Codes.InvalidType,
|
|
1325
|
+
use,
|
|
1326
|
+
`Cannot map ${named.name} (${literals.map(value => JSON.stringify(value)).join(' | ')}) to a GraphQL type (${use.coordinate}): only unions of string literals are enums`,
|
|
1327
|
+
['Declare a TypeScript enum (enum Level { Low = 1, High = 2 }), or use Int'],
|
|
1328
|
+
)
|
|
1329
|
+
}
|
|
1330
|
+
const name = this.#typeName(named, use)
|
|
1331
|
+
if (name === undefined) return undefined
|
|
1332
|
+
const values: EnumValueModel[] = []
|
|
1333
|
+
for (const literal of literals as readonly string[]) {
|
|
1334
|
+
if (!this.#checkEnumValueName(literal, named, named.location, use)) continue
|
|
1335
|
+
values.push({ name: literal, value: literal, description: undefined, deprecationReason: undefined })
|
|
1336
|
+
}
|
|
1337
|
+
return this.#registerEnum(named, name, values, use)
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
#checkEnumValueName(value: string, named: NamedShape, location: Location, use: Use): boolean {
|
|
1341
|
+
if (isGraphQLName(value) && !RESERVED_ENUM_VALUES.has(value)) return true
|
|
1342
|
+
this.#reportOnce(`enum\0${named.key}\0${value}`, {
|
|
1343
|
+
code: Codes.NameConflict,
|
|
1344
|
+
severity: 'error',
|
|
1345
|
+
message: `'${value}' of ${named.name} is not a valid GraphQL enum value`,
|
|
1346
|
+
location,
|
|
1347
|
+
related: reachedFrom(use),
|
|
1348
|
+
hints: [`${NAME_HINT}; true, false and null are reserved`],
|
|
1349
|
+
})
|
|
1350
|
+
return false
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
#registerEnum(named: NamedShape, name: string, values: readonly EnumValueModel[], use: Use): Inner | undefined {
|
|
1354
|
+
const model: TypeModel = {
|
|
1355
|
+
kind: 'enum',
|
|
1356
|
+
name,
|
|
1357
|
+
key: named.key,
|
|
1358
|
+
description: descriptionOf(named.doc),
|
|
1359
|
+
location: named.location,
|
|
1360
|
+
values,
|
|
1361
|
+
}
|
|
1362
|
+
this.#models.set(named.key, model)
|
|
1363
|
+
if (!this.#claim(model, use)) return undefined
|
|
1364
|
+
return refTo(name)
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// ------------------------------------------------------------------------------------- unions
|
|
1368
|
+
|
|
1369
|
+
/** A named alias of a union of object types (output only), with its runtime discriminator. */
|
|
1370
|
+
#union(alias: NamedShape, members: readonly Shape[], use: Use): Inner | undefined {
|
|
1371
|
+
if (use.position === 'input') {
|
|
1372
|
+
return this.#fail(
|
|
1373
|
+
Codes.InvalidInput,
|
|
1374
|
+
use,
|
|
1375
|
+
`${use.coordinate} is the union ${alias.name}: unions cannot be input values`,
|
|
1376
|
+
['Declare one input type with optional fields, or one argument per case'],
|
|
1377
|
+
)
|
|
1378
|
+
}
|
|
1379
|
+
const existing = this.#models.get(alias.key)
|
|
1380
|
+
if (existing !== undefined) return this.#known(existing)
|
|
1381
|
+
const name = this.#typeName(alias, use)
|
|
1382
|
+
if (name === undefined) return undefined
|
|
1383
|
+
const model: UnionModel = {
|
|
1384
|
+
kind: 'union',
|
|
1385
|
+
name,
|
|
1386
|
+
key: alias.key,
|
|
1387
|
+
description: descriptionOf(alias.doc),
|
|
1388
|
+
location: alias.location,
|
|
1389
|
+
members: [],
|
|
1390
|
+
discriminator: undefined,
|
|
1391
|
+
}
|
|
1392
|
+
this.#models.set(alias.key, model)
|
|
1393
|
+
if (!this.#claim(model, use)) return undefined
|
|
1394
|
+
const trail = `${use.trail} → ${name}`
|
|
1395
|
+
const objects: ObjectModel[] = []
|
|
1396
|
+
// a failed member is reported: no discriminator then (members that collapse into one object, as
|
|
1397
|
+
// `Book | BookAlias`, are not failures)
|
|
1398
|
+
let failed = false
|
|
1399
|
+
for (const member of members) {
|
|
1400
|
+
const memberUse: Use = { ...use, coordinate: name, trail, reached: trail, locate: () => alias.location }
|
|
1401
|
+
const converted = this.#inner(member, memberUse)
|
|
1402
|
+
if (converted === undefined) {
|
|
1403
|
+
failed = true
|
|
1404
|
+
continue
|
|
1405
|
+
}
|
|
1406
|
+
const target = converted.ref.kind === 'named' ? this.#types.get(converted.ref.name) : undefined
|
|
1407
|
+
if (target?.kind !== 'object') {
|
|
1408
|
+
failed = true
|
|
1409
|
+
this.#fail(
|
|
1410
|
+
Codes.InvalidType,
|
|
1411
|
+
memberUse,
|
|
1412
|
+
`The union ${name} contains ${describeShape(member)}, which is not an object type`,
|
|
1413
|
+
['GraphQL unions only contain object types'],
|
|
1414
|
+
)
|
|
1415
|
+
continue
|
|
1416
|
+
}
|
|
1417
|
+
if (!objects.includes(target)) objects.push(target)
|
|
1418
|
+
}
|
|
1419
|
+
objects.sort((a, b) => compareStrings(a.name, b.name))
|
|
1420
|
+
model.members = objects.map(object => object.name)
|
|
1421
|
+
if (!failed) model.discriminator = this.#discriminator(model, objects)
|
|
1422
|
+
return refTo(name)
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/**
|
|
1426
|
+
* How the runtime tells the members apart (§5.7): `instanceof` when every member is an exported
|
|
1427
|
+
* class, a `__typename` property, a literal property with a distinct value per member, else
|
|
1428
|
+
* ZNIGHT064.
|
|
1429
|
+
*/
|
|
1430
|
+
#discriminator(union: UnionModel, members: readonly ObjectModel[]): DiscriminatorModel | undefined {
|
|
1431
|
+
const found = discriminatorOf(members, identity => this.#ctx.classInfo(identity))
|
|
1432
|
+
if (found !== undefined) return found
|
|
1433
|
+
const names = members.map(member => member.name)
|
|
1434
|
+
const one = names.length === 1
|
|
1435
|
+
const listed = one ? (names[0] as string) : `${names.slice(0, -1).join(', ')} and ${names.at(-1) as string}`
|
|
1436
|
+
const allClasses = members.every(member => member.classIdentity !== undefined)
|
|
1437
|
+
let why: string
|
|
1438
|
+
if (allClasses) why = one ? 'is a class that is not exported' : 'are classes that are not all exported'
|
|
1439
|
+
else why = one ? 'has no literal property' : 'have no common literal property'
|
|
1440
|
+
this.#report({
|
|
1441
|
+
code: Codes.UnresolvableAbstractType,
|
|
1442
|
+
severity: 'error',
|
|
1443
|
+
message: `The members of union ${union.name} cannot be told apart at runtime: ${listed} ${why}`,
|
|
1444
|
+
...(union.location === undefined ? {} : { location: union.location }),
|
|
1445
|
+
hints: [
|
|
1446
|
+
allClasses
|
|
1447
|
+
? 'Export the member classes: the generated module imports them for instanceof'
|
|
1448
|
+
: `Add a literal property to each member (${members.map(member => `readonly kind: '${lowerFirst(member.name)}'`).join(' / ')}), or make the members classes`,
|
|
1449
|
+
],
|
|
1450
|
+
})
|
|
1451
|
+
return undefined
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// --------------------------------------------------------------------------------- final checks
|
|
1455
|
+
|
|
1456
|
+
/** Defaults (`@default`, parameter defaults) against their types (ZNIGHT063), as external values. */
|
|
1457
|
+
#checkDefaults(): void {
|
|
1458
|
+
for (const pending of this.#defaults) {
|
|
1459
|
+
const result = externalValue(pending.value, pending.target.ref, this.#types)
|
|
1460
|
+
if ('error' in result) {
|
|
1461
|
+
const location = pending.location ?? pending.use.locate()
|
|
1462
|
+
this.#report({
|
|
1463
|
+
code: Codes.InvalidInput,
|
|
1464
|
+
severity: 'error',
|
|
1465
|
+
message: `Invalid default value ${describeValue(pending.value)} for ${pending.use.coordinate} (${pending.target.type}): ${result.error}`,
|
|
1466
|
+
...(location === undefined ? {} : { location }),
|
|
1467
|
+
related: reachedFrom(pending.use),
|
|
1468
|
+
hints: ['Defaults are JSON values of the argument type (enum values as their member value or name)'],
|
|
1469
|
+
})
|
|
1470
|
+
continue
|
|
1471
|
+
}
|
|
1472
|
+
pending.target.defaultValue = result.value
|
|
1473
|
+
if (pending.target.ref.nonNull) pending.target.nullAsUndefined = false
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
/** GraphQL forbids a cycle of non-null input fields (ZNIGHT063). */
|
|
1478
|
+
#checkInputCycles(): void {
|
|
1479
|
+
const inputs = [...this.#models.values()]
|
|
1480
|
+
.filter((model): model is InputModel => model.kind === 'input')
|
|
1481
|
+
.sort((a, b) => compareStrings(a.name, b.name))
|
|
1482
|
+
const state = new Map<string, 'visiting' | 'done'>()
|
|
1483
|
+
const stack: string[] = []
|
|
1484
|
+
const visit = (model: InputModel): void => {
|
|
1485
|
+
state.set(model.name, 'visiting')
|
|
1486
|
+
stack.push(model.name)
|
|
1487
|
+
for (const field of model.fields) {
|
|
1488
|
+
if (field.ref.kind !== 'named' || !field.ref.nonNull) continue
|
|
1489
|
+
const target = this.#types.get(field.ref.name)
|
|
1490
|
+
if (target?.kind !== 'input') continue
|
|
1491
|
+
const status = state.get(target.name)
|
|
1492
|
+
if (status === 'visiting') {
|
|
1493
|
+
const cycle = [...stack.slice(stack.indexOf(target.name)), target.name]
|
|
1494
|
+
this.#report({
|
|
1495
|
+
code: Codes.InvalidInput,
|
|
1496
|
+
severity: 'error',
|
|
1497
|
+
message: `The input types ${cycle.join(' → ')} reference each other through non-null fields: no value can be written`,
|
|
1498
|
+
...(target.location === undefined ? {} : { location: target.location }),
|
|
1499
|
+
hints: ['Make one of the fields of the cycle optional (?: T) or a list'],
|
|
1500
|
+
})
|
|
1501
|
+
} else if (status === undefined) {
|
|
1502
|
+
visit(target)
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
stack.pop()
|
|
1506
|
+
state.set(model.name, 'done')
|
|
1507
|
+
}
|
|
1508
|
+
for (const model of inputs) if (!state.has(model.name)) visit(model)
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/** RFC 0001: every root field decides every required route decorator group (ZNIGHT023). */
|
|
1512
|
+
#checkDecisions(resolvers: readonly ResolverData[]): void {
|
|
1513
|
+
const applied: MetaEntry[] = []
|
|
1514
|
+
for (const resolver of resolvers) for (const field of resolver.fields) applied.push(...field.meta)
|
|
1515
|
+
const required = requiredRouteGroups(this.#ctx, applied)
|
|
1516
|
+
if (required.length === 0) return
|
|
1517
|
+
for (const resolver of resolvers) {
|
|
1518
|
+
for (const field of resolver.fields) {
|
|
1519
|
+
if (field.operation === 'field') continue
|
|
1520
|
+
const decided = new Set(field.meta.map(entry => entry.group))
|
|
1521
|
+
for (const group of required) {
|
|
1522
|
+
if (decided.has(group.group)) continue
|
|
1523
|
+
const root = ROOT_TYPE_NAMES[field.operation]
|
|
1524
|
+
this.#report({
|
|
1525
|
+
code: Codes.MissingRouteDecision,
|
|
1526
|
+
severity: 'error',
|
|
1527
|
+
message: `Root field ${root}.${field.name} (${resolver.host.displayName}.${field.method}) has no "${group.group}" decision`,
|
|
1528
|
+
location: field.methodLocation,
|
|
1529
|
+
hints: [`Add ${group.suggestion} to the method or to ${resolver.host.displayName}`],
|
|
1530
|
+
related: [
|
|
1531
|
+
{
|
|
1532
|
+
message: `The "${group.group}" group is required`,
|
|
1533
|
+
location: (group.decorators[0] as { definition: Location }).definition,
|
|
1534
|
+
},
|
|
1535
|
+
],
|
|
1536
|
+
})
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
// ------------------------------------------------------------------------------------ assembly
|
|
1543
|
+
|
|
1544
|
+
#assemble(roots: ReadonlyMap<Operation, ObjectModel>, resolvers: readonly ResolverData[]): GraphQLModel {
|
|
1545
|
+
const types = [...this.#types.values()].sort((a, b) => compareStrings(a.name, b.name))
|
|
1546
|
+
const meta: MetaEntry[] = []
|
|
1547
|
+
for (const resolver of resolvers) for (const field of resolver.fields) meta.push(...field.meta)
|
|
1548
|
+
const rootNames = {
|
|
1549
|
+
query: ROOT_TYPE_NAMES.query,
|
|
1550
|
+
...(roots.has('mutation') ? { mutation: ROOT_TYPE_NAMES.mutation } : {}),
|
|
1551
|
+
...(roots.has('subscription') ? { subscription: ROOT_TYPE_NAMES.subscription } : {}),
|
|
1552
|
+
}
|
|
1553
|
+
const sdl = printSdl({ roots: rootNames, types: types.map(sdlType) })
|
|
1554
|
+
return { roots: rootNames, types, meta, sdl, schemaHash: schemaHash(sdl) }
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// ------------------------------------------------------------------------------------------ helpers
|
|
1559
|
+
|
|
1560
|
+
/** The class (or one of its base classes) declares an instance `load` method. */
|
|
1561
|
+
function hasLoadMethod(ctx: Context, identity: string): boolean {
|
|
1562
|
+
const seen = new Set<string>()
|
|
1563
|
+
let current: string | undefined = identity
|
|
1564
|
+
while (current !== undefined && !seen.has(current)) {
|
|
1565
|
+
seen.add(current)
|
|
1566
|
+
const cls: ClassInfo | undefined = ctx.classInfo(current)
|
|
1567
|
+
if (cls === undefined) return true // not analysable (a compiled class): trust the type checker
|
|
1568
|
+
if (cls.methods.some(method => method.name === 'load' && method.kind === 'method' && !method.static)) return true
|
|
1569
|
+
if (cls.properties.some(property => property.name === 'load' && !property.static)) return true
|
|
1570
|
+
current = cls.superClass?.identity
|
|
1571
|
+
}
|
|
1572
|
+
return false
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
/** A (nullable, until `#convert` decides) reference to a named type. */
|
|
1576
|
+
function refTo(name: string): Inner {
|
|
1577
|
+
return { ref: { kind: 'named', name, nonNull: false }, nullable: false }
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
function reachedFrom(use: Use): { readonly message: string; readonly location: Location }[] {
|
|
1581
|
+
return use.reached === undefined || use.root === undefined
|
|
1582
|
+
? []
|
|
1583
|
+
: [{ message: `reached from ${use.reached}`, location: use.root }]
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
function decoratorName(operation: Operation): string {
|
|
1587
|
+
return ROOT_TYPE_NAMES[operation]
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
/** The token of a binding result (`undefined` when binding failed). */
|
|
1591
|
+
function tokenOfRef(ref: ReturnType<Context['binding']>): string | undefined {
|
|
1592
|
+
if (typeof ref === 'string') return ref
|
|
1593
|
+
return ref !== undefined && 'token' in ref ? ref.token : undefined
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
function describeKey(model: TypeModel): string {
|
|
1597
|
+
return model.key.startsWith('#') ? `the ${model.kind} ${model.name}` : model.key
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
/** Short description of a shape for messages (`string`, `Book`, `Map<string, number>`). */
|
|
1601
|
+
function describeShape(shape: Shape): string {
|
|
1602
|
+
switch (shape.kind) {
|
|
1603
|
+
case 'literal':
|
|
1604
|
+
return JSON.stringify(shape.value)
|
|
1605
|
+
case 'named':
|
|
1606
|
+
return shape.key.slice(shape.key.indexOf('#') + 1)
|
|
1607
|
+
case 'reference':
|
|
1608
|
+
return shape.name
|
|
1609
|
+
case 'opaque':
|
|
1610
|
+
return shape.text
|
|
1611
|
+
case 'array':
|
|
1612
|
+
return `${describeShape(shape.items)}[]`
|
|
1613
|
+
case 'union':
|
|
1614
|
+
return shape.members.map(describeShape).join(' | ')
|
|
1615
|
+
case 'object':
|
|
1616
|
+
return 'an object literal type'
|
|
1617
|
+
default:
|
|
1618
|
+
return shape.kind
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
function describeValue(value: unknown): string {
|
|
1623
|
+
try {
|
|
1624
|
+
return JSON.stringify(value) ?? String(value)
|
|
1625
|
+
} catch {
|
|
1626
|
+
return String(value)
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
function containsRef(value: unknown): boolean {
|
|
1631
|
+
if (typeof value !== 'object' || value === null) return false
|
|
1632
|
+
if (Array.isArray(value)) return value.some(containsRef)
|
|
1633
|
+
const prototype: unknown = Object.getPrototypeOf(value)
|
|
1634
|
+
if (prototype !== Object.prototype && prototype !== null) return true
|
|
1635
|
+
return Object.values(value).some(containsRef)
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
function lowerFirst(text: string): string {
|
|
1639
|
+
return `${text.charAt(0).toLowerCase()}${text.slice(1)}`
|
|
1640
|
+
}
|