@lde/search-api-graphql 0.21.1 → 0.22.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;AAqGD;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,YAAY,EACpB,OAAO,GAAE,yBAA8B,GACtC,aAAa,CA6uBf;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)));
@@ -268,6 +268,12 @@ export function buildGraphQLSchema(schema, options = {}) {
268
268
  ? DEFAULT_LABEL_FIELD
269
269
  : labelFieldNameOf(rootTypesByName.get(field.labelSource));
270
270
  }
271
+ // The declared joins, and the input types they make shareable. Each map is
272
+ // keyed by the SearchType `name`, so a type reached as a join target and the
273
+ // same type queried in its own right meet exactly one `‹Name›Where`.
274
+ const joins = joinGraph(schema);
275
+ const whereInputs = new Map();
276
+ const referenceFilters = new Map();
271
277
  /**
272
278
  * Register the GraphQL type one reference field is served as, once per
273
279
  * referenced shape. A **surfaced inline** reference is served as the nested
@@ -421,10 +427,22 @@ export function buildGraphQLSchema(schema, options = {}) {
421
427
  };
422
428
  }
423
429
  }
430
+ function whereFieldType(field, owner) {
431
+ // A joinable reference is filterable in two ways at once – by the ids it
432
+ // holds, and by a condition on the referent – so it earns a richer input
433
+ // than the plain membership every other reference gets. Making the
434
+ // difference visible in the schema is the point: a consumer sees which
435
+ // references can be filtered through, instead of discovering it from a
436
+ // runtime error.
437
+ const target = joins.resolve(owner, [field.name]);
438
+ return target !== undefined
439
+ ? referenceFilterFor(target)
440
+ : plainWhereFieldType(field);
441
+ }
424
442
  /** The filter input one field accepts, fixed by what the field keys on: a
425
443
  * `reference` keys on identity (its target’s filter, or `IRIFilter` when it
426
444
  * names no target), a `keyword` on literals. */
