@lde/search-api-graphql 0.25.1 → 0.26.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,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
+ {"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,EAIjB,KAAK,WAAW,EAChB,KAAK,YAAY,EAElB,MAAM,aAAa,CAAC;AAsBrB,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,CA85Bf;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,YAAY,EACpB,OAAO,GAAE,yBAA8B,GACtC,MAAM,CAER"}
@@ -1,5 +1,5 @@
1
1
  import { GraphQLBoolean, GraphQLEnumType, GraphQLError, GraphQLFloat, GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLNonNull, GraphQLObjectType, GraphQLScalarType, GraphQLSchema, GraphQLString, printSchema, } from 'graphql';
2
- import { AND_KEY, facetableFields, filterableFields, filterOn, filterOperatorFor, ID_FIELD, isAbsoluteIri, OR_KEY, isRangeFacet, joinGraph, nestedReferenceType, outputFields, pageForOffset, sortableFields, unixSecondsToIso, } from '@lde/search/adapter';
2
+ import { AND_KEY, facetableFields, filterableFields, labelTargetNameOf, localLookupTypeOf, referenceFields, 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
5
  import { projectionFor } from './projection.js';
@@ -262,6 +262,45 @@ export function buildGraphQLSchema(schema, options = {}) {
262
262
  // collide: searchSchema resolves its typeName to a declared Reference Type
263
263
  // and rejects duplicate names schema-wide.
264
264
  const referenceTypes = new Map();
265
+ /**
266
+ * The lookup targets whose emitted type must carry a NULLABLE `id`: those
267
+ * some field reaches through a {@link ReferenceStrategy.local local} lookup,
268
+ * which stores what a document says about an endpoint whether or not the
269
+ * endpoint is identified.
270
+ *
271
+ * Computed over the whole schema before any type is registered, because one
272
+ * emitted type is SHARED by every field pointing at its target. Decided per
273
+ * field instead, the answer would depend on which field happened to register
274
+ * first: a plain `publisher` lookup registering before a `local` creator
275
+ * would make `id` non-null and then fail the response for every unidentified
276
+ * creator – exactly the endpoint `local` exists to serve.
277
+ *
278
+ * Nullability is therefore a property of the type, and takes the weakest
279
+ * guarantee any of its users can keep.
280
+ */
281
+ const nullableIdTargets = new Set();
282
+ {
283
+ const walked = new Set();
284
+ const collect = (searchType) => {
285
+ if (walked.has(searchType.name)) {
286
+ return;
287
+ }
288
+ walked.add(searchType.name);
289
+ for (const field of referenceFields(searchType)) {
290
+ if (field.ref?.strategy === 'lookup' && field.ref.local === true) {
291
+ nullableIdTargets.add(field.ref.target);
292
+ }
293
+ const nested = nestedReferenceType(schema, field) ??
294
+ localLookupTypeOf(field, schema);
295
+ if (nested !== undefined) {
296
+ collect(nested);
297
+ }
298
+ }
299
+ };
300
+ for (const rootType of schema.values()) {
301
+ collect(rootType);
302
+ }
303
+ }
265
304
  // Seeded with the shared types every schema carries, not just the root type
266
305
  // names: a declaration is free to name a type `IRI` or `ValueBucket`, and
267
306
  // without this it would pass both collision checks and fail at
@@ -289,6 +328,7 @@ export function buildGraphQLSchema(schema, options = {}) {
289
328
  // same type queried in its own right meet exactly one `‹Name›Where`.
290
329
  const joins = joinGraph(schema);
291
330
  const whereInputs = new Map();
331
+ const nestedFilters = new Map();
292
332
  const referenceFilters = new Map();
293
333
  /** The name a reference’s emitted type is keyed under: a `lookup`’s target,
294
334
  * an `inline`’s reference type, an `idOnly`’s declared name (which names a
@@ -321,8 +361,11 @@ export function buildGraphQLSchema(schema, options = {}) {
321
361
  const typeName = referencedTypeName(field.ref);
322
362
  if (referenceTypes.has(typeName)) {
323
363
  // 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.
364
+ // about its FIELDS: a lookup's come from the target that names the type,
365
+ // an inline reference's from the Reference Type that does. They can
366
+ // disagree about `id` nullability, which is why that is settled over the
367
+ // whole schema up front ({@link nullableIdTargets}) rather than read off
368
+ // whichever field registers the type first.
326
369
  return;
327
370
  }
328
371
  const graphQLName = rootTypeNames.has(typeName)
@@ -349,14 +392,30 @@ export function buildGraphQLSchema(schema, options = {}) {
349
392
  // An inline referent’s is nullable, and load-bearing: a referent
350
393
  // needs no identity, so a blank-node one nests exactly like a named
351
394
  // one, minus this.
395
+ //
396
+ // A `local` lookup is the exception among lookups, for that same
397
+ // reason: it stores what the referring document says about the
398
+ // endpoint whether or not the endpoint is identified, so an entry
399
+ // may legitimately arrive without one. Declared non-null, every such
400
+ // entry would fail the response instead of serving what it has.
352
401
  id: {
353
- type: field.ref?.strategy === 'lookup'
402
+ type: field.ref?.strategy === 'lookup' &&
403
+ !nullableIdTargets.has(typeName)
354
404
  ? new GraphQLNonNull(iriScalar)
355
405
  : iriScalar,
356
406
  },
357
407
  ...Object.fromEntries(outputFields(nested).map((nestedField) => [
358
408
  nestedField.name,
359
- outputFieldConfig(nestedField),
409
+ // `required` is relaxed for the same reason `id` is above: a
410
+ // `local` lookup carries what a document SAYS about an endpoint,
411
+ // and a document that named one inline states no more than it
412
+ // states. A field the target promises is non-null on the
413
+ // target's own document, not on a referrer's account of it, so
414
+ // holding this one to it fails the whole response for exactly
415
+ // the endpoint `local` exists to serve.
416
+ outputFieldConfig(nullableIdTargets.has(typeName)
417
+ ? { ...nestedField, required: false }
418
+ : nestedField),
360
419
  ])),
361
420
  }),
362
421
  }));
@@ -450,6 +509,14 @@ export function buildGraphQLSchema(schema, options = {}) {
450
509
  }
451
510
  }
452
511
  function whereFieldType(field, owner) {
512
+ // An inline reference is filterable in two ways at once, exactly as a
513
+ // joinable one is: by the ids its entries hold (its identity companion),
514
+ // and by a condition on an entry. Same input shape, because it is the same
515
+ // question one hop out – only what the hop costs differs.
516
+ const nested = nestedReferenceType(schema, field);
517
+ if (nested !== undefined) {
518
+ return nestedFilterFor(nested, field);
519
+ }
453
520
  // A joinable reference is filterable in two ways at once – by the ids it
454
521
  // holds, and by a condition on the referent – so it earns a richer input
455
522
  // than the plain membership every other reference gets. Making the
@@ -461,6 +528,58 @@ export function buildGraphQLSchema(schema, options = {}) {
461
528
  ? referenceFilterFor(target)
462
529
  : plainWhereFieldType(field);
463
530
  }
531
+ /**
532
+ * The filter input an inline reference takes: the ids its entries hold, or a
533
+ * condition on one entry. `@oneOf`, so a criterion stays an atom.
534
+ *
535
+ * The `in` arm is typed by the target the reference’s
536
+ * {@link ReferenceStrategy.identity identity companion} names, so a bucket a
537
+ * consumer clicks feeds straight back in – the same round trip an ordinary
538
+ * facetable reference already guarantees. A reference declaring no identity
539
+ * has no ids to filter, so it offers the `where` arm alone.
540
+ */
541
+ function nestedFilterFor(referenceType, field) {
542
+ const existing = nestedFilters.get(referenceType.name);
543
+ if (existing !== undefined) {
544
+ return existing;
545
+ }
546
+ const identityTarget = labelTargetNameOf(field, schema);
547
+ const nestedWhere = nestedWhereInputFor(referenceType);
548
+ const created = new GraphQLInputObjectType({
549
+ name: `${referenceType.name}Filter`,
550
+ description: `A condition on ${referenceType.name}: the ids its entries reference, or a condition on one entry.`,
551
+ isOneOf: true,
552
+ fields: () => ({
553
+ ...(identityTarget !== undefined && {
554
+ in: { type: new GraphQLList(new GraphQLNonNull(iriScalar)) },
555
+ }),
556
+ where: { type: nestedWhere },
557
+ }),
558
+ });
559
+ nestedFilters.set(referenceType.name, created);
560
+ return created;
561
+ }
562
+ /**
563
+ * The `where` input of a Reference Type – its filterable fields and nothing
564
+ * else. Deliberately without the `id` key every Root Type's `where` carries:
565
+ * an entry is read, not addressed, so it has no document key to filter on
566
+ * ({@link validateQuery} refuses one).
567
+ */
568
+ function nestedWhereInputFor(referenceType) {
569
+ // Memoized by its only caller, which is memoized itself – so this is built
570
+ // once per Reference Type however many fields nest it.
571
+ return new GraphQLInputObjectType({
572
+ name: `${referenceType.name}Where`,
573
+ description: 'Sibling keys are combined with AND, and all of them must hold of the SAME entry.',
574
+ fields: () => Object.fromEntries(filterableFields(referenceType).map((nestedField) => [
575
+ nestedField.name,
576
+ {
577
+ type: whereFieldType(nestedField, referenceType),
578
+ description: nestedField.description,
579
+ },
580
+ ])),
581
+ });
582
+ }
464
583
  /** The filter input one field accepts, fixed by what the field keys on: a
465
584
  * `reference` keys on identity (its target’s filter, or `IRIFilter` when it
466
585
  * names no target), a `keyword` on literals. */
@@ -688,10 +807,10 @@ export function buildGraphQLSchema(schema, options = {}) {
688
807
  },
689
808
  },
690
809
  resolve: async (_source, args, context, info) => {
691
- const built = argsToQuery(args, context, searchType, maxPerPage, joins);
810
+ const built = argsToQuery(args, context, searchType, maxPerPage, joins, schema);
692
811
  // What the client selected decides what each lookup fetches, so the
693
812
  // engine carries the referent fields this query asked for and no more.
694
- const resolve = projectionFor(info, searchType, (target) => rootTypesByName.get(target));
813
+ const resolve = projectionFor(info, searchType, schema);
695
814
  const finalQuery = typeOptions?.queryDefaults
696
815
  ? typeOptions.queryDefaults(built, context)
697
816
  : built;
@@ -790,7 +909,7 @@ export function printGraphQLSchema(schema, options = {}) {
790
909
  /** Pure args → {@link SearchQuery} mapping. Rejects out-of-bounds paging
791
910
  * (`page < 1`, `perPage` outside `1..maxPerPage`) with a clear error – a
792
911
  * negative offset or an unbounded page size must not reach the engine. */
793
- function argsToQuery(args, context, searchType, maxPerPage, joins) {
912
+ function argsToQuery(args, context, searchType, maxPerPage, joins, schema) {
794
913
  const perPage = args.perPage ?? 20;
795
914
  const page = args.page ?? 1;
796
915
  if (page < 1) {
@@ -802,7 +921,7 @@ function argsToQuery(args, context, searchType, maxPerPage, joins) {
802
921
  }
803
922
  return {
804
923
  text: args.query,
805
- where: whereToFilters(args.where, searchType, joins),
924
+ where: whereToFilters(args.where, searchType, joins, schema),
806
925
  orderBy: args.orderBy
807
926
  ? [{ field: args.orderBy.field, direction: args.orderBy.direction }]
808
927
  : [],
@@ -822,7 +941,7 @@ function argsToQuery(args, context, searchType, maxPerPage, joins) {
822
941
  * result is still flat: the nesting lives in each criterion’s `on` path, never
823
942
  * in the clause structure, so skip-own-filter (ADR 5) still scans one level.
824
943
  */
825
- function whereToFilters(where, searchType, joins, on = []) {
944
+ function whereToFilters(where, searchType, joins, schema, on = []) {
826
945
  // `== null` deliberately: an explicit `where: null` is as absent as an omitted
827
946
  // one, and reading keys off it would throw.
828
947
  if (where == null) {
@@ -833,9 +952,22 @@ function whereToFilters(where, searchType, joins, on = []) {
833
952
  // while `or` becomes a SINGLE filter carrying every alternative. That is the
834
953
  // whole AND/OR mapping: which bucket a criterion lands in, never a combinator
835
954
  // inferred from how deeply it is nested.
836
- for (const entry of keyEntriesOf(where, searchType, joins)) {
955
+ for (const entry of keyEntriesOf(where, searchType, joins, schema)) {
837
956
  if ('nested' in entry) {
838
- filters.push(...whereToFilters(entry.nested, entry.target, joins, [
957
+ // A NESTING hop welds: every condition inside one edge's `where` must
958
+ // hold of the SAME entry, which is the whole point of indexing an edge.
959
+ // Written as separate criteria they would be satisfiable by different
960
+ // entries – the parallel-array answer, silently wider than what was
961
+ // asked. A JOIN hop keeps recursing: those conditions are about another
962
+ // document, where there is nothing to weld them to.
963
+ if (entry.target.class === undefined) {
964
+ const welded = weldedCriterionOf(entry, joins, schema, on);
965
+ if (welded !== undefined) {
966
+ filters.push(filterOn(welded));
967
+ }
968
+ continue;
969
+ }
970
+ filters.push(...whereToFilters(entry.nested, entry.target, joins, schema, [
839
971
  ...on,
840
972
  entry.name,
841
973
  ]));
@@ -843,7 +975,7 @@ function whereToFilters(where, searchType, joins, on = []) {
843
975
  }
844
976
  filters.push(filterOn(withPath(entry.criterion, on)));
845
977
  }
846
- const alternatives = (where[OR_KEY] ?? []).flatMap((criterion) => criteriaOf(criterion, searchType, joins, on));
978
+ const alternatives = (where[OR_KEY] ?? []).flatMap((criterion) => criteriaOf(criterion, searchType, joins, schema, on));
847
979
  if (alternatives.length > 0) {
848
980
  filters.push({ or: alternatives });
849
981
  }
@@ -852,10 +984,30 @@ function whereToFilters(where, searchType, joins, on = []) {
852
984
  // result flat: nested conjunctions collapse into this one list, and an `and`
853
985
  // can never sit inside an `or` (which holds only criteria).
854
986
  for (const nested of where[AND_KEY] ?? []) {
855
- filters.push(...whereToFilters(nested, searchType, joins, on));
987
+ filters.push(...whereToFilters(nested, searchType, joins, schema, on));
856
988
  }
857
989
  return filters;
858
990
  }
991
+ /**
992
+ * The one criterion an edge's `where` compiles to: its conditions welded to a
993
+ * single entry.
994
+ *
995
+ * Every key of that `where` becomes a condition. It carries no `or`/`and` of
996
+ * its own – `‹Edge›Where` declares neither, so the surface rejects them before
997
+ * a resolver runs: a disjunction inside a weld is not a weld, and the flat IR
998
+ * has nowhere to put one. `undefined` when the `where` states nothing, which
999
+ * constrains nothing and so contributes no clause.
1000
+ */
1001
+ function weldedCriterionOf(entry, joins, schema, on) {
1002
+ // Every entry here is a plain criterion, and can only be: a hop needs either
1003
+ // a joinable reference (`joins.resolve` answers nothing from a Reference
1004
+ // Type) or a filterable inline one (which needs an `identity`, refused inside
1005
+ // a Reference Type). So there is nothing to skip.
1006
+ const conditions = keyEntriesOf(entry.nested, entry.target, joins, schema).map((nested) => nested.criterion);
1007
+ return conditions.length === 0
1008
+ ? undefined
1009
+ : withPath({ field: entry.name, entry: conditions }, on);
1010
+ }
859
1011
  /** A criterion with its join path attached, or unchanged at the top level –
860
1012
  * so an ordinary query’s IR is byte-for-byte what it was. */
861
1013
  function withPath(criterion, on) {
@@ -872,15 +1024,27 @@ function withPath(criterion, on) {
872
1024
  * inside the `or` this feeds (ADR 18’s flat IR has nowhere to put it), so it is
873
1025
  * rejected with the rewrite that expresses it instead.
874
1026
  */
875
- function criteriaOf(keyed, searchType, joins, on = []) {
1027
+ function criteriaOf(keyed, searchType, joins, schema, on = []) {
876
1028
  const criteria = [];
877
- for (const entry of keyEntriesOf(keyed, searchType, joins)) {
1029
+ for (const entry of keyEntriesOf(keyed, searchType, joins, schema)) {
878
1030
  if (!('nested' in entry)) {
879
1031
  criteria.push(withPath(entry.criterion, on));
880
1032
  continue;
881
1033
  }
1034
+ // A NESTING hop welds, in an `or` alternative exactly as anywhere else –
1035
+ // and it is the one place a weld belongs most naturally, since a welded
1036
+ // criterion IS an atom. Compiled as separate joined criteria it would be
1037
+ // rejected below as “a conjunction inside an or”, which is precisely the
1038
+ // thing welding avoids.
1039
+ if (entry.target.class === undefined) {
1040
+ const welded = weldedCriterionOf(entry, joins, schema, on);
1041
+ if (welded !== undefined) {
1042
+ criteria.push(welded);
1043
+ }
1044
+ continue;
1045
+ }
882
1046
  const path = [...on, entry.name];
883
- const nested = whereToFilters(entry.nested, entry.target, joins, path);
1047
+ const nested = whereToFilters(entry.nested, entry.target, joins, schema, path);
884
1048
  const [only] = nested;
885
1049
  // An `or` alternative must be exactly one criterion. Empty and
886
1050
  // more-than-one are opposite mistakes, so they are named separately rather
@@ -905,7 +1069,7 @@ function criteriaOf(keyed, searchType, joins, on = []) {
905
1069
  * reference gets – while `where` states a condition on the referent and yields
906
1070
  * a nested entry the caller walks into.
907
1071
  */
908
- function keyEntriesOf(keyed, searchType, joins) {
1072
+ function keyEntriesOf(keyed, searchType, joins, schema) {
909
1073
  const entries = [];
910
1074
  const id = keyed[ID_FIELD];
911
1075
  if (id !== undefined && id !== null) {
@@ -916,7 +1080,12 @@ function keyEntriesOf(keyed, searchType, joins) {
916
1080
  if (value === undefined || value === null) {
917
1081
  continue;
918
1082
  }
919
- const target = joins.resolve(searchType, [field.name]);
1083
+ // Both hop kinds take the same `@oneOf` input – `in` (the ids this field
1084
+ // holds) or `where` (a condition one hop out) – so both are read here, and
1085
+ // only what they resolve to differs: another collection, or this
1086
+ // document's own entries.
1087
+ const target = nestedReferenceType(schema, field) ??
1088
+ joins.resolve(searchType, [field.name]);
920
1089
  if (target !== undefined) {
921
1090
  const nested = value
922
1091
  .where;
@@ -1,8 +1,5 @@
1
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;
2
+ import { type ReferenceProjection, type SearchSchema, type SearchType } from '@lde/search';
6
3
  /**
7
4
  * Build the {@link ReferenceProjection} a query’s **selection set** asks for, so
8
5
  * the engine fetches each referent’s fields at the depth the client selected and
@@ -20,5 +17,5 @@ export type TargetResolver = (target: string) => SearchType | undefined;
20
17
  * omitted and then missing. Over-fetching a field is recoverable; a null where
21
18
  * the client asked for a value is not.
22
19
  */
23
- export declare function projectionFor(info: Pick<GraphQLResolveInfo, 'fieldNodes' | 'fragments'>, searchType: SearchType, targetOf: TargetResolver): ReferenceProjection | undefined;
20
+ export declare function projectionFor(info: Pick<GraphQLResolveInfo, 'fieldNodes' | 'fragments'>, searchType: SearchType, schema: SearchSchema): ReferenceProjection | undefined;
24
21
  //# sourceMappingURL=projection.d.ts.map
@@ -1 +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"}
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,EACL,KAAK,mBAAmB,EACxB,KAAK,YAAY,EACjB,KAAK,UAAU,EAChB,MAAM,aAAa,CAAC;AASrB;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,IAAI,CAAC,kBAAkB,EAAE,YAAY,GAAG,WAAW,CAAC,EAC1D,UAAU,EAAE,UAAU,EACtB,MAAM,EAAE,YAAY,GACnB,mBAAmB,GAAG,SAAS,CAQjC"}
@@ -1,5 +1,5 @@
1
1
  import { Kind, } from 'graphql';
2
- import { fieldNamed } from '@lde/search/adapter';
2
+ import { fieldNamed, nestedReferenceType, rootTypeNamed, } from '@lde/search/adapter';
3
3
  /**
4
4
  * Build the {@link ReferenceProjection} a query’s **selection set** asks for, so
5
5
  * the engine fetches each referent’s fields at the depth the client selected and
@@ -17,11 +17,11 @@ import { fieldNamed } from '@lde/search/adapter';
17
17
  * omitted and then missing. Over-fetching a field is recoverable; a null where
18
18
  * the client asked for a value is not.
19
19
  */
20
- export function projectionFor(info, searchType, targetOf) {
20
+ export function projectionFor(info, searchType, schema) {
21
21
  // `items` carries the hits; the root field’s other selections (pagination,
22
22
  // facets) are served without touching a referent.
23
23
  const items = info.fieldNodes.flatMap((node) => selectionsNamed(node.selectionSet, 'items', info.fragments));
24
- const projection = fromSelections(items, searchType, info.fragments, targetOf);
24
+ const projection = fromSelections(items, searchType, info.fragments, schema);
25
25
  return Object.keys(projection).length === 0 ? undefined : projection;
26
26
  }
27
27
  /** The selection sets of every field named `name`, fragments followed. */
@@ -81,30 +81,50 @@ function mergeProjections(left, right) {
81
81
  return merged;
82
82
  }
83
83
  /** One level: which lookups were selected, and what each asked for. */
84
- function fromSelections(selectionSets, searchType, fragments, targetOf) {
84
+ function fromSelections(selectionSets, searchType, fragments, schema) {
85
85
  const projection = {};
86
86
  for (const selectionSet of selectionSets) {
87
87
  for (const selected of fieldsOf(selectionSet, fragments)) {
88
88
  const field = fieldNamed(searchType, selected.name.value);
89
- if (field === undefined ||
90
- field.kind !== 'reference' ||
91
- field.ref?.strategy !== 'lookup') {
89
+ if (field === undefined) {
90
+ continue;
91
+ }
92
+ // An INLINE reference is already carried by the hit, so it asks for
93
+ // nothing itself – but a lookup may sit inside it, and that one has to be
94
+ // reached. So the level is recorded with no `fields`, purely to carry
95
+ // what is selected below it.
96
+ const nested = nestedReferenceType(schema, field);
97
+ if (nested !== undefined) {
98
+ if (selected.selectionSet === undefined) {
99
+ continue;
100
+ }
101
+ const below = fromSelections([selected.selectionSet], nested, fragments, schema);
102
+ if (Object.keys(below).length > 0) {
103
+ const entry = (projection[selected.name.value] ??= {});
104
+ entry.resolve = mergeProjections(entry.resolve, below);
105
+ }
106
+ continue;
107
+ }
108
+ if (field.kind !== 'reference' || field.ref?.strategy !== 'lookup') {
92
109
  continue;
93
110
  }
94
111
  // The target's own declaration decides what its selections mean, so the
95
112
  // level below is read against it rather than against this type.
96
- const target = targetOf(field.ref.target);
113
+ // `searchSchema` rejects a lookup whose target it cannot resolve, so a
114
+ // schema always resolves its own – which is why this reads the schema
115
+ // rather than taking a resolver that could answer nothing.
116
+ const target = rootTypeNamed(schema, field.ref.target);
97
117
  // Only what the target actually serves. A selection carries more than
98
118
  // that: `id` is on the referring document already, and every GraphQL
99
119
  // client worth the name injects `__typename` into every selection set –
100
120
  // asking the engine for either would fail the query at the port's guard.
101
121
  const wanted = fieldsOf(selected.selectionSet, fragments)
102
122
  .map((node) => node.name.value)
103
- .filter((name) => target === undefined ? false : servesField(target, name));
123
+ .filter((name) => servesField(target, name));
104
124
  const entry = (projection[selected.name.value] ??= {});
105
125
  entry.fields = [...new Set([...(entry.fields ?? []), ...wanted])];
106
- if (target !== undefined && selected.selectionSet !== undefined) {
107
- const below = fromSelections([selected.selectionSet], target, fragments, targetOf);
126
+ if (selected.selectionSet !== undefined) {
127
+ const below = fromSelections([selected.selectionSet], target, fragments, schema);
108
128
  if (Object.keys(below).length > 0) {
109
129
  // Merged level by level, not key by key: one lookup selected twice –
110
130
  // two fragments each spreading it – must union what each asked for,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lde/search-api-graphql",
3
- "version": "0.25.1",
3
+ "version": "0.26.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.3",
40
- "@lde/search": "^0.23.1",
40
+ "@lde/search": "^0.24.0",
41
41
  "commander": "^15.0.0",
42
42
  "dataloader": "^2.2.3",
43
43
  "graphql": "^16.9.0",