@lde/search-api-graphql 0.17.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,CAoaf;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)));
@@ -63,6 +63,20 @@ export function buildGraphQLSchema(schema, options = {}) {
63
63
  count: { type: new GraphQLNonNull(GraphQLInt) },
64
64
  },
65
65
  });
66
+ // A boolean-facet bucket: the value as a real boolean, so the bucket a client
67
+ // selects round-trips straight into the `is` filter that selects it. No label
68
+ // – a boolean has no data label to resolve, and the sensible rendering (“with
69
+ // an image” / “without one”) is knowable only by the consumer.
70
+ const booleanBucket = new GraphQLObjectType({
71
+ name: 'BooleanBucket',
72
+ fields: {
73
+ value: {
74
+ type: new GraphQLNonNull(GraphQLBoolean),
75
+ resolve: (bucket) => bucket.is,
76
+ },
77
+ count: { type: new GraphQLNonNull(GraphQLInt) },
78
+ },
79
+ });
66
80
  // The pagination actually applied (after queryDefaults), shared across every
67
81
  // ‹Type›SearchResult so one client pager fragment serves all root types.
68
82
  const paginationType = new GraphQLObjectType({
@@ -241,17 +255,54 @@ export function buildGraphQLSchema(schema, options = {}) {
241
255
  // field and always exists – no type is unaddressable by IRI, whatever it
242
256
  // declares.
243
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.
244
295
  const whereInput = new GraphQLInputObjectType({
245
296
  name: `${typeName}Where`,
246
- fields: () => {
247
- const fields = {
248
- [ID_FIELD]: { type: stringFilter },
249
- };
250
- for (const field of filterable) {
251
- fields[field.name] = { type: whereFieldType(field) };
252
- }
253
- return fields;
254
- },
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
+ }),
255
306
  });
256
307
  const sortValues = {
257
308
  RELEVANCE: { value: 'relevance' },
@@ -274,7 +325,8 @@ export function buildGraphQLSchema(schema, options = {}) {
274
325
  },
275
326
  });
276
327
  // Keyed facets object: one field per facetable field, typed by its kind
277
- // (range fields → [RangeBucket!], else [ValueBucket!]). Only the selected
328
+ // (range fields → [RangeBucket!], boolean fields → [BooleanBucket!], else
329
+ // [ValueBucket!]). Only the selected
278
330
  // fields are resolved (GraphQL prunes the rest), so the selection IS the
279
331
  // request; how they are computed – skip-own-filter, batched into one
280
332
  // engine dispatch – lives in facet-batch.ts.
@@ -329,6 +381,13 @@ export function buildGraphQLSchema(schema, options = {}) {
329
381
  },
330
382
  };
331
383
  }
384
+ /** The bucket type a facet’s kind earns: bins for a range facet, a real
385
+ * boolean for a boolean one, a keyed value otherwise. */
386
+ function bucketTypeFor(field) {
387
+ if (isRangeFacet(field))
388
+ return rangeBucket;
389
+ return field.kind === 'boolean' ? booleanBucket : valueBucket;
390
+ }
332
391
  /** The keyed facets object for one type (only called with ≥ 1 facetable field). */
333
392
  function facetsTypeFor(typeName, facetable) {
334
393
  return new GraphQLObjectType({
@@ -337,7 +396,7 @@ export function buildGraphQLSchema(schema, options = {}) {
337
396
  const fields = {};
338
397
  for (const field of facetable) {
339
398
  fields[field.name] = {
340
- type: nonNullListOf(isRangeFacet(field) ? rangeBucket : valueBucket),
399
+ type: nonNullListOf(bucketTypeFor(field)),
341
400
  // The skip-own-filter query building, the batching into one
342
401
  // engine dispatch and the degrade-to-[] error handling all live
343
402
  // in the loader (facet-batch.ts).
@@ -399,40 +458,66 @@ function argsToQuery(args, context, searchType, maxPerPage) {
399
458
  };
400
459
  }
401
460
  function whereToFilters(where, searchType) {
402
- 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) {
403
464
  return [];
404
465
  }
405
- const filters = [];
406
- // `id` first: it is declared by no type, so the field loop below never sees it.
407
- 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];
408
493
  if (id !== undefined && id !== null) {
409
- filters.push({ field: ID_FIELD, in: id.in ?? [] });
494
+ criteria.push({ field: ID_FIELD, in: id.in ?? [] });
410
495
  }
411
496
  for (const field of filterableFields(searchType)) {
412
- const value = where[field.name];
497
+ const value = keyed[field.name];
413
498
  if (value === undefined || value === null) {
414
499
  continue;
415
500
  }
416
501
  switch (filterOperatorFor(field.kind)) {
417
502
  case 'in':
418
- filters.push({
503
+ criteria.push({
419
504
  field: field.name,
420
505
  in: value.in ?? [],
421
506
  });
422
507
  break;
423
508
  case 'range': {
424
509
  const range = value;
425
- filters.push({
510
+ criteria.push({
426
511
  field: field.name,
427
512
  range: { min: range.min, max: range.max },
428
513
  });
429
514
  break;
430
515
  }
431
516
  default:
432
- filters.push({ field: field.name, is: value });
517
+ criteria.push({ field: field.name, is: value });
433
518
  }
434
519
  }
435
- return filters;
520
+ return criteria;
436
521
  }
437
522
  function rangeInput(name, bound) {
438
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.17.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.16.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"