427
- function whereFieldType(field) {
445
+ function plainWhereFieldType(field) {
428
446
  switch (filterOperatorFor(field.kind)) {
429
447
  case 'in':
430
448
  if (field.kind !== 'reference') {
@@ -443,58 +461,88 @@ export function buildGraphQLSchema(schema, options = {}) {
443
461
  return GraphQLBoolean;
444
462
  }
445
463
  }
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
- },
464
+ /**
465
+ * The filter input a joinable reference to `target` takes, created once per
466
+ * target type and shared by EVERY field pointing at it – `publisher` and
467
+ * `creator` both resolving to `Organization` take the same
468
+ * `OrganizationReferenceFilter`, because what it can express is a property of
469
+ * the referenced type, not of the referring field.
470
+ *
471
+ * `@oneOf`, so a criterion stays an atom: filter by the ids the field holds,
472
+ * **or** by a condition on the referent, never both in one term.
473
+ */
474
+ function referenceFilterFor(target) {
475
+ const existing = referenceFilters.get(target.name);
476
+ if (existing !== undefined) {
477
+ return existing;
478
+ }
479
+ const created = new GraphQLInputObjectType({
480
+ name: `${target.name}ReferenceFilter`,
481
+ description: `A condition on a joinable reference to ${target.name}: the ids it holds, or a condition on the referenced ${target.name} itself.`,
482
+ isOneOf: true,
483
+ // A thunk: the target’s own `where` may point back through further
484
+ // joinable references (the join graph is acyclic, so this terminates).
485
+ fields: () => ({
486
+ // `IRI`, exactly as `‹Target›Filter` types it: this arm asks the same
487
+ // question that filter asks – which of the target’s ids the field
488
+ // holds – so it must accept the same variable a consumer declares
489
+ // `[IRI!]` for. Typing it `String` here would make the join arm the
490
+ // one place identity is not `IRI`-keyed.
491
+ in: { type: new GraphQLList(new GraphQLNonNull(iriScalar)) },
492
+ where: { type: whereInputFor(target) },
493
+ }),
460
494
  });
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;
495
+ referenceFilters.set(target.name, created);
496
+ return created;
497
+ }
498
+ /**
499
+ * One key per filterable field (plus the undeclared `id`), each typed by its
500
+ * OWN kind – so the operator a key accepts is fixed by the field it names,
501
+ * and a range on a keyword field cannot be written at all. This is the
502
+ * single vocabulary every level of `where` is built from: the same keys
503
+ * appear on the clause and on a criterion, so a field is never named a
504
+ * second way.
505
+ */
506
+ function fieldKeys(searchType) {
507
+ const fields = {
508
+ // Typed self-referentially – `TermWhere.id: TermFilter` – so a consumer
509
+ // can resolve “the collection I am browsing” to “the filter type that
510
+ // accepts its IRIs”, which is what makes the refined discovery strategy
511
+ // work without hardcoding a single domain name.
512
+ [ID_FIELD]: { type: targetFilter(searchType.name) },
488
513
  };
514
+ for (const field of filterableFields(searchType)) {
515
+ fields[field.name] = {
516
+ type: whereFieldType(field, searchType),
517
+ description: field.description,
518
+ };
519
+ }
520
+ return fields;
521
+ }
522
+ /**
523
+ * The `where` input of one root type, created once and shared: it is both the
524
+ * argument of that type’s own query field and – through a
525
+ * {@link referenceFilterFor} – the shape a *joined* condition on it takes
526
+ * from another type. One input either way, so the vocabulary a consumer
527
+ * learns for `datasets(where: …)` is the same one they write inside
528
+ * `creativeWorks(where: { dataset: { where: … } })`.
529
+ */
530
+ function whereInputFor(searchType) {
531
+ const existing = whereInputs.get(searchType.name);
532
+ if (existing !== undefined) {
533
+ return existing;
534
+ }
489
535
  // A criterion is an ATOM: exactly one field, enforced by `@oneOf`. That is
490
536
  // what keeps `where` a flat conjunction of disjunctions – a criterion that
491
537
  // could carry two keys would be a conjunction nested inside an `or`, and
492
538
  // skip-own-filter (ADR 5) has no answer for a clause buried inside another.
539
+ // Created here rather than on its own, because it exists for exactly one
540
+ // `where` and shares its memoization.
493
541
  const criterionInput = new GraphQLInputObjectType({
494
- name: `${typeName}Criterion`,
542
+ name: `${searchType.name}Criterion`,
495
543
  description: 'A condition on exactly one field. Used inside `or`, where the criteria are alternatives.',
496
544
  isOneOf: true,
497
- fields: fieldKeys,
545
+ fields: () => fieldKeys(searchType),
498
546
  });
499
547
  const orKey = {
500
548
  type: new GraphQLList(new GraphQLNonNull(criterionInput)),
@@ -506,18 +554,41 @@ export function buildGraphQLSchema(schema, options = {}) {
506
554
  // `or` holds only `@oneOf` criteria. So `where` stays a flat conjunction of
507
555
  // disjunctions at any nesting depth, and a reader meets one input type
508
556
  // instead of two near-identical ones.
509
- const whereInput = new GraphQLInputObjectType({
510
- name: `${typeName}Where`,
557
+ const created = new GraphQLInputObjectType({
558
+ name: `${searchType.name}Where`,
511
559
  description: 'Sibling keys are combined with AND. Use `or` for a disjunction, and `and` when a query needs more than one of them.',
512
560
  fields: () => ({
513
- ...fieldKeys(),
561
+ ...fieldKeys(searchType),
514
562
  [OR_KEY]: orKey,
515
563
  [AND_KEY]: {
516
- type: new GraphQLList(new GraphQLNonNull(whereInput)),
564
+ type: new GraphQLList(new GraphQLNonNull(created)),
517
565
  description: 'Further groups of conditions, all of which apply. The way to carry a second `or` disjunction alongside the first.',
518
566
  },
519
567
  }),
520
568
  });
569
+ whereInputs.set(searchType.name, created);
570
+ return created;
571
+ }
572
+ /** The root query field for one {@link RootType}, with its derived types. */
573
+ function rootField(searchType, typeOptions) {
574
+ const typeName = searchType.name;
575
+ const outputType = new GraphQLObjectType({
576
+ name: typeName,
577
+ fields: () => {
578
+ const fields = {
579
+ id: { type: new GraphQLNonNull(iriScalar) },
580
+ };
581
+ for (const field of outputFields(searchType)) {
582
+ fields[field.name] = outputFieldConfig(field);
583
+ }
584
+ return fields;
585
+ },
586
+ });
587
+ // Every type is filterable on `id`, so the input always has at least one
588
+ // field and always exists – no type is unaddressable by IRI, whatever it
589
+ // declares. It may already have been created as a joined type’s filter
590
+ // shape; `whereInputFor` hands back the same instance either way.
591
+ const whereInput = whereInputFor(searchType);
521
592
  const sortValues = {
522
593
  RELEVANCE: { value: 'relevance' },
523
594
  };
@@ -593,7 +664,7 @@ export function buildGraphQLSchema(schema, options = {}) {
593
664
  },
594
665
  },
595
666
  resolve: async (_source, args, context) => {
596
- const built = argsToQuery(args, context, searchType, maxPerPage);
667
+ const built = argsToQuery(args, context, searchType, maxPerPage, joins);
597
668
  const finalQuery = typeOptions?.queryDefaults
598
669
  ? typeOptions.queryDefaults(built, context)
599
670
  : built;
@@ -673,7 +744,7 @@ export function printGraphQLSchema(schema, options = {}) {
673
744
  /** Pure args → {@link SearchQuery} mapping. Rejects out-of-bounds paging
674
745
  * (`page < 1`, `perPage` outside `1..maxPerPage`) with a clear error – a
675
746
  * negative offset or an unbounded page size must not reach the engine. */
676
- function argsToQuery(args, context, searchType, maxPerPage) {
747
+ function argsToQuery(args, context, searchType, maxPerPage, joins) {
677
748
  const perPage = args.perPage ?? 20;
678
749
  const page = args.page ?? 1;
679
750
  if (page < 1) {
@@ -685,7 +756,7 @@ function argsToQuery(args, context, searchType, maxPerPage) {
685
756
  }
686
757
  return {
687
758
  text: args.query,
688
- where: whereToFilters(args.where, searchType),
759
+ where: whereToFilters(args.where, searchType, joins),
689
760
  orderBy: args.orderBy
690
761
  ? [{ field: args.orderBy.field, direction: args.orderBy.direction }]
691
762
  : [],
@@ -696,18 +767,37 @@ function argsToQuery(args, context, searchType, maxPerPage) {
696
767
  locale: context.acceptLanguage[0] ?? 'und',
697
768
  };
698
769
  }
699
- function whereToFilters(where, searchType) {
770
+ /**
771
+ * Compile one `Where` input into the flat conjunction of disjunctions the IR
772
+ * holds, at `on` hops out from the searched type (`[]` at the top level).
773
+ *
774
+ * A **joined** key carrying `where` recurses with the path extended by that
775
+ * key, and its clauses join this list – exactly how `and` already flattens. The
776
+ * result is still flat: the nesting lives in each criterion’s `on` path, never
777
+ * in the clause structure, so skip-own-filter (ADR 5) still scans one level.
778
+ */
779
+ function whereToFilters(where, searchType, joins, on = []) {
700
780
  // `== null` deliberately: an explicit `where: null` is as absent as an omitted
701
781
  // one, and reading keys off it would throw.
702
782
  if (where == null) {
703
783
  return [];
704
784
  }
705
- const filters = criteriaOf(where, searchType).map(filterOn);
785
+ const filters = [];
706
786
  // Sibling field keys AND, so each becomes a one-criterion filter of its own,
707
787
  // while `or` becomes a SINGLE filter carrying every alternative. That is the
708
788
  // whole AND/OR mapping: which bucket a criterion lands in, never a combinator
709
789
  // inferred from how deeply it is nested.
710
- const alternatives = (where[OR_KEY] ?? []).flatMap((criterion) => criteriaOf(criterion, searchType));
790
+ for (const entry of keyEntriesOf(where, searchType, joins)) {
791
+ if ('nested' in entry) {
792
+ filters.push(...whereToFilters(entry.nested, entry.target, joins, [
793
+ ...on,
794
+ entry.name,
795
+ ]));
796
+ continue;
797
+ }
798
+ filters.push(filterOn(withPath(entry.criterion, on)));
799
+ }
800
+ const alternatives = (where[OR_KEY] ?? []).flatMap((criterion) => criteriaOf(criterion, searchType, joins, on));
711
801
  if (alternatives.length > 0) {
712
802
  filters.push({ or: alternatives });
713
803
  }
@@ -716,47 +806,105 @@ function whereToFilters(where, searchType) {
716
806
  // result flat: nested conjunctions collapse into this one list, and an `and`
717
807
  // can never sit inside an `or` (which holds only criteria).
718
808
  for (const nested of where[AND_KEY] ?? []) {
719
- filters.push(...whereToFilters(nested, searchType));
809
+ filters.push(...whereToFilters(nested, searchType, joins, on));
720
810
  }
721
811
  return filters;
722
812
  }
813
+ /** A criterion with its join path attached, or unchanged at the top level –
814
+ * so an ordinary query’s IR is byte-for-byte what it was. */
815
+ function withPath(criterion, on) {
816
+ return on.length === 0 ? criterion : { ...criterion, on };
817
+ }
723
818
  /**
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.
819
+ * The criteria a keyed object carries, in declaration order, `on` hops out. A
820
+ * `Criterion` is `@oneOf`, so it yields exactly one; a `Where` yields one per
821
+ * field key it sets.
822
+ *
823
+ * A joined key carrying `where` is compiled through {@link whereToFilters} and
824
+ * must come back as exactly ONE one-criterion clause. Anything else – two field
825
+ * keys, an `or`, an `and` – is a conjunction, and a conjunction cannot sit
826
+ * inside the `or` this feeds (ADR 18’s flat IR has nowhere to put it), so it is
827
+ * rejected with the rewrite that expresses it instead.
728
828
  */
729
- function criteriaOf(keyed, searchType) {
829
+ function criteriaOf(keyed, searchType, joins, on = []) {
730
830
  const criteria = [];
831
+ for (const entry of keyEntriesOf(keyed, searchType, joins)) {
832
+ if (!('nested' in entry)) {
833
+ criteria.push(withPath(entry.criterion, on));
834
+ continue;
835
+ }
836
+ const path = [...on, entry.name];
837
+ const nested = whereToFilters(entry.nested, entry.target, joins, path);
838
+ const [only] = nested;
839
+ // An `or` alternative must be exactly one criterion. Empty and
840
+ // more-than-one are opposite mistakes, so they are named separately rather
841
+ // than sharing a message that fits only one of them.
842
+ if (only === undefined) {
843
+ 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.`);
844
+ }
845
+ if (nested.length !== 1 || only.or.length !== 1) {
846
+ 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”.`);
847
+ }
848
+ criteria.push(only.or[0]);
849
+ }
850
+ return criteria;
851
+ }
852
+ /**
853
+ * The field keys a keyed input sets, in declaration order. `id` is read first:
854
+ * no type declares it, so the field loop never sees it.
855
+ *
856
+ * A joinable reference takes a `‹Target›ReferenceFilter` rather than a plain
857
+ * `StringFilter`, and its two `@oneOf` arms mean different things: `in` filters
858
+ * by the ids the field itself holds – no join, the same membership every other
859
+ * reference gets – while `where` states a condition on the referent and yields
860
+ * a nested entry the caller walks into.
861
+ */
862
+ function keyEntriesOf(keyed, searchType, joins) {
863
+ const entries = [];
731
864
  const id = keyed[ID_FIELD];
732
865
  if (id !== undefined && id !== null) {
733
- criteria.push({ field: ID_FIELD, in: id.in ?? [] });
866
+ entries.push({ criterion: { field: ID_FIELD, in: id.in ?? [] } });
734
867
  }
735
868
  for (const field of filterableFields(searchType)) {
736
869
  const value = keyed[field.name];
737
870
  if (value === undefined || value === null) {
738
871
  continue;
739
872
  }
873
+ const target = joins.resolve(searchType, [field.name]);
874
+ if (target !== undefined) {
875
+ const nested = value
876
+ .where;
877
+ if (nested !== undefined && nested !== null) {
878
+ entries.push({ name: field.name, nested, target });
879
+ continue;
880
+ }
881
+ }
740
882
  switch (filterOperatorFor(field.kind)) {
741
883
  case 'in':
742
- criteria.push({
743
- field: field.name,
744
- in: value.in ?? [],
884
+ entries.push({
885
+ criterion: {
886
+ field: field.name,
887
+ in: value.in ?? [],
888
+ },
745
889
  });
746
890
  break;
747
891
  case 'range': {
748
892
  const range = value;
749
- criteria.push({
750
- field: field.name,
751
- range: { min: range.min, max: range.max },
893
+ entries.push({
894
+ criterion: {
895
+ field: field.name,
896
+ range: { min: range.min, max: range.max },
897
+ },
752
898
  });
753
899
  break;
754
900
  }
755
901
  default:
756
- criteria.push({ field: field.name, is: value });
902
+ entries.push({
903
+ criterion: { field: field.name, is: value },
904
+ });
757
905
  }
758
906
  }
759
- return criteria;
907
+ return entries;
760
908
  }
761
909
  function rangeInput(name, bound) {
762
910
  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.22.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.20.0",
41
41
  "commander": "^15.0.0",
42
42
  "dataloader": "^2.2.3",
43
43
  "graphql": "^16.9.0",