@lde/search-api-graphql 0.21.1 → 0.23.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;AAmBrB,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;AAqGD;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,YAAY,EACpB,OAAO,GAAE,yBAA8B,GACtC,aAAa,CA4pBf;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,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,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, DEFAULT_LABEL_FIELD, facetableFields, filterableFields, filterOn, filterOperatorFor, ID_FIELD, isAbsoluteIri, OR_KEY, isRangeFacet, labelFieldNameOf, nestedReferenceType, outputFields, pageForOffset, sortableFields, unixSecondsToIso, } from '@lde/search/adapter';
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';
3
3
  import { defaultLanguageOrder, toLanguageStrings, } from './language.js';
4
4
  import { createFacetLoader } from './facet-batch.js';
5
5
  const nonNullListOf = (type) => new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(type)));
@@ -15,21 +15,28 @@ const scalarOutput = (scalar, field) => field.required === true ? new GraphQLNon
15
15
  * distinguishable. Consequence for consumers: a variable must be declared
16
16
  * `[IRI!]`, not `[String!]`, because GraphQL checks variable usage nominally.
17
17
  *
18
- * **Input coercion validates; output serialization does not.** Rejecting a bare
19
- * token on the way in is what turns `where: { material: { in: ["boerenbont"] } }`
20
- * from a silent empty result into an error saying why – the same job
21
- * `argsToQuery` already does for an out-of-range `perPage`.
18
+ * **It validates in both directions.** Rejecting a bare token on the way in is
19
+ * what turns `where: { material: { in: ["boerenbont"] } }` from a silent empty
20
+ * result into an error saying why – the same job `argsToQuery` already does for
21
+ * an out-of-range `perPage`. Outbound matters for the same reason: a type
22
+ * enforced in one direction only is not one a consumer can rely on, and the
23
+ * coarse discovery strategy reads `IRI` as the promise that a value is a
24
+ * selection key.
22
25
  *
23
- * It validates on the way **out** too, and deliberately: a type that is enforced
24
- * in one direction only is not a type a consumer can rely on, and the coarse
25
- * discovery strategy reads `IRI` as the promise that a value is a selection key.
26
- * Serving a non-IRI under it would make the promise false exactly where a
27
- * consumer acts on it. The projection applies the same rule at the source, so
28
- * the only way to reach this error is an index written before it existed – which
29
- * a reindex fixes, and which failing names instead of hiding.
26
+ * **Where a bad value is dropped rather than raised.** Raising is right where
27
+ * the value IS the document’s identity (`id`, a reference’s `id`): such a
28
+ * document cannot be selected, so serving it would hand back something
29
+ * unusable. It is wrong for a facet bucket – `value: IRI!` sits inside a chain
30
+ * of non-nulls up to the root field, so raising there nulls the WHOLE response
31
+ * and discards `items`, which is precisely what facet-batch.ts’s degradation
32
+ * contract forbids a supplementary sidebar count from doing. The facet
33
+ * resolvers therefore filter unselectable buckets out instead.
30
34
  *
31
35
  * What counts as an IRI is {@link isAbsoluteIri} – shared with the projection,
32
- * so the surface cannot promise something the index contradicts.
36
+ * which applies it on every route a reference value can take (graph path,
37
+ * `derive`, `from`, `transform`), so the surface cannot promise something the
38
+ * index contradicts and the outbound error is reachable only from an index
39
+ * written before that rule existed.
33
40
  */
