@lde/search-api-graphql 0.21.0 → 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;AA+ED;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,YAAY,EACpB,OAAO,GAAE,yBAA8B,GACtC,aAAa,CAuoBf;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)));
@@ -41,18 +41,39 @@ const iriScalar = new GraphQLScalarType({
41
41
  });
42
42
  function assertIri(value) {
43
43
  if (!isAbsoluteIri(value)) {
44
- throw new GraphQLError(`IRI cannot represent “${value}”: an IRI needs a scheme (for example “https:”, “urn:” or “doi:”) and no whitespace. A value like this is usually a label or a token, which selects nothing on a field that keys on identity.`);
44
+ throw userError(`IRI cannot represent “${value}”: an IRI needs a scheme (for example “https:”, “urn:” or “doi:”) and no whitespace. A value like this is usually a label or a token, which selects nothing on a field that keys on identity.`);
45
45
  }
46
46
  return value;
47
47
  }
48
48
  /** Outbound the fault is the index, not the caller, so the message points at
49
- * the fix rather than at the query. */
49
+ * the fix rather than at the query – and it carries no `BAD_USER_INPUT`,
50
+ * since there is nothing the query could have done differently. */
50
51
  function assertIriOut(value) {
51
52
  if (!isAbsoluteIri(value)) {
52
53
  throw new GraphQLError(`IRI cannot serialize “${value}”: the index holds a value that is not an absolute IRI on a field that keys on identity. The projection drops such values at the source, so this document predates that rule – reindex it.`);
53
54
  }
54
55
  return value;
55
56
  }
57
+ /**
58
+ * An error the CALLER can fix, marked as such: a `GraphQLError` carrying
59
+ * `extensions.code = 'BAD_USER_INPUT'`.
60
+ *
61
+ * Both halves matter, and both are about the consumer rather than about us. A
62
+ * plain `Error` thrown from a resolver is a server fault as far as the
63
+ * transport is concerned, so graphql-yoga masks it to `“Unexpected error.”`;
64
+ * the message then survives only in the API container’s log, where a
65
+ * presentation-layer developer building against a hosted endpoint cannot read
66
+ * it. Throwing a `GraphQLError` keeps the sentence that says what was wrong.
67
+ * The `code` says whose fault it is, so a client can distinguish “fix your
68
+ * query” from “retry later” without matching on prose – the convention every
69
+ * major GraphQL server shares. `“Unexpected error.”` is left for what the name
70
+ * says: faults we did not anticipate.
71
+ */
72
+ function userError(message) {
73
+ return new GraphQLError(message, {
74
+ extensions: { code: 'BAD_USER_INPUT' },
75
+ });
76
+ }
56
77
  /** SCREAMING_SNAKE_CASE for an enum value name, e.g. `datePosted` → `DATE_POSTED`. */
