@lde/search-api-graphql 0.1.0 → 0.2.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/README.md +10 -4
- package/dist/build-schema.d.ts +5 -3
- package/dist/build-schema.d.ts.map +1 -1
- package/dist/build-schema.js +14 -35
- package/dist/facet-batch.d.ts +33 -0
- package/dist/facet-batch.d.ts.map +1 -0
- package/dist/facet-batch.js +80 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -84,10 +84,16 @@ const gqlSchema = buildGraphQLSchema(searchSchema(DATASET, PERSON));
|
|
|
84
84
|
`FloatRange` / `DateRange`, or `Boolean`); omitted entirely for a type with no
|
|
85
85
|
filterable fields.
|
|
86
86
|
- **`orderBy`**: `RELEVANCE` plus every `sortable` field, as an enum.
|
|
87
|
-
- **Facets**:
|
|
88
|
-
`count` + a nullable `label`
|
|
89
|
-
`null` for token/free-string facets whose display the
|
|
90
|
-
i18n, or the value itself).
|
|
87
|
+
- **Facets**: a keyed object with one field per `facetable` field; a bucket
|
|
88
|
+
carries `value` + `count` + a nullable `label` – the resolved data label for
|
|
89
|
+
**reference** facets, `null` for token/free-string facets whose display the
|
|
90
|
+
consumer owns (its own i18n, or the value itself). Selecting facet fields IS
|
|
91
|
+
the request: each selected facet is computed with its own `where`-filter
|
|
92
|
+
removed (skip-own-filter), and the whole selection is **batched per
|
|
93
|
+
request** – facets whose field carries no active filter share one query
|
|
94
|
+
(the unfiltered browse collapses to a single query) and everything is
|
|
95
|
+
dispatched as one `engine.searchFacets` call, so a typical page costs the
|
|
96
|
+
listing search plus one batched facet round-trip.
|
|
91
97
|
|
|
92
98
|
## Guarding the contract
|
|
93
99
|
|
package/dist/build-schema.d.ts
CHANGED
|
@@ -10,9 +10,11 @@ export interface SearchContext {
|
|
|
10
10
|
/** Parsed, ordered `Accept-Language`; drives locale selection and output order. */
|
|
11
11
|
readonly acceptLanguage: readonly string[];
|
|
12
12
|
/**
|
|
13
|
-
* Called
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* Called once per affected facet field when its computation fails – for a
|
|
14
|
+
* failed facet query only that query's fields, for a failed batch dispatch
|
|
15
|
+
* every selected field. The affected facets degrade to empty lists (a
|
|
16
|
+
* supplementary facet must not fail the whole query); supply this to log
|
|
17
|
+
* the cause. Optional – omit to swallow silently.
|
|
16
18
|
*/
|
|
17
19
|
readonly onFacetError?: (field: string, error: unknown) => void;
|
|
18
20
|
}
|
|
@@ -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,EAGL,KAAK,YAAY,EAEjB,KAAK,WAAW,EAChB,KAAK,YAAY,EAElB,MAAM,aAAa,CAAC;AAWrB,OAAO,EAGL,KAAK,aAAa,EACnB,MAAM,eAAe,CAAC;
|
|
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,EAGL,KAAK,YAAY,EAEjB,KAAK,WAAW,EAChB,KAAK,YAAY,EAElB,MAAM,aAAa,CAAC;AAWrB,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,CAwWf;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,YAAY,EACpB,OAAO,GAAE,yBAA8B,GACtC,MAAM,CAER"}
|
package/dist/build-schema.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { GraphQLBoolean, GraphQLEnumType, GraphQLFloat, GraphQLInputObjectType, GraphQLInt, GraphQLList, GraphQLNonNull, GraphQLObjectType, GraphQLSchema, GraphQLString, printSchema, } from 'graphql';
|
|
2
2
|
import { facetableFields, filterableFields, filterOperatorFor, isRangeFacet, outputFields, pageForOffset, sortableFields, unixSecondsToIso, } from '@lde/search/adapter';
|
|
3
3
|
import { defaultLanguageOrder, toLanguageStrings, } from './language.js';
|
|
4
|
+
import { createFacetLoader } from './facet-batch.js';
|
|
4
5
|
const nonNullListOf = (type) => new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(type)));
|
|
5
6
|
const scalarOutput = (scalar, field) => field.required === true ? new GraphQLNonNull(scalar) : scalar;
|
|
6
7
|
/** SCREAMING_SNAKE_CASE for an enum value name, e.g. `datePosted` → `DATE_POSTED`. */
|
|
@@ -222,16 +223,14 @@ export function buildGraphQLSchema(schema, options = {}) {
|
|
|
222
223
|
},
|
|
223
224
|
});
|
|
224
225
|
// Keyed facets object: one field per facetable field, typed by its kind
|
|
225
|
-
// (range fields → [RangeBucket!], else [ValueBucket!]).
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
226
|
+
// (range fields → [RangeBucket!], else [ValueBucket!]). Only the selected
|
|
227
|
+
// fields are resolved (GraphQL prunes the rest), so the selection IS the
|
|
228
|
+
// request; how they are computed – skip-own-filter, batched into one
|
|
229
|
+
// engine dispatch – lives in facet-batch.ts.
|
|
229
230
|
// Like `where`, omitted entirely for a type with no facetable fields (a
|
|
230
231
|
// GraphQL object type must have at least one field).
|
|
231
232
|
const facetable = facetableFields(searchType);
|
|
232
|
-
const facetsType = facetable.length === 0
|
|
233
|
-
? undefined
|
|
234
|
-
: facetsTypeFor(searchType, typeName, facetable);
|
|
233
|
+
const facetsType = facetable.length === 0 ? undefined : facetsTypeFor(typeName, facetable);
|
|
235
234
|
const resultType = new GraphQLObjectType({
|
|
236
235
|
name: `${typeName}SearchResult`,
|
|
237
236
|
fields: {
|
|
@@ -240,7 +239,7 @@ export function buildGraphQLSchema(schema, options = {}) {
|
|
|
240
239
|
page: { type: new GraphQLNonNull(GraphQLInt) },
|
|
241
240
|
perPage: { type: new GraphQLNonNull(GraphQLInt) },
|
|
242
241
|
// Resolved lazily, per selected key (skip-own-filter); the result object
|
|
243
|
-
// (which carries the
|
|
242
|
+
// (which carries the per-request facet loader) is the facets source.
|
|
244
243
|
...(facetsType && {
|
|
245
244
|
facets: {
|
|
246
245
|
type: new GraphQLNonNull(facetsType),
|
|
@@ -273,14 +272,14 @@ export function buildGraphQLSchema(schema, options = {}) {
|
|
|
273
272
|
total: result.total,
|
|
274
273
|
page: pageForOffset(finalQuery.offset, finalQuery.limit),
|
|
275
274
|
perPage: finalQuery.limit,
|
|
276
|
-
// Carried for the
|
|
277
|
-
|
|
275
|
+
// Carried for the facet field resolvers (see facet-batch.ts).
|
|
276
|
+
loadFacet: createFacetLoader(context.engine, searchType, finalQuery, context.onFacetError),
|
|
278
277
|
};
|
|
279
278
|
},
|
|
280
279
|
};
|
|
281
280
|
}
|
|
282
281
|
/** The keyed facets object for one type (only called with ≥ 1 facetable field). */
|
|
283
|
-
function facetsTypeFor(
|
|
282
|
+
function facetsTypeFor(typeName, facetable) {
|
|
284
283
|
return new GraphQLObjectType({
|
|
285
284
|
name: `${typeName}Facets`,
|
|
286
285
|
fields: () => {
|
|
@@ -288,30 +287,10 @@ export function buildGraphQLSchema(schema, options = {}) {
|
|
|
288
287
|
for (const field of facetable) {
|
|
289
288
|
fields[field.name] = {
|
|
290
289
|
type: nonNullListOf(isRangeFacet(field) ? rangeBucket : valueBucket),
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
// the status facet counts across every status).
|
|
296
|
-
const facetQuery = {
|
|
297
|
-
...query,
|
|
298
|
-
where: query.where.filter((filter) => filter.field !== field.name),
|
|
299
|
-
facets: [field.name],
|
|
300
|
-
limit: 0,
|
|
301
|
-
offset: 0,
|
|
302
|
-
};
|
|
303
|
-
// A facet is supplementary: degrade a failed facet to an empty list
|
|
304
|
-
// rather than failing the whole query (which would null the non-null
|
|
305
|
-
// result and discard the items + every other facet).
|
|
306
|
-
try {
|
|
307
|
-
const result = await context.engine.search(searchType, facetQuery);
|
|
308
|
-
return result.facets[field.name] ?? [];
|
|
309
|
-
}
|
|
310
|
-
catch (error) {
|
|
311
|
-
context.onFacetError?.(field.name, error);
|
|
312
|
-
return [];
|
|
313
|
-
}
|
|
314
|
-
},
|
|
290
|
+
// The skip-own-filter query building, the batching into one
|
|
291
|
+
// engine dispatch and the degrade-to-[] error handling all live
|
|
292
|
+
// in the loader (facet-batch.ts).
|
|
293
|
+
resolve: (source) => source.loadFacet(field.name),
|
|
315
294
|
};
|
|
316
295
|
}
|
|
317
296
|
return fields;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { FacetBucket, SearchEngine, SearchQuery, SearchType } from '@lde/search';
|
|
2
|
+
/** Resolves one selected facet field to its buckets; see {@link createFacetLoader}. */
|
|
3
|
+
export type FacetLoader = (field: string) => Promise<readonly FacetBucket[]>;
|
|
4
|
+
/**
|
|
5
|
+
* A per-request batcher behind the keyed facets object. Each selected facet
|
|
6
|
+
* field’s resolver calls the loader; GraphQL resolves the sibling facet
|
|
7
|
+
* fields synchronously, so the loads land in the same tick, where the
|
|
8
|
+
* DataLoader collects them into one batch, which is grouped into the fewest
|
|
9
|
+
* equivalent queries ({@link groupFacetQueries}) and dispatched as ONE
|
|
10
|
+
* `engine.searchFacets` call – one engine round-trip for the whole sidebar
|
|
11
|
+
* instead of one search per facet.
|
|
12
|
+
*
|
|
13
|
+
* A facet is supplementary: a failed facet query degrades exactly its own
|
|
14
|
+
* facets to empty lists – reported per field via `onFacetError` – while its
|
|
15
|
+
* siblings keep their buckets; only a batch-level failure (the dispatch
|
|
16
|
+
* itself rejecting) degrades every facet. Neither fails the whole GraphQL
|
|
17
|
+
* query, which would null the non-null result and discard the items.
|
|
18
|
+
*/
|
|
19
|
+
export declare function createFacetLoader(engine: SearchEngine, searchType: SearchType, query: SearchQuery, onFacetError?: (field: string, error: unknown) => void): FacetLoader;
|
|
20
|
+
/**
|
|
21
|
+
* Group the selected facet fields into the fewest facet-only queries with
|
|
22
|
+
* unchanged skip-own-filter semantics. Each facet counts with its OWN
|
|
23
|
+
* `where`-filter removed, so removal only matters for a facet whose field is
|
|
24
|
+
* actively filtered: every facet whose field carries no filter shares the
|
|
25
|
+
* untouched `where` – one query faceting all of them (the unfiltered browse
|
|
26
|
+
* collapses to a single query) – while each own-filtered facet gets its own
|
|
27
|
+
* query with its own effective `where`. (Dropping a facet’s filter also drops
|
|
28
|
+
* a policy default on that field, e.g. valid-only `status`, so the facet
|
|
29
|
+
* counts across every value.) The queries are facet-only: no hits (`limit:
|
|
30
|
+
* 0`) and, with no hits to order, no `orderBy`.
|
|
31
|
+
*/
|
|
32
|
+
export declare function groupFacetQueries(query: SearchQuery, fields: readonly string[]): SearchQuery[];
|
|
33
|
+
//# sourceMappingURL=facet-batch.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"facet-batch.d.ts","sourceRoot":"","sources":["../src/facet-batch.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,WAAW,EACX,YAAY,EACZ,WAAW,EACX,UAAU,EACX,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,UAAU,EACtB,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"}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import DataLoader from 'dataloader';
|
|
2
|
+
/**
|
|
3
|
+
* A per-request batcher behind the keyed facets object. Each selected facet
|
|
4
|
+
* field’s resolver calls the loader; GraphQL resolves the sibling facet
|
|
5
|
+
* fields synchronously, so the loads land in the same tick, where the
|
|
6
|
+
* DataLoader collects them into one batch, which is grouped into the fewest
|
|
7
|
+
* equivalent queries ({@link groupFacetQueries}) and dispatched as ONE
|
|
8
|
+
* `engine.searchFacets` call – one engine round-trip for the whole sidebar
|
|
9
|
+
* instead of one search per facet.
|
|
10
|
+
*
|
|
11
|
+
* A facet is supplementary: a failed facet query degrades exactly its own
|
|
12
|
+
* facets to empty lists – reported per field via `onFacetError` – while its
|
|
13
|
+
* siblings keep their buckets; only a batch-level failure (the dispatch
|
|
14
|
+
* itself rejecting) degrades every facet. Neither fails the whole GraphQL
|
|
15
|
+
* query, which would null the non-null result and discard the items.
|
|
16
|
+
*/
|
|
17
|
+
export function createFacetLoader(engine, searchType, query, onFacetError) {
|
|
18
|
+
const loader = new DataLoader(async (fields) => {
|
|
19
|
+
const queries = groupFacetQueries(query, fields);
|
|
20
|
+
const buckets = new Map();
|
|
21
|
+
try {
|
|
22
|
+
const outcomes = await engine.searchFacets(searchType, queries);
|
|
23
|
+
queries.forEach((facetQuery, index) => {
|
|
24
|
+
const outcome = outcomes[index];
|
|
25
|
+
if (outcome !== undefined && !('error' in outcome)) {
|
|
26
|
+
for (const field of facetQuery.facets) {
|
|
27
|
+
buckets.set(field, outcome.facets[field] ?? []);
|
|
28
|
+
}
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
// A failed (or missing – a port-contract breach) outcome degrades
|
|
32
|
+
// exactly this query's facets; its siblings keep theirs.
|
|
33
|
+
const error = outcome === undefined
|
|
34
|
+
? new Error('The engine returned no outcome for this query.')
|
|
35
|
+
: outcome.error;
|
|
36
|
+
for (const field of facetQuery.facets) {
|
|
37
|
+
onFacetError?.(field, error);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
// A batch-level failure leaves `buckets` empty: every facet in the
|
|
43
|
+
// batch degrades to [].
|
|
44
|
+
for (const field of fields) {
|
|
45
|
+
onFacetError?.(field, error);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return fields.map((field) => buckets.get(field) ?? []);
|
|
49
|
+
});
|
|
50
|
+
return (field) => loader.load(field);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Group the selected facet fields into the fewest facet-only queries with
|
|
54
|
+
* unchanged skip-own-filter semantics. Each facet counts with its OWN
|
|
55
|
+
* `where`-filter removed, so removal only matters for a facet whose field is
|
|
56
|
+
* actively filtered: every facet whose field carries no filter shares the
|
|
57
|
+
* untouched `where` – one query faceting all of them (the unfiltered browse
|
|
58
|
+
* collapses to a single query) – while each own-filtered facet gets its own
|
|
59
|
+
* query with its own effective `where`. (Dropping a facet’s filter also drops
|
|
60
|
+
* a policy default on that field, e.g. valid-only `status`, so the facet
|
|
61
|
+
* counts across every value.) The queries are facet-only: no hits (`limit:
|
|
62
|
+
* 0`) and, with no hits to order, no `orderBy`.
|
|
63
|
+
*/
|
|
64
|
+
export function groupFacetQueries(query, fields) {
|
|
65
|
+
const filteredFields = new Set(query.where.map((filter) => filter.field));
|
|
66
|
+
const facetOnly = { ...query, orderBy: [], limit: 0, offset: 0 };
|
|
67
|
+
const sharedFields = fields.filter((field) => !filteredFields.has(field));
|
|
68
|
+
const queries = [];
|
|
69
|
+
if (sharedFields.length > 0) {
|
|
70
|
+
queries.push({ ...facetOnly, facets: sharedFields });
|
|
71
|
+
}
|
|
72
|
+
for (const field of fields.filter((field) => filteredFields.has(field))) {
|
|
73
|
+
queries.push({
|
|
74
|
+
...facetOnly,
|
|
75
|
+
where: query.where.filter((filter) => filter.field !== field),
|
|
76
|
+
facets: [field],
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return queries;
|
|
80
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lde/search-api-graphql",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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. 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",
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
"!**/*.tsbuildinfo"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@lde/search": "^0.
|
|
28
|
+
"@lde/search": "^0.3.0",
|
|
29
|
+
"dataloader": "^2.2.3",
|
|
29
30
|
"graphql": "^15.8.0",
|
|
30
31
|
"tslib": "^2.3.0"
|
|
31
32
|
}
|