@lde/search-api-graphql 0.18.0 → 0.19.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,EASL,aAAa,EAQd,MAAM,SAAS,CAAC;AACjB,OAAO,EAIL,KAAK,YAAY,EAEjB,KAAK,WAAW,EAChB,KAAK,YAAY,EAElB,MAAM,aAAa,CAAC;AAarB,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;AAkBD;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,YAAY,EACpB,OAAO,GAAE,yBAA8B,GACtC,aAAa,CAwbf;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,EASL,aAAa,EAQd,MAAM,SAAS,CAAC;AACjB,OAAO,EAKL,KAAK,YAAY,EAEjB,KAAK,WAAW,EAChB,KAAK,YAAY,EAElB,MAAM,aAAa,CAAC;AAgBrB,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;AAkBD;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,YAAY,EACpB,OAAO,GAAE,yBAA8B,GACtC,aAAa,CAkef;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, GraphQLFloat, GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLNonNull, GraphQLObjectType, GraphQLSchema, GraphQLString, printSchema, } from 'graphql';
2
- import { facetableFields, filterableFields, filterOperatorFor, ID_FIELD, isRangeFacet, nestedReferenceType, outputFields, pageForOffset, sortableFields, unixSecondsToIso, } from '@lde/search/adapter';
2
+ import { AND_KEY, facetableFields, filterableFields, filterOn, filterOperatorFor, ID_FIELD, OR_KEY, isRangeFacet, 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)));
@@ -255,17 +255,54 @@ export function buildGraphQLSchema(schema, options = {}) {
255
255
  // field and always exists – no type is unaddressable by IRI, whatever it
256
256
  // declares.
257
257
  const filterable = filterableFields(searchType);
258
+ /**
259
+ * One key per filterable field (plus the undeclared `id`), each typed by its
260
+ * OWN kind – so the operator a key accepts is fixed by the field it names,
261
+ * and a range on a keyword field cannot be written at all. This is the
262
+ * single vocabulary every level of `where` is built from: the same keys
263
+ * appear on the clause and on a criterion, so a field is never named a
264
+ * second way.
265
+ */
266
+ const fieldKeys = () => {
267
+ const fields = {
268
+ [ID_FIELD]: { type: stringFilter },
269
+ };
270
+ for (const field of filterable) {
271
+ fields[field.name] = { type: whereFieldType(field) };
272
+ }
273
+ return fields;
274
+ };
275
+ // A criterion is an ATOM: exactly one field, enforced by `@oneOf`. That is
276
+ // what keeps `where` a flat conjunction of disjunctions – a criterion that
277
+ // could carry two keys would be a conjunction nested inside an `or`, and
278
+ // skip-own-filter (ADR 5) has no answer for a clause buried inside another.
279
+ const criterionInput = new GraphQLInputObjectType({
280
+ name: `${typeName}Criterion`,
281
+ description: 'A condition on exactly one field. Used inside `or`, where the criteria are alternatives.',
282
+ isOneOf: true,
283
+ fields: fieldKeys,
284
+ });
285
+ const orKey = {
286
+ type: new GraphQLList(new GraphQLNonNull(criterionInput)),
287
+ description: 'A disjunction: a document matches when ANY of these criteria holds. Combined with the sibling keys by AND, so it widens across fields without widening the query as a whole.',
288
+ };
289
+ // `and` carries further `Where`s rather than a separate clause type. Safe to
290
+ // be recursive: a conjunction inside a conjunction FLATTENS, and the one
291
+ // shape that would not – an `and` inside an `or` – is unreachable, because
292
+ // `or` holds only `@oneOf` criteria. So `where` stays a flat conjunction of
293
+ // disjunctions at any nesting depth, and a reader meets one input type
294
+ // instead of two near-identical ones.
258
295
  const whereInput = new GraphQLInputObjectType({
259
296
  name: `${typeName}Where`,
260
- fields: () => {
261
- const fields = {
262
- [ID_FIELD]: { type: stringFilter },
263
- };
264
- for (const field of filterable) {
265
- fields[field.name] = { type: whereFieldType(field) };
266
- }
267
- return fields;
268
- },
297
+ description: 'Sibling keys are combined with AND. Use `or` for a disjunction, and `and` when a query needs more than one of them.',
298
+ fields: () => ({
299
+ ...fieldKeys(),
300
+ [OR_KEY]: orKey,
301
+ [AND_KEY]: {
302
+ type: new GraphQLList(new GraphQLNonNull(whereInput)),
303
+ description: 'Further groups of conditions, all of which apply. The way to carry a second `or` disjunction alongside the first.',
304
+ },
305
+ }),
269
306
  });
270
307
  const sortValues = {
271
308
  RELEVANCE: { value: 'relevance' },
@@ -421,40 +458,66 @@ function argsToQuery(args, context, searchType, maxPerPage) {
421
458
  };
422
459
  }
423
460
  function whereToFilters(where, searchType) {
424
- if (where === undefined) {
461
+ // `== null` deliberately: an explicit `where: null` is as absent as an omitted
462
+ // one, and reading keys off it would throw.
463
+ if (where == null) {
425
464
  return [];
426
465
  }
427
- const filters = [];
428
- // `id` first: it is declared by no type, so the field loop below never sees it.
429
- const id = where[ID_FIELD];
466
+ const filters = criteriaOf(where, searchType).map(filterOn);
467
+ // Sibling field keys AND, so each becomes a one-criterion filter of its own,
468
+ // while `or` becomes a SINGLE filter carrying every alternative. That is the
469
+ // whole AND/OR mapping: which bucket a criterion lands in, never a combinator
470
+ // inferred from how deeply it is nested.
471
+ const alternatives = (where[OR_KEY] ?? []).flatMap((criterion) => criteriaOf(criterion, searchType));
472
+ if (alternatives.length > 0) {
473
+ filters.push({ or: alternatives });
474
+ }
475
+ // `and` carries further groups, each contributing its own filters – which is
476
+ // how a query states a second `or` alongside the first. Recursing keeps the
477
+ // result flat: nested conjunctions collapse into this one list, and an `and`
478
+ // can never sit inside an `or` (which holds only criteria).
479
+ for (const nested of where[AND_KEY] ?? []) {
480
+ filters.push(...whereToFilters(nested, searchType));
481
+ }
482
+ return filters;
483
+ }
484
+ /**
485
+ * The criteria a keyed object carries, in declaration order. A `Criterion` is
486
+ * `@oneOf`, so it yields exactly one; a `Where`/`Clause` yields one per field
487
+ * key it sets. `id` is read first: no type declares it, so the field loop below
488
+ * never sees it.
489
+ */
490
+ function criteriaOf(keyed, searchType) {
491
+ const criteria = [];
492
+ const id = keyed[ID_FIELD];
430
493
  if (id !== undefined && id !== null) {
431
- filters.push({ field: ID_FIELD, in: id.in ?? [] });
494
+ criteria.push({ field: ID_FIELD, in: id.in ?? [] });
432
495
  }
433
496
  for (const field of filterableFields(searchType)) {
434
- const value = where[field.name];
497
+ const value = keyed[field.name];
435
498
  if (value === undefined || value === null) {
436
499
  continue;
437
500
  }
438
501
  switch (filterOperatorFor(field.kind)) {
439
502
  case 'in':
440
- filters.push({
503
+ criteria.push({
441
504
  field: field.name,
442
505
  in: value.in ?? [],
443
506
  });
444
507
  break;
445
508
  case 'range': {
446
509
  const range = value;
447
- filters.push({
510
+ criteria.push({
448
511
  field: field.name,
449
512
  range: { min: range.min, max: range.max },
450
513
  });
451
514
  break;
452
515
  }
453
516
  default:
454
- filters.push({ field: field.name, is: value });
517
+ criteria.push({ field: field.name, is: value });
455
518
  }
456
519
  }
457
- return filters;
520
+ return criteria;
458
521
  }
459
522
  function rangeInput(name, bound) {
460
523
  return new GraphQLInputObjectType({
@@ -28,6 +28,17 @@ export declare function createFacetLoader(engine: SearchEngine, searchType: Root
28
28
  * a policy default on that field, e.g. valid-only `status`, so the facet
29
29
  * counts across every value.) The queries are facet-only: no hits (`limit:
30
30
  * 0`) and, with no hits to order, no `orderBy`.
31
+ *
32
+ * A clause is a facet’s **own** only when it carries exactly one criterion, on
33
+ * that field ({@link ownsFacet}). A disjunction (`or`) is nobody’s own and
34
+ * always stays: the user constrained the document as a whole, never that one
35
+ * axis, so there is no selection on it to widen. Keeping it is what leaves each facet
36
+ * *complete on its own field* – every value it offers is one the user can pick,
37
+ * with the count they will actually get. Dropping the clause’s own disjunct
38
+ * instead would hide values that do return hits (on an entity page: the very
39
+ * entity the page is about, absent from a sidebar beside results full of it),
40
+ * and dropping it whole would count a corpus the sibling facets do not.
41
+ * It also keeps a multi-field clause from ever splitting the batch.
31
42
  */
32
43
  export declare function groupFacetQueries(query: SearchQuery, fields: readonly string[]): SearchQuery[];
33
44
  //# sourceMappingURL=facet-batch.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"facet-batch.d.ts","sourceRoot":"","sources":["../src/facet-batch.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,WAAW,EACX,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;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,WAAW,EAClB,MAAM,EAAE,SAAS,MAAM,EAAE,GACxB,WAAW,EAAE,CAgBf"}
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"}
@@ -60,9 +60,22 @@ export function createFacetLoader(engine, searchType, query, onFacetError) {
60
60
  * a policy default on that field, e.g. valid-only `status`, so the facet
61
61
  * counts across every value.) The queries are facet-only: no hits (`limit:
62
62
  * 0`) and, with no hits to order, no `orderBy`.
63
+ *
64
+ * A clause is a facet’s **own** only when it carries exactly one criterion, on
65
+ * that field ({@link ownsFacet}). A disjunction (`or`) is nobody’s own and
66
+ * always stays: the user constrained the document as a whole, never that one
67
+ * axis, so there is no selection on it to widen. Keeping it is what leaves each facet
68
+ * *complete on its own field* – every value it offers is one the user can pick,
69
+ * with the count they will actually get. Dropping the clause’s own disjunct
70
+ * instead would hide values that do return hits (on an entity page: the very
71
+ * entity the page is about, absent from a sidebar beside results full of it),
72
+ * and dropping it whole would count a corpus the sibling facets do not.
73
+ * It also keeps a multi-field clause from ever splitting the batch.
63
74
  */
64
75
  export function groupFacetQueries(query, fields) {
65
- const filteredFields = new Set(query.where.map((filter) => filter.field));
76
+ const filteredFields = new Set(query.where
77
+ .map(ownedField)
78
+ .filter((field) => field !== undefined));
66
79
  const facetOnly = { ...query, orderBy: [], limit: 0, offset: 0 };
67
80
  const sharedFields = fields.filter((field) => !filteredFields.has(field));
68
81
  const queries = [];
@@ -72,9 +85,37 @@ export function groupFacetQueries(query, fields) {
72
85
  for (const field of fields.filter((field) => filteredFields.has(field))) {
73
86
  queries.push({
74
87
  ...facetOnly,
75
- where: query.where.filter((filter) => filter.field !== field),
88
+ where: query.where.filter((filter) => !ownsFacet(filter, field)),
76
89
  facets: [field],
77
90
  });
78
91
  }
79
92
  return queries;
80
93
  }
94
+ /** Whether a clause is `field`’s own – it constrains that field and nothing
95
+ * else, so skip-own-filter removes it when counting that facet. */
96
+ function ownsFacet(filter, field) {
97
+ return ownedField(filter) === field;
98
+ }
99
+ /**
100
+ * The field a clause belongs to, or `undefined` when it belongs to none.
101
+ *
102
+ * A clause owns a field when **every** criterion names it – which covers the
103
+ * ordinary one-criterion filter and equally a same-field disjunction
104
+ * (`or: [{ created: { max: … } }, { created: { min: … } }]`, or a multi-select
105
+ * spelled as alternatives). Those ARE a selection on one axis, so skip-own-filter
106
+ * must drop them; leaving them in would compute the facet with the user’s own
107
+ * selection applied, offering back only what they already picked.
108
+ *
109
+ * A clause spanning several fields is nobody’s own: the user constrained the
110
+ * document as a whole, never one axis, so there is nothing on any single facet
111
+ * to widen.
112
+ */
113
+ function ownedField(filter) {
114
+ const [first, ...rest] = filter.or;
115
+ if (first === undefined) {
116
+ return undefined;
117
+ }
118
+ return rest.every((criterion) => criterion.field === first.field)
119
+ ? first.field
120
+ : undefined;
121
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lde/search-api-graphql",
3
- "version": "0.18.0",
3
+ "version": "0.19.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",
@@ -28,9 +28,9 @@
28
28
  "@escape.tech/graphql-armor-cost-limit": "^2.4.3",
29
29
  "@escape.tech/graphql-armor-max-depth": "^2.4.2",
30
30
  "@graphql-yoga/render-graphiql": "^5.21.2",
31
- "@lde/search": "^0.17.0",
31
+ "@lde/search": "^0.18.0",
32
32
  "dataloader": "^2.2.3",
33
- "graphql": "^16.0.0",
33
+ "graphql": "^16.9.0",
34
34
  "graphql-yoga": "^5.21.2",
35
35
  "negotiator": "^1.0.0",
36
36
  "tslib": "^2.3.0"