57
78
  function screamingSnake(name) {
58
79
  return name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase();
@@ -247,6 +268,12 @@ export function buildGraphQLSchema(schema, options = {}) {
247
268
  ? DEFAULT_LABEL_FIELD
248
269
  : labelFieldNameOf(rootTypesByName.get(field.labelSource));
249
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();
250
277
  /**
251
278
  * Register the GraphQL type one reference field is served as, once per
252
279
  * referenced shape. A **surfaced inline** reference is served as the nested
@@ -400,10 +427,22 @@ export function buildGraphQLSchema(schema, options = {}) {
400
427
  };
401
428
  }
402
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
+ }
403
442
  /** The filter input one field accepts, fixed by what the field keys on: a
404
443
  * `reference` keys on identity (its target’s filter, or `IRIFilter` when it
405
444
  * names no target), a `keyword` on literals. */
406
- function whereFieldType(field) {
445
+ function plainWhereFieldType(field) {
407
446
  switch (filterOperatorFor(field.kind)) {
408
447
  case 'in':
409
448
  if (field.kind !== 'reference') {
@@ -422,58 +461,88 @@ export function buildGraphQLSchema(schema, options = {}) {
422
461
  return GraphQLBoolean;
423
462
  }
424
463
  }
425
- /** The root query field for one {@link RootType}, with its derived types. */
426
- function rootField(searchType, typeOptions) {
427
- const typeName = searchType.name;
428
- const outputType = new GraphQLObjectType({
429
- name: typeName,
430
- fields: () => {
431
- const fields = {
432
- id: { type: new GraphQLNonNull(iriScalar) },
433
- };
434
- for (const field of outputFields(searchType)) {
435
- fields[field.name] = outputFieldConfig(field);
436
- }
437
- return fields;
438
- },
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
+ }),
439
494
  });
440
- // Every type is filterable on `id`, so the input always has at least one
441
- // field and always exists – no type is unaddressable by IRI, whatever it
442
- // declares.
443
- const filterable = filterableFields(searchType);
444
- /**
445
- * One key per filterable field (plus the undeclared `id`), each typed by its
446
- * OWN kind – so the operator a key accepts is fixed by the field it names,
447
- * and a range on a keyword field cannot be written at all. This is the
448
- * single vocabulary every level of `where` is built from: the same keys
449
- * appear on the clause and on a criterion, so a field is never named a
450
- * second way.
451
- */
452
- const fieldKeys = () => {
453
- const fields = {
454
- // Typed self-referentially – `TermWhere.id: TermFilter` – so a consumer
455
- // can resolve “the collection I am browsing” to “the filter type that
456
- // accepts its IRIs”, which is what makes the refined discovery strategy
457
- // work without hardcoding a single domain name.
458
- [ID_FIELD]: { type: targetFilter(typeName) },
459
- };
460
- for (const field of filterable) {
461
- fields[field.name] = {
462
- type: whereFieldType(field),
463
- description: field.description,
464
- };
465
- }
466
- 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) },
467
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
+ }
468
535
  // A criterion is an ATOM: exactly one field, enforced by `@oneOf`. That is
469
536
  // what keeps `where` a flat conjunction of disjunctions – a criterion that
470
537
  // could carry two keys would be a conjunction nested inside an `or`, and
471
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.
472
541
  const criterionInput = new GraphQLInputObjectType({
473
- name: `${typeName}Criterion`,
542
+ name: `${searchType.name}Criterion`,
474
543
  description: 'A condition on exactly one field. Used inside `or`, where the criteria are alternatives.',
475
544
  isOneOf: true,
476
- fields: fieldKeys,
545
+ fields: () => fieldKeys(searchType),
477
546
  });
478
547
  const orKey = {
479
548
  type: new GraphQLList(new GraphQLNonNull(criterionInput)),
@@ -485,18 +554,41 @@ export function buildGraphQLSchema(schema, options = {}) {
485
554
  // `or` holds only `@oneOf` criteria. So `where` stays a flat conjunction of
486
555
  // disjunctions at any nesting depth, and a reader meets one input type
487
556
  // instead of two near-identical ones.
488
- const whereInput = new GraphQLInputObjectType({
489
- name: `${typeName}Where`,
557
+ const created = new GraphQLInputObjectType({
558
+ name: `${searchType.name}Where`,
490
559
  description: 'Sibling keys are combined with AND. Use `or` for a disjunction, and `and` when a query needs more than one of them.',
491
560
  fields: () => ({
492
- ...fieldKeys(),
561
+ ...fieldKeys(searchType),
493
562
  [OR_KEY]: orKey,
494
563
  [AND_KEY]: {
495
- type: new GraphQLList(new GraphQLNonNull(whereInput)),
564
+ type: new GraphQLList(new GraphQLNonNull(created)),
496
565
  description: 'Further groups of conditions, all of which apply. The way to carry a second `or` disjunction alongside the first.',
497
566
  },
498
567
  }),
499
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);
500
592
  const sortValues = {
501
593
  RELEVANCE: { value: 'relevance' },
502
594
  };
@@ -545,14 +637,34 @@ export function buildGraphQLSchema(schema, options = {}) {
545
637
  return {
546
638
  type: new GraphQLNonNull(resultType),
547
639
  args: {
548
- query: { type: GraphQLString },
549
- where: { type: whereInput },
550
- orderBy: { type: orderByInput },
551
- page: { type: GraphQLInt, defaultValue: 1 },
552
- perPage: { type: GraphQLInt, defaultValue: 20 },
640
+ query: {
641
+ type: GraphQLString,
642
+ description: 'Free-text query. Omit it to browse by filter alone.',
643
+ },
644
+ where: {
645
+ type: whereInput,
646
+ description: 'Conditions every result must satisfy.',
647
+ },
648
+ orderBy: {
649
+ type: orderByInput,
650
+ description: 'Sort order. Defaults to relevance for a free-text query.',
651
+ },
652
+ page: {
653
+ type: GraphQLInt,
654
+ defaultValue: 1,
655
+ // The bound in the SDL, so the playground’s own documentation
656
+ // answers “how large may a page be?” before a request has to fail
657
+ // to say it.
658
+ description: '1-based page number; at least 1.',
659
+ },
660
+ perPage: {
661
+ type: GraphQLInt,
662
+ defaultValue: 20,
663
+ 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.`,
664
+ },
553
665
  },
554
666
  resolve: async (_source, args, context) => {
555
- const built = argsToQuery(args, context, searchType, maxPerPage);
667
+ const built = argsToQuery(args, context, searchType, maxPerPage, joins);
556
668
  const finalQuery = typeOptions?.queryDefaults
557
669
  ? typeOptions.queryDefaults(built, context)
558
670
  : built;
@@ -632,19 +744,19 @@ export function printGraphQLSchema(schema, options = {}) {
632
744
  /** Pure args → {@link SearchQuery} mapping. Rejects out-of-bounds paging
633
745
  * (`page < 1`, `perPage` outside `1..maxPerPage`) with a clear error – a
634
746
  * negative offset or an unbounded page size must not reach the engine. */
635
- function argsToQuery(args, context, searchType, maxPerPage) {
747
+ function argsToQuery(args, context, searchType, maxPerPage, joins) {
636
748
  const perPage = args.perPage ?? 20;
637
749
  const page = args.page ?? 1;
638
750
  if (page < 1) {
639
- throw new Error(`page must be at least 1; got ${page}.`);
751
+ throw userError(`page must be at least 1; got ${page}.`);
640
752
  }
641
753
  // perPage: 0 is a legitimate facet-only query (no hits, page pins to 1).
642
754
  if (perPage < 0 || perPage > maxPerPage) {
643
- throw new Error(`perPage must be between 0 and ${maxPerPage}; got ${perPage}.`);
755
+ throw userError(`perPage must be between 0 and ${maxPerPage}; got ${perPage}.`);
644
756
  }
645
757
  return {
646
758
  text: args.query,
647
- where: whereToFilters(args.where, searchType),
759
+ where: whereToFilters(args.where, searchType, joins),
648
760
  orderBy: args.orderBy
649
761
  ? [{ field: args.orderBy.field, direction: args.orderBy.direction }]
650
762
  : [],
@@ -655,18 +767,37 @@ function argsToQuery(args, context, searchType, maxPerPage) {
655
767
  locale: context.acceptLanguage[0] ?? 'und',
656
768
  };
657
769
  }
658
- 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 = []) {
659
780
  // `== null` deliberately: an explicit `where: null` is as absent as an omitted
660
781
  // one, and reading keys off it would throw.
661
782
  if (where == null) {
662
783
  return [];
663
784
  }
664
- const filters = criteriaOf(where, searchType).map(filterOn);
785
+ const filters = [];
665
786
  // Sibling field keys AND, so each becomes a one-criterion filter of its own,
666
787
  // while `or` becomes a SINGLE filter carrying every alternative. That is the
667
788
  // whole AND/OR mapping: which bucket a criterion lands in, never a combinator
668
789
  // inferred from how deeply it is nested.
669
- 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));
670
801
  if (alternatives.length > 0) {
671
802
  filters.push({ or: alternatives });
672
803
  }
@@ -675,47 +806,105 @@ function whereToFilters(where, searchType) {
675
806
  // result flat: nested conjunctions collapse into this one list, and an `and`
676
807
  // can never sit inside an `or` (which holds only criteria).
677
808
  for (const nested of where[AND_KEY] ?? []) {
678
- filters.push(...whereToFilters(nested, searchType));
809
+ filters.push(...whereToFilters(nested, searchType, joins, on));
679
810
  }
680
811
  return filters;
681
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
+ }
682
818
  /**
683
- * The criteria a keyed object carries, in declaration order. A `Criterion` is
684
- * `@oneOf`, so it yields exactly one; a `Where`/`Clause` yields one per field
685
- * key it sets. `id` is read first: no type declares it, so the field loop below
686
- * 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.
687
828
  */
688
- function criteriaOf(keyed, searchType) {
829
+ function criteriaOf(keyed, searchType, joins, on = []) {
689
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 = [];
690
864
  const id = keyed[ID_FIELD];
691
865
  if (id !== undefined && id !== null) {
692
- criteria.push({ field: ID_FIELD, in: id.in ?? [] });
866
+ entries.push({ criterion: { field: ID_FIELD, in: id.in ?? [] } });
693
867
  }
694
868
  for (const field of filterableFields(searchType)) {
695
869
  const value = keyed[field.name];
696
870
  if (value === undefined || value === null) {
697
871
  continue;
698
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
+ }
699
882
  switch (filterOperatorFor(field.kind)) {
700
883
  case 'in':
701
- criteria.push({
702
- field: field.name,
703
- in: value.in ?? [],
884
+ entries.push({
885
+ criterion: {
886
+ field: field.name,
887
+ in: value.in ?? [],
888
+ },
704
889
  });
705
890
  break;
706
891
  case 'range': {
707
892
  const range = value;
708
- criteria.push({
709
- field: field.name,
710
- 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
+ },
711
898
  });
712
899
  break;
713
900
  }
714
901
  default:
715
- criteria.push({ field: field.name, is: value });
902
+ entries.push({
903
+ criterion: { field: field.name, is: value },
904
+ });
716
905
  }
717
906
  }
718
- return criteria;
907
+ return entries;
719
908
  }
720
909
  function rangeInput(name, bound) {
721
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.0",
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",