34
41
  const iriScalar = new GraphQLScalarType({
35
42
  name: 'IRI',
@@ -133,7 +140,7 @@ export function buildGraphQLSchema(schema, options = {}) {
133
140
  // that selects it, with no `String`-to-`IRI` boundary in between – the
134
141
  // contract BooleanBucket already keeps for `is`.
135
142
  const iriBucket = new GraphQLObjectType({
136
- name: 'IriBucket',
143
+ name: 'IRIBucket',
137
144
  fields: valueBucketFields(iriScalar),
138
145
  });
139
146
  // A numeric range-facet bin: half-open `[min, max)` bounds (max null on an
@@ -254,7 +261,28 @@ export function buildGraphQLSchema(schema, options = {}) {
254
261
  // collide: searchSchema resolves its typeName to a declared Reference Type
255
262
  // and rejects duplicate names schema-wide.
256
263
  const referenceTypes = new Map();
257
- const takenTypeNames = new Set(rootTypeNames);
264
+ // Seeded with the shared types every schema carries, not just the root type
265
+ // names: a declaration is free to name a type `IRI` or `ValueBucket`, and
266
+ // without this it would pass both collision checks and fail at
267
+ // `new GraphQLSchema` with graphql-js’s “Schema must contain uniquely named
268
+ // types” – naming neither the declaration nor the field, which is the opaque
269
+ // failure these checks exist to replace.
270
+ const takenTypeNames = new Set([
271
+ ...rootTypeNames,
272
+ iriScalar.name,
273
+ keywordFilter.name,
274
+ iriFilter.name,
275
+ languageString.name,
276
+ valueBucket.name,
277
+ iriBucket.name,
278
+ rangeBucket.name,
279
+ booleanBucket.name,
280
+ paginationType.name,
281
+ sortDirection.name,
282
+ intRange.name,
283
+ floatRange.name,
284
+ dateRange.name,
285
+ ]);
258
286
  /** The label key each id-plus-label reference type was registered with; no
259
287
  * entry for a surfaced inline one, which carries fields instead of a label. */
260
288
  const labelKeys = new Map();
@@ -268,6 +296,12 @@ export function buildGraphQLSchema(schema, options = {}) {
268
296
  ? DEFAULT_LABEL_FIELD
269
297
  : labelFieldNameOf(rootTypesByName.get(field.labelSource));
270
298
  }
299
+ // The declared joins, and the input types they make shareable. Each map is
300
+ // keyed by the SearchType `name`, so a type reached as a join target and the
301
+ // same type queried in its own right meet exactly one `‹Name›Where`.
302
+ const joins = joinGraph(schema);
303
+ const whereInputs = new Map();
304
+ const referenceFilters = new Map();
271
305
  /**
272
306
  * Register the GraphQL type one reference field is served as, once per
273
307
  * referenced shape. A **surfaced inline** reference is served as the nested
@@ -421,10 +455,22 @@ export function buildGraphQLSchema(schema, options = {}) {
421
455
  };
422
456
  }
423
457
  }
458
+ function whereFieldType(field, owner) {
459
+ // A joinable reference is filterable in two ways at once – by the ids it
460
+ // holds, and by a condition on the referent – so it earns a richer input
461
+ // than the plain membership every other reference gets. Making the
462
+ // difference visible in the schema is the point: a consumer sees which
463
+ // references can be filtered through, instead of discovering it from a
464
+ // runtime error.
465
+ const target = joins.resolve(owner, [field.name]);
466
+ return target !== undefined
467
+ ? referenceFilterFor(target)
468
+ : plainWhereFieldType(field);
469
+ }
424
470
  /** The filter input one field accepts, fixed by what the field keys on: a
425
471
  * `reference` keys on identity (its target’s filter, or `IRIFilter` when it
426
472
  * names no target), a `keyword` on literals. */
427
- function whereFieldType(field) {
473
+ function plainWhereFieldType(field) {
428
474
  switch (filterOperatorFor(field.kind)) {
429
475
  case 'in':
430
476
  if (field.kind !== 'reference') {
@@ -443,58 +489,88 @@ export function buildGraphQLSchema(schema, options = {}) {
443
489
  return GraphQLBoolean;
444
490
  }
445
491
  }
446
- /** The root query field for one {@link RootType}, with its derived types. */
447
- function rootField(searchType, typeOptions) {
448
- const typeName = searchType.name;
449
- const outputType = new GraphQLObjectType({
450
- name: typeName,
451
- fields: () => {
452
- const fields = {
453
- id: { type: new GraphQLNonNull(iriScalar) },
454
- };
455
- for (const field of outputFields(searchType)) {
456
- fields[field.name] = outputFieldConfig(field);
457
- }
458
- return fields;
459
- },
492
+ /**
493
+ * The filter input a joinable reference to `target` takes, created once per
494
+ * target type and shared by EVERY field pointing at it – `publisher` and
495
+ * `creator` both resolving to `Organization` take the same
496
+ * `OrganizationReferenceFilter`, because what it can express is a property of
497
+ * the referenced type, not of the referring field.
498
+ *
499
+ * `@oneOf`, so a criterion stays an atom: filter by the ids the field holds,
500
+ * **or** by a condition on the referent, never both in one term.
501
+ */
502
+ function referenceFilterFor(target) {
503
+ const existing = referenceFilters.get(target.name);
504
+ if (existing !== undefined) {
505
+ return existing;
506
+ }
507
+ const created = new GraphQLInputObjectType({
508
+ name: `${target.name}ReferenceFilter`,
509
+ description: `A condition on a joinable reference to ${target.name}: the ids it holds, or a condition on the referenced ${target.name} itself.`,
510
+ isOneOf: true,
511
+ // A thunk: the target’s own `where` may point back through further
512
+ // joinable references (the join graph is acyclic, so this terminates).
513
+ fields: () => ({
514
+ // `IRI`, exactly as `‹Target›Filter` types it: this arm asks the same
515
+ // question that filter asks – which of the target’s ids the field
516
+ // holds – so it must accept the same variable a consumer declares
517
+ // `[IRI!]` for. Typing it `String` here would make the join arm the
518
+ // one place identity is not `IRI`-keyed.
519
+ in: { type: new GraphQLList(new GraphQLNonNull(iriScalar)) },
520
+ where: { type: whereInputFor(target) },
521
+ }),
460
522
  });
461
- // Every type is filterable on `id`, so the input always has at least one
462
- // field and always exists – no type is unaddressable by IRI, whatever it
463
- // declares.
464
- const filterable = filterableFields(searchType);
465
- /**
466
- * One key per filterable field (plus the undeclared `id`), each typed by its
467
- * OWN kind – so the operator a key accepts is fixed by the field it names,
468
- * and a range on a keyword field cannot be written at all. This is the
469
- * single vocabulary every level of `where` is built from: the same keys
470
- * appear on the clause and on a criterion, so a field is never named a
471
- * second way.
472
- */
473
- const fieldKeys = () => {
474
- const fields = {
475
- // Typed self-referentially – `TermWhere.id: TermFilter` – so a consumer
476
- // can resolve “the collection I am browsing” to “the filter type that
477
- // accepts its IRIs”, which is what makes the refined discovery strategy
478
- // work without hardcoding a single domain name.
479
- [ID_FIELD]: { type: targetFilter(typeName) },
480
- };
481
- for (const field of filterable) {
482
- fields[field.name] = {
483
- type: whereFieldType(field),
484
- description: field.description,
485
- };
486
- }
487
- return fields;
523
+ referenceFilters.set(target.name, created);
524
+ return created;
525
+ }
526
+ /**
527
+ * One key per filterable field (plus the undeclared `id`), each typed by its
528
+ * OWN kind – so the operator a key accepts is fixed by the field it names,
529
+ * and a range on a keyword field cannot be written at all. This is the
530
+ * single vocabulary every level of `where` is built from: the same keys
531
+ * appear on the clause and on a criterion, so a field is never named a
532
+ * second way.
533
+ */
534
+ function fieldKeys(searchType) {
535
+ const fields = {
536
+ // Typed self-referentially – `TermWhere.id: TermFilter` – so a consumer
537
+ // can resolve “the collection I am browsing” to “the filter type that
538
+ // accepts its IRIs”, which is what makes the refined discovery strategy
539
+ // work without hardcoding a single domain name.
540
+ [ID_FIELD]: { type: targetFilter(searchType.name) },
488
541
  };
542
+ for (const field of filterableFields(searchType)) {
543
+ fields[field.name] = {
544
+ type: whereFieldType(field, searchType),
545
+ description: field.description,
546
+ };
547
+ }
548
+ return fields;
549
+ }
550
+ /**
551
+ * The `where` input of one root type, created once and shared: it is both the
552
+ * argument of that type’s own query field and – through a
553
+ * {@link referenceFilterFor} – the shape a *joined* condition on it takes
554
+ * from another type. One input either way, so the vocabulary a consumer
555
+ * learns for `datasets(where: …)` is the same one they write inside
556
+ * `creativeWorks(where: { dataset: { where: … } })`.
557
+ */
558
+ function whereInputFor(searchType) {
559
+ const existing = whereInputs.get(searchType.name);
560
+ if (existing !== undefined) {
561
+ return existing;
562
+ }
489
563
  // A criterion is an ATOM: exactly one field, enforced by `@oneOf`. That is
490
564
  // what keeps `where` a flat conjunction of disjunctions – a criterion that
491
565
  // could carry two keys would be a conjunction nested inside an `or`, and
492
566
  // skip-own-filter (ADR 5) has no answer for a clause buried inside another.
567
+ // Created here rather than on its own, because it exists for exactly one
568
+ // `where` and shares its memoization.
493
569
  const criterionInput = new GraphQLInputObjectType({
494
- name: `${typeName}Criterion`,
570
+ name: `${searchType.name}Criterion`,
495
571
  description: 'A condition on exactly one field. Used inside `or`, where the criteria are alternatives.',
496
572
  isOneOf: true,
497
- fields: fieldKeys,
573
+ fields: () => fieldKeys(searchType),
498
574
  });
499
575
  const orKey = {
500
576
  type: new GraphQLList(new GraphQLNonNull(criterionInput)),
@@ -506,18 +582,41 @@ export function buildGraphQLSchema(schema, options = {}) {
506
582
  // `or` holds only `@oneOf` criteria. So `where` stays a flat conjunction of
507
583
  // disjunctions at any nesting depth, and a reader meets one input type
508
584
  // instead of two near-identical ones.
509
- const whereInput = new GraphQLInputObjectType({
510
- name: `${typeName}Where`,
585
+ const created = new GraphQLInputObjectType({
586
+ name: `${searchType.name}Where`,
511
587
  description: 'Sibling keys are combined with AND. Use `or` for a disjunction, and `and` when a query needs more than one of them.',
512
588
  fields: () => ({
513
- ...fieldKeys(),
589
+ ...fieldKeys(searchType),
514
590
  [OR_KEY]: orKey,
515
591
  [AND_KEY]: {
516
- type: new GraphQLList(new GraphQLNonNull(whereInput)),
592
+ type: new GraphQLList(new GraphQLNonNull(created)),
517
593
  description: 'Further groups of conditions, all of which apply. The way to carry a second `or` disjunction alongside the first.',
518
594
  },
519
595
  }),
520
596
  });
597
+ whereInputs.set(searchType.name, created);
598
+ return created;
599
+ }
600
+ /** The root query field for one {@link RootType}, with its derived types. */
601
+ function rootField(searchType, typeOptions) {
602
+ const typeName = searchType.name;
603
+ const outputType = new GraphQLObjectType({
604
+ name: typeName,
605
+ fields: () => {
606
+ const fields = {
607
+ id: { type: new GraphQLNonNull(iriScalar) },
608
+ };
609
+ for (const field of outputFields(searchType)) {
610
+ fields[field.name] = outputFieldConfig(field);
611
+ }
612
+ return fields;
613
+ },
614
+ });
615
+ // Every type is filterable on `id`, so the input always has at least one
616
+ // field and always exists – no type is unaddressable by IRI, whatever it
617
+ // declares. It may already have been created as a joined type’s filter
618
+ // shape; `whereInputFor` hands back the same instance either way.
619
+ const whereInput = whereInputFor(searchType);
521
620
  const sortValues = {
522
621
  RELEVANCE: { value: 'relevance' },
523
622
  };
@@ -540,7 +639,7 @@ export function buildGraphQLSchema(schema, options = {}) {
540
639
  });
541
640
  // Keyed facets object: one field per facetable field, typed by its kind
542
641
  // (range fields → [RangeBucket!], boolean fields → [BooleanBucket!],
543
- // reference fields → [IriBucket!], else [ValueBucket!]). Only the selected
642
+ // reference fields → [IRIBucket!], else [ValueBucket!]). Only the selected
544
643
  // fields are resolved (GraphQL prunes the rest), so the selection IS the
545
644
  // request; how they are computed – skip-own-filter, batched into one
546
645
  // engine dispatch – lives in facet-batch.ts.
@@ -593,7 +692,7 @@ export function buildGraphQLSchema(schema, options = {}) {
593
692
  },
594
693
  },
595
694
  resolve: async (_source, args, context) => {
596
- const built = argsToQuery(args, context, searchType, maxPerPage);
695
+ const built = argsToQuery(args, context, searchType, maxPerPage, joins);
597
696
  const finalQuery = typeOptions?.queryDefaults
598
697
  ? typeOptions.queryDefaults(built, context)
599
698
  : built;
@@ -634,12 +733,28 @@ export function buildGraphQLSchema(schema, options = {}) {
634
733
  fields: () => {
635
734
  const fields = {};
636
735
  for (const field of facetable) {
736
+ const bucketType = bucketTypeFor(field);
637
737
  fields[field.name] = {
638
- type: nonNullListOf(bucketTypeFor(field)),
738
+ type: nonNullListOf(bucketType),
639
739
  // The skip-own-filter query building, the batching into one
640
740
  // engine dispatch and the degrade-to-[] error handling all live
641
741
  // in the loader (facet-batch.ts).
642
- resolve: (source) => source.loadFacet(field.name),
742
+ resolve: async (source) => {
743
+ const buckets = await source.loadFacet(field.name);
744
+ if (bucketType !== iriBucket) {
745
+ return buckets;
746
+ }
747
+ // A bucket keyed on something that is not an IRI is not a
748
+ // selection key – it cannot be sent back as the filter that
749
+ // selects it – so it is dropped rather than served under `IRI`.
750
+ // Dropping, not throwing: `value: IRI!` sits inside a chain of
751
+ // non-nulls up to the root field, so a raising coercion here
752
+ // would null the WHOLE response and take `items` with it, which
753
+ // is exactly what facet-batch.ts’s degradation contract forbids
754
+ // a supplementary sidebar count from doing.
755
+ return buckets.filter((bucket) => typeof bucket.value === 'string' &&
756
+ isAbsoluteIri(bucket.value));
757
+ },
643
758
  };
644
759
  }
645
760
  return fields;
@@ -673,7 +788,7 @@ export function printGraphQLSchema(schema, options = {}) {
673
788
  /** Pure args → {@link SearchQuery} mapping. Rejects out-of-bounds paging
674
789
  * (`page < 1`, `perPage` outside `1..maxPerPage`) with a clear error – a
675
790
  * negative offset or an unbounded page size must not reach the engine. */
676
- function argsToQuery(args, context, searchType, maxPerPage) {
791
+ function argsToQuery(args, context, searchType, maxPerPage, joins) {
677
792
  const perPage = args.perPage ?? 20;
678
793
  const page = args.page ?? 1;
679
794
  if (page < 1) {
@@ -685,7 +800,7 @@ function argsToQuery(args, context, searchType, maxPerPage) {
685
800
  }
686
801
  return {
687
802
  text: args.query,
688
- where: whereToFilters(args.where, searchType),
803
+ where: whereToFilters(args.where, searchType, joins),
689
804
  orderBy: args.orderBy
690
805
  ? [{ field: args.orderBy.field, direction: args.orderBy.direction }]
691
806
  : [],
@@ -696,18 +811,37 @@ function argsToQuery(args, context, searchType, maxPerPage) {
696
811
  locale: context.acceptLanguage[0] ?? 'und',
697
812
  };
698
813
  }
699
- function whereToFilters(where, searchType) {
814
+ /**
815
+ * Compile one `Where` input into the flat conjunction of disjunctions the IR
816
+ * holds, at `on` hops out from the searched type (`[]` at the top level).
817
+ *
818
+ * A **joined** key carrying `where` recurses with the path extended by that
819
+ * key, and its clauses join this list – exactly how `and` already flattens. The
820
+ * result is still flat: the nesting lives in each criterion’s `on` path, never
821
+ * in the clause structure, so skip-own-filter (ADR 5) still scans one level.
822
+ */
823
+ function whereToFilters(where, searchType, joins, on = []) {
700
824
  // `== null` deliberately: an explicit `where: null` is as absent as an omitted
701
825
  // one, and reading keys off it would throw.
702
826
  if (where == null) {
703
827
  return [];
704
828
  }
705
- const filters = criteriaOf(where, searchType).map(filterOn);
829
+ const filters = [];
706
830
  // Sibling field keys AND, so each becomes a one-criterion filter of its own,
707
831
  // while `or` becomes a SINGLE filter carrying every alternative. That is the
708
832
  // whole AND/OR mapping: which bucket a criterion lands in, never a combinator
709
833
  // inferred from how deeply it is nested.
710
- const alternatives = (where[OR_KEY] ?? []).flatMap((criterion) => criteriaOf(criterion, searchType));
834
+ for (const entry of keyEntriesOf(where, searchType, joins)) {
835
+ if ('nested' in entry) {
836
+ filters.push(...whereToFilters(entry.nested, entry.target, joins, [
837
+ ...on,
838
+ entry.name,
839
+ ]));
840
+ continue;
841
+ }
842
+ filters.push(filterOn(withPath(entry.criterion, on)));
843
+ }
844
+ const alternatives = (where[OR_KEY] ?? []).flatMap((criterion) => criteriaOf(criterion, searchType, joins, on));
711
845
  if (alternatives.length > 0) {
712
846
  filters.push({ or: alternatives });
713
847
  }
@@ -716,47 +850,105 @@ function whereToFilters(where, searchType) {
716
850
  // result flat: nested conjunctions collapse into this one list, and an `and`
717
851
  // can never sit inside an `or` (which holds only criteria).
718
852
  for (const nested of where[AND_KEY] ?? []) {
719
- filters.push(...whereToFilters(nested, searchType));
853
+ filters.push(...whereToFilters(nested, searchType, joins, on));
720
854
  }
721
855
  return filters;
722
856
  }
857
+ /** A criterion with its join path attached, or unchanged at the top level –
858
+ * so an ordinary query’s IR is byte-for-byte what it was. */
859
+ function withPath(criterion, on) {
860
+ return on.length === 0 ? criterion : { ...criterion, on };
861
+ }
723
862
  /**
724
- * The criteria a keyed object carries, in declaration order. A `Criterion` is
725
- * `@oneOf`, so it yields exactly one; a `Where`/`Clause` yields one per field
726
- * key it sets. `id` is read first: no type declares it, so the field loop below
727
- * never sees it.
863
+ * The criteria a keyed object carries, in declaration order, `on` hops out. A
864
+ * `Criterion` is `@oneOf`, so it yields exactly one; a `Where` yields one per
865
+ * field key it sets.
866
+ *
867
+ * A joined key carrying `where` is compiled through {@link whereToFilters} and
868
+ * must come back as exactly ONE one-criterion clause. Anything else – two field
869
+ * keys, an `or`, an `and` – is a conjunction, and a conjunction cannot sit
870
+ * inside the `or` this feeds (ADR 18’s flat IR has nowhere to put it), so it is
871
+ * rejected with the rewrite that expresses it instead.
728
872
  */
729
- function criteriaOf(keyed, searchType) {
873
+ function criteriaOf(keyed, searchType, joins, on = []) {
730
874
  const criteria = [];
875
+ for (const entry of keyEntriesOf(keyed, searchType, joins)) {
876
+ if (!('nested' in entry)) {
877
+ criteria.push(withPath(entry.criterion, on));
878
+ continue;
879
+ }
880
+ const path = [...on, entry.name];
881
+ const nested = whereToFilters(entry.nested, entry.target, joins, path);
882
+ const [only] = nested;
883
+ // An `or` alternative must be exactly one criterion. Empty and
884
+ // more-than-one are opposite mistakes, so they are named separately rather
885
+ // than sharing a message that fits only one of them.
886
+ if (only === undefined) {
887
+ throw new Error(`The joined condition on “${entry.name}” states no criterion, so it constrains nothing – and an “or” alternative that constrains nothing would make the whole disjunction match everything. Give it a condition, or leave the alternative out.`);
888
+ }
889
+ if (nested.length !== 1 || only.or.length !== 1) {
890
+ throw new Error(`The joined condition on “${entry.name}” states more than one criterion, so it is a conjunction – which cannot sit inside an “or”. Write one “or” alternative per criterion, or move the conjunction into “and”.`);
891
+ }
892
+ criteria.push(only.or[0]);
893
+ }
894
+ return criteria;
895
+ }
896
+ /**
897
+ * The field keys a keyed input sets, in declaration order. `id` is read first:
898
+ * no type declares it, so the field loop never sees it.
899
+ *
900
+ * A joinable reference takes a `‹Target›ReferenceFilter` rather than a plain
901
+ * `StringFilter`, and its two `@oneOf` arms mean different things: `in` filters
902
+ * by the ids the field itself holds – no join, the same membership every other
903
+ * reference gets – while `where` states a condition on the referent and yields
904
+ * a nested entry the caller walks into.
905
+ */
906
+ function keyEntriesOf(keyed, searchType, joins) {
907
+ const entries = [];
731
908
  const id = keyed[ID_FIELD];
732
909
  if (id !== undefined && id !== null) {
733
- criteria.push({ field: ID_FIELD, in: id.in ?? [] });
910
+ entries.push({ criterion: { field: ID_FIELD, in: id.in ?? [] } });
734
911
  }
735
912
  for (const field of filterableFields(searchType)) {
736
913
  const value = keyed[field.name];
737
914
  if (value === undefined || value === null) {
738
915
  continue;
739
916
  }
917
+ const target = joins.resolve(searchType, [field.name]);
918
+ if (target !== undefined) {
919
+ const nested = value
920
+ .where;
921
+ if (nested !== undefined && nested !== null) {
922
+ entries.push({ name: field.name, nested, target });
923
+ continue;
924
+ }
925
+ }
740
926
  switch (filterOperatorFor(field.kind)) {
741
927
  case 'in':
742
- criteria.push({
743
- field: field.name,
744
- in: value.in ?? [],
928
+ entries.push({
929
+ criterion: {
930
+ field: field.name,
931
+ in: value.in ?? [],
932
+ },
745
933
  });
746
934
  break;
747
935
  case 'range': {
748
936
  const range = value;
749
- criteria.push({
750
- field: field.name,
751
- range: { min: range.min, max: range.max },
937
+ entries.push({
938
+ criterion: {
939
+ field: field.name,
940
+ range: { min: range.min, max: range.max },
941
+ },
752
942
  });
753
943
  break;
754
944
  }
755
945
  default:
756
- criteria.push({ field: field.name, is: value });
946
+ entries.push({
947
+ criterion: { field: field.name, is: value },
948
+ });
757
949
  }
758
950
  }
759
- return criteria;
951
+ return entries;
760
952
  }
761
953
  function rangeInput(name, bound) {
762
954
  return new GraphQLInputObjectType({
@@ -1 +1 @@
1
- {"version":3,"file":"facet-batch.d.ts","sourceRoot":"","sources":["../src/facet-batch.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,WAAW,EAEX,QAAQ,EACR,YAAY,EACZ,WAAW,EACZ,MAAM,aAAa,CAAC;AAErB,uFAAuF;AACvF,MAAM,MAAM,WAAW,GAAG,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,SAAS,WAAW,EAAE,CAAC,CAAC;AAE7E;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,YAAY,EACpB,UAAU,EAAE,QAAQ,EACpB,KAAK,EAAE,WAAW,EAClB,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,KAAK,IAAI,GACrD,WAAW,CAoCb;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,WAAW,EAClB,MAAM,EAAE,SAAS,MAAM,EAAE,GACxB,WAAW,EAAE,CAoBf"}
1
+ {"version":3,"file":"facet-batch.d.ts","sourceRoot":"","sources":["../src/facet-batch.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAEV,WAAW,EAEX,QAAQ,EACR,YAAY,EACZ,WAAW,EACZ,MAAM,aAAa,CAAC;AAErB,uFAAuF;AACvF,MAAM,MAAM,WAAW,GAAG,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,SAAS,WAAW,EAAE,CAAC,CAAC;AAE7E;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,YAAY,EACpB,UAAU,EAAE,QAAQ,EACpB,KAAK,EAAE,WAAW,EAClB,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,KAAK,IAAI,GACrD,WAAW,CAoCb;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,WAAW,EAClB,MAAM,EAAE,SAAS,MAAM,EAAE,GACxB,WAAW,EAAE,CAoBf"}
@@ -115,7 +115,21 @@ function ownedField(filter) {
115
115
  if (first === undefined) {
116
116
  return undefined;
117
117
  }
118
- return rest.every((criterion) => criterion.field === first.field)
119
- ? first.field
120
- : undefined;
118
+ const key = axisOf(first);
119
+ return rest.every((criterion) => axisOf(criterion) === key) ? key : undefined;
120
+ }
121
+ /**
122
+ * The axis one criterion constrains: its field, qualified by the join path it
123
+ * reaches that field through. Two criteria are the same axis only when both
124
+ * agree – `dataset.publisher` and a local `publisher` are different questions,
125
+ * so a selection on one is no selection on the other.
126
+ *
127
+ * Facets are not yet offered on joined fields, so a joined clause’s key can
128
+ * never equal a requested facet name and such a clause simply never drops. That
129
+ * falls out of the key rather than being a rule of its own, and stays right if
130
+ * joined facets land later.
131
+ */
132
+ function axisOf(criterion) {
133
+ const on = criterion.on ?? [];
134
+ return on.length === 0 ? criterion.field : [...on, criterion.field].join('.');
121
135
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lde/search-api-graphql",
3
- "version": "0.21.1",
3
+ "version": "0.23.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.19.0",
40
+ "@lde/search": "^0.21.0",
41
41
  "commander": "^15.0.0",
42
42
  "dataloader": "^2.2.3",
43
43
  "graphql": "^16.9.0",