@lde/search-api-graphql 0.23.1 → 0.24.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.
@@ -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,EAEjB,KAAK,WAAW,EAChB,KAAK,YAAY,EAElB,MAAM,aAAa,CAAC;AAqBrB,OAAO,EAGL,KAAK,aAAa,EACnB,MAAM,eAAe,CAAC;AAGvB,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,CAsxBf;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,YAAY,EACpB,OAAO,GAAE,yBAA8B,GACtC,MAAM,CAER"}
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"}
@@ -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, DEFAULT_LABEL_FIELD, facetableFields, filterableFields, filterOn, filterOperatorFor, ID_FIELD, isAbsoluteIri, OR_KEY, isRangeFacet, joinGraph, labelFieldNameOf, nestedReferenceType, outputFields, pageForOffset, sortableFields, unixSecondsToIso, } from '@lde/search/adapter';
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.typeName === undefined) {
316
+ referencedTypeName(field.ref) === undefined) {
322
317
  return;
323
318
  }
324
- if (referenceTypes.has(field.ref.typeName)) {
325
- // Fields sharing a `ref.typeName` share one emitted type, so they must
326
- // agree on the word it serves its label under – otherwise which one wins
327
- // would come down to declaration order.
328
- const registered = labelKeys.get(field.ref.typeName);
329
- if (registered !== undefined && registered !== labelKeyOf(field)) {
330
- throw new Error(`Reference “${owner.name}.${field.name}” serves its label as “${labelKeyOf(field)}”, but “${field.ref.typeName}” is already served with “${registered}”; fields sharing a reference type must resolve labels from sources that agree on their labelField.`);
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
- const nested = nestedReferenceType(schema, field);
343
- if (nested === undefined) {
344
- labelKeys.set(typeName, labelKeyOf(field));
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: () => nested === undefined
351
- ? {
352
- id: { type: new GraphQLNonNull(iriScalar) },
353
- // The same word the label source declares its label field
354
- // under (`label` by default): one resolved label, one name for
355
- // it wherever it surfaces.
356
- [labelKeyOf(field)]: labelList((source) => source.label),
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?.typeName ?? '');
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
- return field.ref?.typeName === undefined
480
- ? iriFilter
481
- : targetFilter(field.ref.typeName);
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
  /**
@@ -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;;;;;GAKG;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,CA2CtB"}
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.23.1",
3
+ "version": "0.24.0",
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.21.1",
40
+ "@lde/search": "^0.22.0",
41
41
  "commander": "^15.0.0",
42
42
  "dataloader": "^2.2.3",
43
43
  "graphql": "^16.9.0",