@lde/search-api-graphql 0.23.1 → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-schema.d.ts.map +1 -1
- package/dist/build-schema.js +55 -53
- package/dist/handler.d.ts +3 -1
- package/dist/handler.d.ts.map +1 -1
- package/dist/handler.js +11 -0
- package/dist/projection.d.ts +24 -0
- package/dist/projection.d.ts.map +1 -0
- package/dist/projection.js +119 -0
- package/package.json +2 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"build-schema.d.ts","sourceRoot":"","sources":["../src/build-schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAWL,aAAa,EAQd,MAAM,SAAS,CAAC;AACjB,OAAO,EAKL,KAAK,YAAY,
|
|
1
|
+
{"version":3,"file":"build-schema.d.ts","sourceRoot":"","sources":["../src/build-schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAWL,aAAa,EAQd,MAAM,SAAS,CAAC;AACjB,OAAO,EAKL,KAAK,YAAY,EAGjB,KAAK,WAAW,EAChB,KAAK,YAAY,EAElB,MAAM,aAAa,CAAC;AAmBrB,OAAO,EAGL,KAAK,aAAa,EACnB,MAAM,eAAe,CAAC;AAIvB,8EAA8E;AAC9E,MAAM,WAAW,aAAa;IAC5B;;sDAEkD;IAClD,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC;IAC9B,mFAAmF;IACnF,QAAQ,CAAC,cAAc,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3C;;;;;;OAMG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CACjE;AAED;2EAC2E;AAC3E,MAAM,WAAW,iBAAiB;IAChC;yCACqC;IACrC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B;uBACmB;IACnB,QAAQ,CAAC,aAAa,CAAC,EAAE,CACvB,KAAK,EAAE,WAAW,EAClB,OAAO,EAAE,aAAa,KACnB,WAAW,CAAC;CAClB;AAED,MAAM,WAAW,yBAAyB;IACxC;;kEAE8D;IAC9D,QAAQ,CAAC,KAAK,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC,CAAC;IAC7D,+EAA+E;IAC/E,QAAQ,CAAC,aAAa,CAAC,EAAE,aAAa,CAAC;IACvC;;gDAE4C;IAC5C,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B;AA4GD;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,YAAY,EACpB,OAAO,GAAE,yBAA8B,GACtC,aAAa,CA0xBf;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,YAAY,EACpB,OAAO,GAAE,yBAA8B,GACtC,MAAM,CAER"}
|
package/dist/build-schema.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { GraphQLBoolean, GraphQLEnumType, GraphQLError, GraphQLFloat, GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLNonNull, GraphQLObjectType, GraphQLScalarType, GraphQLSchema, GraphQLString, printSchema, } from 'graphql';
|
|
2
|
-
import { AND_KEY,
|
|
2
|
+
import { AND_KEY, facetableFields, filterableFields, filterOn, filterOperatorFor, ID_FIELD, isAbsoluteIri, OR_KEY, isRangeFacet, joinGraph, nestedReferenceType, outputFields, pageForOffset, sortableFields, unixSecondsToIso, } from '@lde/search/adapter';
|
|
3
3
|
import { defaultLanguageOrder, toLanguageStrings, } from './language.js';
|
|
4
4
|
import { createFacetLoader } from './facet-batch.js';
|
|
5
|
+
import { projectionFor } from './projection.js';
|
|
5
6
|
const nonNullListOf = (type) => new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(type)));
|
|
6
7
|
const scalarOutput = (scalar, field) => field.required === true ? new GraphQLNonNull(scalar) : scalar;
|
|
7
8
|
/**
|
|
@@ -283,25 +284,19 @@ export function buildGraphQLSchema(schema, options = {}) {
|
|
|
283
284
|
floatRange.name,
|
|
284
285
|
dateRange.name,
|
|
285
286
|
]);
|
|
286
|
-
/** The label key each id-plus-label reference type was registered with; no
|
|
287
|
-
* entry for a surfaced inline one, which carries fields instead of a label. */
|
|
288
|
-
const labelKeys = new Map();
|
|
289
|
-
/** The key a reference serves its resolved label under: the name its label
|
|
290
|
-
* source declares that label field with, so the reference and the type it
|
|
291
|
-
* resolves against agree on the word. An id-only reference resolves no
|
|
292
|
-
* label, so it keeps the default. `searchSchema` guarantees the source is a
|
|
293
|
-
* declared Root Type. */
|
|
294
|
-
function labelKeyOf(field) {
|
|
295
|
-
return field.labelSource === undefined
|
|
296
|
-
? DEFAULT_LABEL_FIELD
|
|
297
|
-
: labelFieldNameOf(rootTypesByName.get(field.labelSource));
|
|
298
|
-
}
|
|
299
287
|
// The declared joins, and the input types they make shareable. Each map is
|
|
300
288
|
// keyed by the SearchType `name`, so a type reached as a join target and the
|
|
301
289
|
// same type queried in its own right meet exactly one `‹Name›Where`.
|
|
302
290
|
const joins = joinGraph(schema);
|
|
303
291
|
const whereInputs = new Map();
|
|
304
292
|
const referenceFilters = new Map();
|
|
293
|
+
/** The name a reference’s emitted type is keyed under: a `lookup`’s target,
|
|
294
|
+
* an `inline`’s reference type, an `idOnly`’s declared name (which names a
|
|
295
|
+
* filter’s target, never an object type – an `idOnly` surfaces as its bare
|
|
296
|
+
* IRI). */
|
|
297
|
+
function referencedTypeName(ref) {
|
|
298
|
+
return ref.strategy === 'lookup' ? ref.target : ref.typeName;
|
|
299
|
+
}
|
|
305
300
|
/**
|
|
306
301
|
* Register the GraphQL type one reference field is served as, once per
|
|
307
302
|
* referenced shape. A **surfaced inline** reference is served as the nested
|
|
@@ -318,20 +313,18 @@ export function buildGraphQLSchema(schema, options = {}) {
|
|
|
318
313
|
// as the IRI itself rather than as an object – there is no type to
|
|
319
314
|
// register, and no name needed to register one under.
|
|
320
315
|
field.ref.strategy === 'idOnly' ||
|
|
321
|
-
field.ref
|
|
316
|
+
referencedTypeName(field.ref) === undefined) {
|
|
322
317
|
return;
|
|
323
318
|
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
}
|
|
319
|
+
// Guaranteed by the guard above: idOnly is out, and the other two strategies
|
|
320
|
+
// each name their referent.
|
|
321
|
+
const typeName = referencedTypeName(field.ref);
|
|
322
|
+
if (referenceTypes.has(typeName)) {
|
|
323
|
+
// Fields sharing a referent share one emitted type, and cannot disagree
|
|
324
|
+
// about it: a lookup's fields come from the target that names the type,
|
|
325
|
+
// an inline reference's from the Reference Type that does.
|
|
332
326
|
return;
|
|
333
327
|
}
|
|
334
|
-
const { typeName } = field.ref;
|
|
335
328
|
const graphQLName = rootTypeNames.has(typeName)
|
|
336
329
|
? `${typeName}Reference`
|
|
337
330
|
: typeName;
|
|
@@ -339,35 +332,34 @@ export function buildGraphQLSchema(schema, options = {}) {
|
|
|
339
332
|
throw new Error(`Reference type “${typeName}” (field “${field.name}” of “${owner.name}”) would be served as “${graphQLName}”, which collides with another type name; rename one.`);
|
|
340
333
|
}
|
|
341
334
|
takenTypeNames.add(graphQLName);
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
335
|
+
// What the emitted type carries, by one rule read off two declarations: a
|
|
336
|
+
// surfaced inline reference nests its declared Reference Type, a lookup
|
|
337
|
+
// carries its target root type's own output fields. Always resolvable –
|
|
338
|
+
// searchSchema rejects a lookup whose target it cannot find, and an inline
|
|
339
|
+
// reference that resolves to no Reference Type.
|
|
340
|
+
const nested = (field.ref.strategy === 'lookup'
|
|
341
|
+
? rootTypesByName.get(field.ref.target)
|
|
342
|
+
: nestedReferenceType(schema, field));
|
|
346
343
|
referenceTypes.set(typeName, new GraphQLObjectType({
|
|
347
344
|
name: graphQLName,
|
|
348
345
|
// A thunk, so a Reference Type nesting another one resolves whatever
|
|
349
346
|
// the registration order is (the graph is acyclic by searchSchema).
|
|
350
|
-
fields: () =>
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
// Nullable, and load-bearing: a referent needs no identity, so
|
|
360
|
-
// a blank-node one nests exactly like a named one, minus this.
|
|
361
|
-
id: { type: iriScalar },
|
|
362
|
-
...Object.fromEntries(outputFields(nested).map((nestedField) => [
|
|
363
|
-
nestedField.name,
|
|
364
|
-
outputFieldConfig(nestedField),
|
|
365
|
-
])),
|
|
347
|
+
fields: () => ({
|
|
348
|
+
// A lookup resolves a document by IRI, so its `id` is always there.
|
|
349
|
+
// An inline referent’s is nullable, and load-bearing: a referent
|
|
350
|
+
// needs no identity, so a blank-node one nests exactly like a named
|
|
351
|
+
// one, minus this.
|
|
352
|
+
id: {
|
|
353
|
+
type: field.ref?.strategy === 'lookup'
|
|
354
|
+
? new GraphQLNonNull(iriScalar)
|
|
355
|
+
: iriScalar,
|
|
366
356
|
},
|
|
357
|
+
...Object.fromEntries(outputFields(nested).map((nestedField) => [
|
|
358
|
+
nestedField.name,
|
|
359
|
+
outputFieldConfig(nestedField),
|
|
360
|
+
])),
|
|
361
|
+
}),
|
|
367
362
|
}));
|
|
368
|
-
if (nested === undefined) {
|
|
369
|
-
return;
|
|
370
|
-
}
|
|
371
363
|
// A nested reference type’s own surfaced references are types too; register
|
|
372
364
|
// them now, so every type exists before the thunks above are called.
|
|
373
365
|
for (const nestedField of outputFields(nested)) {
|
|
@@ -421,7 +413,9 @@ export function buildGraphQLSchema(schema, options = {}) {
|
|
|
421
413
|
resolve: (source) => iriOf(source[field.name]) ?? null,
|
|
422
414
|
};
|
|
423
415
|
}
|
|
424
|
-
const referenceType = referenceTypes.get(field.ref
|
|
416
|
+
const referenceType = referenceTypes.get((field.ref === undefined
|
|
417
|
+
? undefined
|
|
418
|
+
: referencedTypeName(field.ref)) ?? '');
|
|
425
419
|
return field.array === true
|
|
426
420
|
? {
|
|
427
421
|
type: nonNullListOf(referenceType),
|
|
@@ -472,13 +466,15 @@ export function buildGraphQLSchema(schema, options = {}) {
|
|
|
472
466
|
* names no target), a `keyword` on literals. */
|
|
473
467
|
function plainWhereFieldType(field) {
|
|
474
468
|
switch (filterOperatorFor(field.kind)) {
|
|
475
|
-
case 'in':
|
|
469
|
+
case 'in': {
|
|
476
470
|
if (field.kind !== 'reference') {
|
|
477
471
|
return keywordFilter;
|
|
478
472
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
473
|
+
// A lookup keys on its `target`, an idOnly/inline on its `typeName`:
|
|
474
|
+
// one reading, so a filter is typed by whatever names the referent.
|
|
475
|
+
const target = field.ref === undefined ? undefined : referencedTypeName(field.ref);
|
|
476
|
+
return target === undefined ? iriFilter : targetFilter(target);
|
|
477
|
+
}
|
|
482
478
|
case 'range':
|
|
483
479
|
return field.kind === 'integer'
|
|
484
480
|
? intRange
|
|
@@ -691,14 +687,20 @@ export function buildGraphQLSchema(schema, options = {}) {
|
|
|
691
687
|
description: `Results per page, between 0 and ${maxPerPage}. Use 0 for a facet-only query: no results are fetched, and the facet counts still come back.`,
|
|
692
688
|
},
|
|
693
689
|
},
|
|
694
|
-
resolve: async (_source, args, context) => {
|
|
690
|
+
resolve: async (_source, args, context, info) => {
|
|
695
691
|
const built = argsToQuery(args, context, searchType, maxPerPage, joins);
|
|
692
|
+
// What the client selected decides what each lookup fetches, so the
|
|
693
|
+
// engine carries the referent fields this query asked for and no more.
|
|
694
|
+
const resolve = projectionFor(info, searchType, (target) => rootTypesByName.get(target));
|
|
696
695
|
const finalQuery = typeOptions?.queryDefaults
|
|
697
696
|
? typeOptions.queryDefaults(built, context)
|
|
698
697
|
: built;
|
|
699
698
|
// Items + total only; facets are resolved lazily per selected key.
|
|
700
699
|
const result = await context.engine.search(searchType, {
|
|
701
700
|
...finalQuery,
|
|
701
|
+
// A `queryDefaults` policy may add its own projection; the selection
|
|
702
|
+
// set is what the caller actually asked for, so it wins.
|
|
703
|
+
resolve: resolve ?? finalQuery.resolve,
|
|
702
704
|
facets: [],
|
|
703
705
|
});
|
|
704
706
|
return {
|
package/dist/handler.d.ts
CHANGED
|
@@ -6,7 +6,9 @@ import { type BuildGraphQLSchemaOptions, type SearchContext } from './build-sche
|
|
|
6
6
|
* A framework-agnostic request handler: SvelteKit, Fastify (via a
|
|
7
7
|
* `Request`/`Response` bridge), Hono and plain `node:http` (via
|
|
8
8
|
* `@whatwg-node/server`, which graphql-yoga uses internally) all mount it
|
|
9
|
-
* directly.
|
|
9
|
+
* directly. On a runtime that has its own Fetch classes, the returned
|
|
10
|
+
* `Response` is an instance of that runtime’s global, so a host that checks
|
|
11
|
+
* `instanceof Response` accepts it as is.
|
|
10
12
|
*/
|
|
11
13
|
export type SearchGraphQLHandler = (request: Request) => Promise<Response>;
|
|
12
14
|
/**
|
package/dist/handler.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../src/handler.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,KAAK,aAAa,EAAE,MAAM,SAAS,CAAC;AAC1D,OAAO,EAEL,KAAK,WAAW,EAChB,KAAK,eAAe,EACrB,MAAM,cAAc,CAAC;AAKtB,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC9D,OAAO,EAEL,KAAK,yBAAyB,EAC9B,KAAK,aAAa,EACnB,MAAM,mBAAmB,CAAC;AAE3B
|
|
1
|
+
{"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../src/handler.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,KAAK,aAAa,EAAE,MAAM,SAAS,CAAC;AAC1D,OAAO,EAEL,KAAK,WAAW,EAChB,KAAK,eAAe,EACrB,MAAM,cAAc,CAAC;AAKtB,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC9D,OAAO,EAEL,KAAK,yBAAyB,EAC9B,KAAK,aAAa,EACnB,MAAM,mBAAmB,CAAC;AAE3B;;;;;;;GAOG;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AAE3E;;;;GAIG;AACH,MAAM,MAAM,kBAAkB,GAAG,CAC/B,OAAO,CAAC,EAAE,eAAe,KACtB,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;AAElC,MAAM,WAAW,2BAA2B;IAC1C;;;OAGG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,YAAY,CAAC;IACrC;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC;IAChC,wDAAwD;IACxD,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC;IAC9B,iFAAiF;IACjF,QAAQ,CAAC,aAAa,CAAC,EAAE,yBAAyB,CAAC;IACnD,4DAA4D;IAC5D,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;IAClC;;;;;OAKG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,OAAO,CAAC;IAC9B,oEAAoE;IACpE,QAAQ,CAAC,gBAAgB,CAAC,EAAE,kBAAkB,CAAC;IAC/C;;;;OAIG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC;IACtC;;;OAGG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,wEAAwE;IACxE,QAAQ,CAAC,YAAY,CAAC,EAAE,aAAa,CAAC,cAAc,CAAC,CAAC;CACvD;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,2BAA2B,GACnC,oBAAoB,CAsDtB"}
|
package/dist/handler.js
CHANGED
|
@@ -21,6 +21,17 @@ export function createSearchGraphQLHandler(options) {
|
|
|
21
21
|
const yoga = createYoga({
|
|
22
22
|
schema,
|
|
23
23
|
graphqlEndpoint,
|
|
24
|
+
// Build responses with the runtime’s own Fetch classes, so the handler’s
|
|
25
|
+
// `Response` is the class a host framework checks against. graphql-yoga
|
|
26
|
+
// otherwise uses `@whatwg-node/fetch`, a ponyfill: spec-conformant, but a
|
|
27
|
+
// different class, which every host that checks `instanceof Response`
|
|
28
|
+
// nominally – SvelteKit, Hono, Next – rejects. yoga ignores the keys a
|
|
29
|
+
// runtime leaves `undefined`, so one without them keeps the ponyfill.
|
|
30
|
+
fetchAPI: {
|
|
31
|
+
Request: globalThis.Request,
|
|
32
|
+
Response: globalThis.Response,
|
|
33
|
+
ReadableStream: globalThis.ReadableStream,
|
|
34
|
+
},
|
|
24
35
|
landingPage: false,
|
|
25
36
|
graphiql: options.playground !== false,
|
|
26
37
|
renderGraphiQL: options.renderPlayground ?? renderGraphiQL,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type GraphQLResolveInfo } from 'graphql';
|
|
2
|
+
import { type ReferenceProjection, type SearchType } from '@lde/search';
|
|
3
|
+
/** How a `lookup` field’s `target` resolves to the Root Type it reads from –
|
|
4
|
+
* supplied by the caller, which holds the schema. */
|
|
5
|
+
export type TargetResolver = (target: string) => SearchType | undefined;
|
|
6
|
+
/**
|
|
7
|
+
* Build the {@link ReferenceProjection} a query’s **selection set** asks for, so
|
|
8
|
+
* the engine fetches each referent’s fields at the depth the client selected and
|
|
9
|
+
* no further. Without it a lookup would either fetch every field of every target
|
|
10
|
+
* at every depth – unbounded, since targets reference one another – or serve
|
|
11
|
+
* fields nothing filled.
|
|
12
|
+
*
|
|
13
|
+
* Only `lookup` references appear: an `idOnly` reference surfaces as its bare
|
|
14
|
+
* IRI, and an `inline` one is already carried by the hit. A selection naming
|
|
15
|
+
* nothing but `id` asks for no `fields`; the id is on the referring document
|
|
16
|
+
* already, so it costs no round-trip.
|
|
17
|
+
*
|
|
18
|
+
* Fragments are followed; `@skip`/`@include` are deliberately **not** evaluated,
|
|
19
|
+
* so a conditionally-skipped selection is fetched and discarded rather than
|
|
20
|
+
* omitted and then missing. Over-fetching a field is recoverable; a null where
|
|
21
|
+
* the client asked for a value is not.
|
|
22
|
+
*/
|
|
23
|
+
export declare function projectionFor(info: Pick<GraphQLResolveInfo, 'fieldNodes' | 'fragments'>, searchType: SearchType, targetOf: TargetResolver): ReferenceProjection | undefined;
|
|
24
|
+
//# sourceMappingURL=projection.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"projection.d.ts","sourceRoot":"","sources":["../src/projection.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,kBAAkB,EAExB,MAAM,SAAS,CAAC;AACjB,OAAO,EAAE,KAAK,mBAAmB,EAAE,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAGxE;sDACsD;AACtD,MAAM,MAAM,cAAc,GAAG,CAAC,MAAM,EAAE,MAAM,KAAK,UAAU,GAAG,SAAS,CAAC;AAIxE;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,IAAI,CAAC,kBAAkB,EAAE,YAAY,GAAG,WAAW,CAAC,EAC1D,UAAU,EAAE,UAAU,EACtB,QAAQ,EAAE,cAAc,GACvB,mBAAmB,GAAG,SAAS,CAajC"}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { Kind, } from 'graphql';
|
|
2
|
+
import { fieldNamed } from '@lde/search/adapter';
|
|
3
|
+
/**
|
|
4
|
+
* Build the {@link ReferenceProjection} a query’s **selection set** asks for, so
|
|
5
|
+
* the engine fetches each referent’s fields at the depth the client selected and
|
|
6
|
+
* no further. Without it a lookup would either fetch every field of every target
|
|
7
|
+
* at every depth – unbounded, since targets reference one another – or serve
|
|
8
|
+
* fields nothing filled.
|
|
9
|
+
*
|
|
10
|
+
* Only `lookup` references appear: an `idOnly` reference surfaces as its bare
|
|
11
|
+
* IRI, and an `inline` one is already carried by the hit. A selection naming
|
|
12
|
+
* nothing but `id` asks for no `fields`; the id is on the referring document
|
|
13
|
+
* already, so it costs no round-trip.
|
|
14
|
+
*
|
|
15
|
+
* Fragments are followed; `@skip`/`@include` are deliberately **not** evaluated,
|
|
16
|
+
* so a conditionally-skipped selection is fetched and discarded rather than
|
|
17
|
+
* omitted and then missing. Over-fetching a field is recoverable; a null where
|
|
18
|
+
* the client asked for a value is not.
|
|
19
|
+
*/
|
|
20
|
+
export function projectionFor(info, searchType, targetOf) {
|
|
21
|
+
// `items` carries the hits; the root field’s other selections (pagination,
|
|
22
|
+
// facets) are served without touching a referent.
|
|
23
|
+
const items = info.fieldNodes.flatMap((node) => selectionsNamed(node.selectionSet, 'items', info.fragments));
|
|
24
|
+
const projection = fromSelections(items, searchType, info.fragments, targetOf);
|
|
25
|
+
return Object.keys(projection).length === 0 ? undefined : projection;
|
|
26
|
+
}
|
|
27
|
+
/** The selection sets of every field named `name`, fragments followed. */
|
|
28
|
+
function selectionsNamed(selectionSet, name, fragments) {
|
|
29
|
+
const found = [];
|
|
30
|
+
for (const field of fieldsOf(selectionSet, fragments)) {
|
|
31
|
+
if (field.name.value === name && field.selectionSet !== undefined) {
|
|
32
|
+
found.push(field.selectionSet);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return found;
|
|
36
|
+
}
|
|
37
|
+
/** Every field selection in a set, with fragment spreads flattened in place. */
|
|
38
|
+
function fieldsOf(selectionSet, fragments) {
|
|
39
|
+
if (selectionSet === undefined) {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
return selectionSet.selections.flatMap((selection) => {
|
|
43
|
+
switch (selection.kind) {
|
|
44
|
+
case Kind.FIELD:
|
|
45
|
+
return [selection];
|
|
46
|
+
case Kind.INLINE_FRAGMENT:
|
|
47
|
+
return fieldsOf(selection.selectionSet, fragments);
|
|
48
|
+
case Kind.FRAGMENT_SPREAD: {
|
|
49
|
+
const fragment = fragments[selection.name.value];
|
|
50
|
+
return fragment === undefined
|
|
51
|
+
? []
|
|
52
|
+
: fieldsOf(fragment.selectionSet, fragments);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/** Whether a type serves this field name – what a projection may ask for. */
|
|
58
|
+
function servesField(searchType, name) {
|
|
59
|
+
return fieldNamed(searchType, name)?.output === true;
|
|
60
|
+
}
|
|
61
|
+
/** Union two projections level by level, so neither loses what it asked for. */
|
|
62
|
+
function mergeProjections(left, right) {
|
|
63
|
+
if (left === undefined) {
|
|
64
|
+
return right;
|
|
65
|
+
}
|
|
66
|
+
const merged = { ...left };
|
|
67
|
+
for (const [name, level] of Object.entries(right)) {
|
|
68
|
+
const existing = merged[name];
|
|
69
|
+
merged[name] =
|
|
70
|
+
existing === undefined
|
|
71
|
+
? level
|
|
72
|
+
: {
|
|
73
|
+
fields: [
|
|
74
|
+
...new Set([...(existing.fields ?? []), ...(level.fields ?? [])]),
|
|
75
|
+
],
|
|
76
|
+
resolve: level.resolve === undefined
|
|
77
|
+
? existing.resolve
|
|
78
|
+
: mergeProjections(existing.resolve, level.resolve),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return merged;
|
|
82
|
+
}
|
|
83
|
+
/** One level: which lookups were selected, and what each asked for. */
|
|
84
|
+
function fromSelections(selectionSets, searchType, fragments, targetOf) {
|
|
85
|
+
const projection = {};
|
|
86
|
+
for (const selectionSet of selectionSets) {
|
|
87
|
+
for (const selected of fieldsOf(selectionSet, fragments)) {
|
|
88
|
+
const field = fieldNamed(searchType, selected.name.value);
|
|
89
|
+
if (field === undefined ||
|
|
90
|
+
field.kind !== 'reference' ||
|
|
91
|
+
field.ref?.strategy !== 'lookup') {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// The target's own declaration decides what its selections mean, so the
|
|
95
|
+
// level below is read against it rather than against this type.
|
|
96
|
+
const target = targetOf(field.ref.target);
|
|
97
|
+
// Only what the target actually serves. A selection carries more than
|
|
98
|
+
// that: `id` is on the referring document already, and every GraphQL
|
|
99
|
+
// client worth the name injects `__typename` into every selection set –
|
|
100
|
+
// asking the engine for either would fail the query at the port's guard.
|
|
101
|
+
const wanted = fieldsOf(selected.selectionSet, fragments)
|
|
102
|
+
.map((node) => node.name.value)
|
|
103
|
+
.filter((name) => target === undefined ? false : servesField(target, name));
|
|
104
|
+
const entry = (projection[selected.name.value] ??= {});
|
|
105
|
+
entry.fields = [...new Set([...(entry.fields ?? []), ...wanted])];
|
|
106
|
+
if (target !== undefined && selected.selectionSet !== undefined) {
|
|
107
|
+
const below = fromSelections([selected.selectionSet], target, fragments, targetOf);
|
|
108
|
+
if (Object.keys(below).length > 0) {
|
|
109
|
+
// Merged level by level, not key by key: one lookup selected twice –
|
|
110
|
+
// two fragments each spreading it – must union what each asked for,
|
|
111
|
+
// or the second selection silently replaces the first and a field
|
|
112
|
+
// the client asked for is never fetched.
|
|
113
|
+
entry.resolve = mergeProjections(entry.resolve, below);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return projection;
|
|
119
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lde/search-api-graphql",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.1",
|
|
4
4
|
"description": "Engine- and domain-agnostic GraphQL surface for @lde/search: builds an executable GraphQLSchema from a whole SearchSchema at runtime (no codegen) – one root query field per SearchType – served by generic resolvers over any SearchEngine – and serves it as a framework-agnostic fetch handler with a self-contained playground, SDL endpoint, CORS and depth/cost limits. You supply the schema and per-type typeNames; it names neither your domain nor your engine.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"url": "git+https://github.com/ldelements/lde.git",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"@escape.tech/graphql-armor-cost-limit": "^2.4.3",
|
|
38
38
|
"@escape.tech/graphql-armor-max-depth": "^2.4.2",
|
|
39
39
|
"@graphql-yoga/render-graphiql": "^5.21.2",
|
|
40
|
-
"@lde/search": "^0.
|
|
40
|
+
"@lde/search": "^0.22.1",
|
|
41
41
|
"commander": "^15.0.0",
|
|
42
42
|
"dataloader": "^2.2.3",
|
|
43
43
|
"graphql": "^16.9.0",
|