@lde/search-api-graphql 0.18.0 → 0.20.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.
- package/dist/build-schema.d.ts.map +1 -1
- package/dist/build-schema.js +87 -21
- package/dist/facet-batch.d.ts +11 -0
- package/dist/facet-batch.d.ts.map +1 -1
- package/dist/facet-batch.js +43 -2
- package/package.json +3 -3
|
@@ -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,
|
|
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,CAqef;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,YAAY,EACpB,OAAO,GAAE,yBAA8B,GACtC,MAAM,CAER"}
|
package/dist/build-schema.js
CHANGED
|
@@ -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)));
|
|
@@ -152,7 +152,10 @@ export function buildGraphQLSchema(schema, options = {}) {
|
|
|
152
152
|
fields: () => nested === undefined
|
|
153
153
|
? {
|
|
154
154
|
id: { type: new GraphQLNonNull(GraphQLString) },
|
|
155
|
-
|
|
155
|
+
// `label`, the same word the label source declares and a
|
|
156
|
+
// reference facet’s bucket carries: one resolved label, one
|
|
157
|
+
// name for it wherever it surfaces.
|
|
158
|
+
label: labelList((source) => source.label),
|
|
156
159
|
}
|
|
157
160
|
: {
|
|
158
161
|
id: { type: GraphQLString },
|
|
@@ -255,17 +258,54 @@ export function buildGraphQLSchema(schema, options = {}) {
|
|
|
255
258
|
// field and always exists – no type is unaddressable by IRI, whatever it
|
|
256
259
|
// declares.
|
|
257
260
|
const filterable = filterableFields(searchType);
|
|
261
|
+
/**
|
|
262
|
+
* One key per filterable field (plus the undeclared `id`), each typed by its
|
|
263
|
+
* OWN kind – so the operator a key accepts is fixed by the field it names,
|
|
264
|
+
* and a range on a keyword field cannot be written at all. This is the
|
|
265
|
+
* single vocabulary every level of `where` is built from: the same keys
|
|
266
|
+
* appear on the clause and on a criterion, so a field is never named a
|
|
267
|
+
* second way.
|
|
268
|
+
*/
|
|
269
|
+
const fieldKeys = () => {
|
|
270
|
+
const fields = {
|
|
271
|
+
[ID_FIELD]: { type: stringFilter },
|
|
272
|
+
};
|
|
273
|
+
for (const field of filterable) {
|
|
274
|
+
fields[field.name] = { type: whereFieldType(field) };
|
|
275
|
+
}
|
|
276
|
+
return fields;
|
|
277
|
+
};
|
|
278
|
+
// A criterion is an ATOM: exactly one field, enforced by `@oneOf`. That is
|
|
279
|
+
// what keeps `where` a flat conjunction of disjunctions – a criterion that
|
|
280
|
+
// could carry two keys would be a conjunction nested inside an `or`, and
|
|
281
|
+
// skip-own-filter (ADR 5) has no answer for a clause buried inside another.
|
|
282
|
+
const criterionInput = new GraphQLInputObjectType({
|
|
283
|
+
name: `${typeName}Criterion`,
|
|
284
|
+
description: 'A condition on exactly one field. Used inside `or`, where the criteria are alternatives.',
|
|
285
|
+
isOneOf: true,
|
|
286
|
+
fields: fieldKeys,
|
|
287
|
+
});
|
|
288
|
+
const orKey = {
|
|
289
|
+
type: new GraphQLList(new GraphQLNonNull(criterionInput)),
|
|
290
|
+
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.',
|
|
291
|
+
};
|
|
292
|
+
// `and` carries further `Where`s rather than a separate clause type. Safe to
|
|
293
|
+
// be recursive: a conjunction inside a conjunction FLATTENS, and the one
|
|
294
|
+
// shape that would not – an `and` inside an `or` – is unreachable, because
|
|
295
|
+
// `or` holds only `@oneOf` criteria. So `where` stays a flat conjunction of
|
|
296
|
+
// disjunctions at any nesting depth, and a reader meets one input type
|
|
297
|
+
// instead of two near-identical ones.
|
|
258
298
|
const whereInput = new GraphQLInputObjectType({
|
|
259
299
|
name: `${typeName}Where`,
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
},
|
|
300
|
+
description: 'Sibling keys are combined with AND. Use `or` for a disjunction, and `and` when a query needs more than one of them.',
|
|
301
|
+
fields: () => ({
|
|
302
|
+
...fieldKeys(),
|
|
303
|
+
[OR_KEY]: orKey,
|
|
304
|
+
[AND_KEY]: {
|
|
305
|
+
type: new GraphQLList(new GraphQLNonNull(whereInput)),
|
|
306
|
+
description: 'Further groups of conditions, all of which apply. The way to carry a second `or` disjunction alongside the first.',
|
|
307
|
+
},
|
|
308
|
+
}),
|
|
269
309
|
});
|
|
270
310
|
const sortValues = {
|
|
271
311
|
RELEVANCE: { value: 'relevance' },
|
|
@@ -421,40 +461,66 @@ function argsToQuery(args, context, searchType, maxPerPage) {
|
|
|
421
461
|
};
|
|
422
462
|
}
|
|
423
463
|
function whereToFilters(where, searchType) {
|
|
424
|
-
|
|
464
|
+
// `== null` deliberately: an explicit `where: null` is as absent as an omitted
|
|
465
|
+
// one, and reading keys off it would throw.
|
|
466
|
+
if (where == null) {
|
|
425
467
|
return [];
|
|
426
468
|
}
|
|
427
|
-
const filters =
|
|
428
|
-
//
|
|
429
|
-
|
|
469
|
+
const filters = criteriaOf(where, searchType).map(filterOn);
|
|
470
|
+
// Sibling field keys AND, so each becomes a one-criterion filter of its own,
|
|
471
|
+
// while `or` becomes a SINGLE filter carrying every alternative. That is the
|
|
472
|
+
// whole AND/OR mapping: which bucket a criterion lands in, never a combinator
|
|
473
|
+
// inferred from how deeply it is nested.
|
|
474
|
+
const alternatives = (where[OR_KEY] ?? []).flatMap((criterion) => criteriaOf(criterion, searchType));
|
|
475
|
+
if (alternatives.length > 0) {
|
|
476
|
+
filters.push({ or: alternatives });
|
|
477
|
+
}
|
|
478
|
+
// `and` carries further groups, each contributing its own filters – which is
|
|
479
|
+
// how a query states a second `or` alongside the first. Recursing keeps the
|
|
480
|
+
// result flat: nested conjunctions collapse into this one list, and an `and`
|
|
481
|
+
// can never sit inside an `or` (which holds only criteria).
|
|
482
|
+
for (const nested of where[AND_KEY] ?? []) {
|
|
483
|
+
filters.push(...whereToFilters(nested, searchType));
|
|
484
|
+
}
|
|
485
|
+
return filters;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* The criteria a keyed object carries, in declaration order. A `Criterion` is
|
|
489
|
+
* `@oneOf`, so it yields exactly one; a `Where`/`Clause` yields one per field
|
|
490
|
+
* key it sets. `id` is read first: no type declares it, so the field loop below
|
|
491
|
+
* never sees it.
|
|
492
|
+
*/
|
|
493
|
+
function criteriaOf(keyed, searchType) {
|
|
494
|
+
const criteria = [];
|
|
495
|
+
const id = keyed[ID_FIELD];
|
|
430
496
|
if (id !== undefined && id !== null) {
|
|
431
|
-
|
|
497
|
+
criteria.push({ field: ID_FIELD, in: id.in ?? [] });
|
|
432
498
|
}
|
|
433
499
|
for (const field of filterableFields(searchType)) {
|
|
434
|
-
const value =
|
|
500
|
+
const value = keyed[field.name];
|
|
435
501
|
if (value === undefined || value === null) {
|
|
436
502
|
continue;
|
|
437
503
|
}
|
|
438
504
|
switch (filterOperatorFor(field.kind)) {
|
|
439
505
|
case 'in':
|
|
440
|
-
|
|
506
|
+
criteria.push({
|
|
441
507
|
field: field.name,
|
|
442
508
|
in: value.in ?? [],
|
|
443
509
|
});
|
|
444
510
|
break;
|
|
445
511
|
case 'range': {
|
|
446
512
|
const range = value;
|
|
447
|
-
|
|
513
|
+
criteria.push({
|
|
448
514
|
field: field.name,
|
|
449
515
|
range: { min: range.min, max: range.max },
|
|
450
516
|
});
|
|
451
517
|
break;
|
|
452
518
|
}
|
|
453
519
|
default:
|
|
454
|
-
|
|
520
|
+
criteria.push({ field: field.name, is: value });
|
|
455
521
|
}
|
|
456
522
|
}
|
|
457
|
-
return
|
|
523
|
+
return criteria;
|
|
458
524
|
}
|
|
459
525
|
function rangeInput(name, bound) {
|
|
460
526
|
return new GraphQLInputObjectType({
|
package/dist/facet-batch.d.ts
CHANGED
|
@@ -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,
|
|
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"}
|
package/dist/facet-batch.js
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.20.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.
|
|
31
|
+
"@lde/search": "^0.18.0",
|
|
32
32
|
"dataloader": "^2.2.3",
|
|
33
|
-
"graphql": "^16.
|
|
33
|
+
"graphql": "^16.9.0",
|
|
34
34
|
"graphql-yoga": "^5.21.2",
|
|
35
35
|
"negotiator": "^1.0.0",
|
|
36
36
|
"tslib": "^2.3.0"
|