@lde/search-api-graphql 0.21.1 → 0.23.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 +279 -87
- package/dist/facet-batch.d.ts.map +1 -1
- package/dist/facet-batch.js +17 -3
- package/package.json +2 -2
|
@@ -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;
|
|
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;AA4GD;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,YAAY,EACpB,OAAO,GAAE,yBAA8B,GACtC,aAAa,CAsxBf;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, 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)));
|
|
@@ -15,21 +15,28 @@ const scalarOutput = (scalar, field) => field.required === true ? new GraphQLNon
|
|
|
15
15
|
* distinguishable. Consequence for consumers: a variable must be declared
|
|
16
16
|
* `[IRI!]`, not `[String!]`, because GraphQL checks variable usage nominally.
|
|
17
17
|
*
|
|
18
|
-
* **
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* **It validates in both directions.** Rejecting a bare token on the way in is
|
|
19
|
+
* what turns `where: { material: { in: ["boerenbont"] } }` from a silent empty
|
|
20
|
+
* result into an error saying why – the same job `argsToQuery` already does for
|
|
21
|
+
* an out-of-range `perPage`. Outbound matters for the same reason: a type
|
|
22
|
+
* enforced in one direction only is not one a consumer can rely on, and the
|
|
23
|
+
* coarse discovery strategy reads `IRI` as the promise that a value is a
|
|
24
|
+
* selection key.
|
|
22
25
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
26
|
+
* **Where a bad value is dropped rather than raised.** Raising is right where
|
|
27
|
+
* the value IS the document’s identity (`id`, a reference’s `id`): such a
|
|
28
|
+
* document cannot be selected, so serving it would hand back something
|
|
29
|
+
* unusable. It is wrong for a facet bucket – `value: IRI!` sits inside a chain
|
|
30
|
+
* of non-nulls up to the root field, so raising there nulls the WHOLE response
|
|
31
|
+
* and discards `items`, which is precisely what facet-batch.ts’s degradation
|
|
32
|
+
* contract forbids a supplementary sidebar count from doing. The facet
|
|
33
|
+
* resolvers therefore filter unselectable buckets out instead.
|
|
30
34
|
*
|
|
31
35
|
* What counts as an IRI is {@link isAbsoluteIri} – shared with the projection,
|
|
32
|
-
*
|
|
36
|
+
* which applies it on every route a reference value can take (graph path,
|
|
37
|
+
* `derive`, `from`, `transform`), so the surface cannot promise something the
|
|
38
|
+
* index contradicts and the outbound error is reachable only from an index
|
|
39
|
+
* written before that rule existed.
|
|
33
40
|
*/
|
|
34
41
|
const iriScalar = new GraphQLScalarType({
|
|
35
42
|
name: 'IRI',
|
|
@@ -133,7 +140,7 @@ export function buildGraphQLSchema(schema, options = {}) {
|
|
|
133
140
|
// that selects it, with no `String`-to-`IRI` boundary in between – the
|
|
134
141
|
// contract BooleanBucket already keeps for `is`.
|
|
135
142
|
const iriBucket = new GraphQLObjectType({
|
|
136
|
-
name: '
|
|
143
|
+
name: 'IRIBucket',
|
|
137
144
|
fields: valueBucketFields(iriScalar),
|
|
138
145
|
});
|
|
139
146
|
// A numeric range-facet bin: half-open `[min, max)` bounds (max null on an
|
|
@@ -254,7 +261,28 @@ export function buildGraphQLSchema(schema, options = {}) {
|
|
|
254
261
|
// collide: searchSchema resolves its typeName to a declared Reference Type
|
|
255
262
|
// and rejects duplicate names schema-wide.
|
|
256
263
|
const referenceTypes = new Map();
|
|
257
|
-
|
|
264
|
+
// Seeded with the shared types every schema carries, not just the root type
|
|
265
|
+
// names: a declaration is free to name a type `IRI` or `ValueBucket`, and
|
|
266
|
+
// without this it would pass both collision checks and fail at
|
|
267
|
+
// `new GraphQLSchema` with graphql-js’s “Schema must contain uniquely named
|
|
268
|
+
// types” – naming neither the declaration nor the field, which is the opaque
|
|
269
|
+
// failure these checks exist to replace.
|
|
270
|
+
const takenTypeNames = new Set([
|
|
271
|
+
...rootTypeNames,
|
|
272
|
+
iriScalar.name,
|
|
273
|
+
keywordFilter.name,
|
|
274
|
+
iriFilter.name,
|
|
275
|
+
languageString.name,
|
|
276
|
+
valueBucket.name,
|
|
277
|
+
iriBucket.name,
|
|
278
|
+
rangeBucket.name,
|
|
279
|
+
booleanBucket.name,
|
|
280
|
+
paginationType.name,
|
|
281
|
+
sortDirection.name,
|
|
282
|
+
intRange.name,
|
|
283
|
+
floatRange.name,
|
|
284
|
+
dateRange.name,
|
|
285
|
+
]);
|
|
258
286
|
/** The label key each id-plus-label reference type was registered with; no
|
|
259
287
|
* entry for a surfaced inline one, which carries fields instead of a label. */
|
|
260
288
|
const labelKeys = new Map();
|
|
@@ -268,6 +296,12 @@ export function buildGraphQLSchema(schema, options = {}) {
|
|
|
268
296
|
? DEFAULT_LABEL_FIELD
|
|
269
297
|
: labelFieldNameOf(rootTypesByName.get(field.labelSource));
|
|
270
298
|
}
|
|
299
|
+
// The declared joins, and the input types they make shareable. Each map is
|
|
300
|
+
// keyed by the SearchType `name`, so a type reached as a join target and the
|
|
301
|
+
// same type queried in its own right meet exactly one `‹Name›Where`.
|
|
302
|
+
const joins = joinGraph(schema);
|
|
303
|
+
const whereInputs = new Map();
|
|
304
|
+
const referenceFilters = new Map();
|
|
271
305
|
/**
|
|
272
306
|
* Register the GraphQL type one reference field is served as, once per
|
|
273
307
|
* referenced shape. A **surfaced inline** reference is served as the nested
|
|
@@ -421,10 +455,22 @@ export function buildGraphQLSchema(schema, options = {}) {
|
|
|
421
455
|
};
|
|
422
456
|
}
|
|
423
457
|
}
|
|
458
|
+
function whereFieldType(field, owner) {
|
|
459
|
+
// A joinable reference is filterable in two ways at once – by the ids it
|
|
460
|
+
// holds, and by a condition on the referent – so it earns a richer input
|
|
461
|
+
// than the plain membership every other reference gets. Making the
|
|
462
|
+
// difference visible in the schema is the point: a consumer sees which
|
|
463
|
+
// references can be filtered through, instead of discovering it from a
|
|
464
|
+
// runtime error.
|
|
465
|
+
const target = joins.resolve(owner, [field.name]);
|
|
466
|
+
return target !== undefined
|
|
467
|
+
? referenceFilterFor(target)
|
|
468
|
+
: plainWhereFieldType(field);
|
|
469
|
+
}
|
|
424
470
|
/** The filter input one field accepts, fixed by what the field keys on: a
|
|
425
471
|
* `reference` keys on identity (its target’s filter, or `IRIFilter` when it
|
|
426
472
|
* names no target), a `keyword` on literals. */
|
|
427
|
-
function
|
|
473
|
+
function plainWhereFieldType(field) {
|
|
428
474
|
switch (filterOperatorFor(field.kind)) {
|
|
429
475
|
case 'in':
|
|
430
476
|
if (field.kind !== 'reference') {
|
|
@@ -443,58 +489,88 @@ export function buildGraphQLSchema(schema, options = {}) {
|
|
|
443
489
|
return GraphQLBoolean;
|
|
444
490
|
}
|
|
445
491
|
}
|
|
446
|
-
/**
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
492
|
+
/**
|
|
493
|
+
* The filter input a joinable reference to `target` takes, created once per
|
|
494
|
+
* target type and shared by EVERY field pointing at it – `publisher` and
|
|
495
|
+
* `creator` both resolving to `Organization` take the same
|
|
496
|
+
* `OrganizationReferenceFilter`, because what it can express is a property of
|
|
497
|
+
* the referenced type, not of the referring field.
|
|
498
|
+
*
|
|
499
|
+
* `@oneOf`, so a criterion stays an atom: filter by the ids the field holds,
|
|
500
|
+
* **or** by a condition on the referent, never both in one term.
|
|
501
|
+
*/
|
|
502
|
+
function referenceFilterFor(target) {
|
|
503
|
+
const existing = referenceFilters.get(target.name);
|
|
504
|
+
if (existing !== undefined) {
|
|
505
|
+
return existing;
|
|
506
|
+
}
|
|
507
|
+
const created = new GraphQLInputObjectType({
|
|
508
|
+
name: `${target.name}ReferenceFilter`,
|
|
509
|
+
description: `A condition on a joinable reference to ${target.name}: the ids it holds, or a condition on the referenced ${target.name} itself.`,
|
|
510
|
+
isOneOf: true,
|
|
511
|
+
// A thunk: the target’s own `where` may point back through further
|
|
512
|
+
// joinable references (the join graph is acyclic, so this terminates).
|
|
513
|
+
fields: () => ({
|
|
514
|
+
// `IRI`, exactly as `‹Target›Filter` types it: this arm asks the same
|
|
515
|
+
// question that filter asks – which of the target’s ids the field
|
|
516
|
+
// holds – so it must accept the same variable a consumer declares
|
|
517
|
+
// `[IRI!]` for. Typing it `String` here would make the join arm the
|
|
518
|
+
// one place identity is not `IRI`-keyed.
|
|
519
|
+
in: { type: new GraphQLList(new GraphQLNonNull(iriScalar)) },
|
|
520
|
+
where: { type: whereInputFor(target) },
|
|
521
|
+
}),
|
|
460
522
|
});
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
[ID_FIELD]: { type: targetFilter(typeName) },
|
|
480
|
-
};
|
|
481
|
-
for (const field of filterable) {
|
|
482
|
-
fields[field.name] = {
|
|
483
|
-
type: whereFieldType(field),
|
|
484
|
-
description: field.description,
|
|
485
|
-
};
|
|
486
|
-
}
|
|
487
|
-
return fields;
|
|
523
|
+
referenceFilters.set(target.name, created);
|
|
524
|
+
return created;
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* One key per filterable field (plus the undeclared `id`), each typed by its
|
|
528
|
+
* OWN kind – so the operator a key accepts is fixed by the field it names,
|
|
529
|
+
* and a range on a keyword field cannot be written at all. This is the
|
|
530
|
+
* single vocabulary every level of `where` is built from: the same keys
|
|
531
|
+
* appear on the clause and on a criterion, so a field is never named a
|
|
532
|
+
* second way.
|
|
533
|
+
*/
|
|
534
|
+
function fieldKeys(searchType) {
|
|
535
|
+
const fields = {
|
|
536
|
+
// Typed self-referentially – `TermWhere.id: TermFilter` – so a consumer
|
|
537
|
+
// can resolve “the collection I am browsing” to “the filter type that
|
|
538
|
+
// accepts its IRIs”, which is what makes the refined discovery strategy
|
|
539
|
+
// work without hardcoding a single domain name.
|
|
540
|
+
[ID_FIELD]: { type: targetFilter(searchType.name) },
|
|
488
541
|
};
|
|
542
|
+
for (const field of filterableFields(searchType)) {
|
|
543
|
+
fields[field.name] = {
|
|
544
|
+
type: whereFieldType(field, searchType),
|
|
545
|
+
description: field.description,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
return fields;
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* The `where` input of one root type, created once and shared: it is both the
|
|
552
|
+
* argument of that type’s own query field and – through a
|
|
553
|
+
* {@link referenceFilterFor} – the shape a *joined* condition on it takes
|
|
554
|
+
* from another type. One input either way, so the vocabulary a consumer
|
|
555
|
+
* learns for `datasets(where: …)` is the same one they write inside
|
|
556
|
+
* `creativeWorks(where: { dataset: { where: … } })`.
|
|
557
|
+
*/
|
|
558
|
+
function whereInputFor(searchType) {
|
|
559
|
+
const existing = whereInputs.get(searchType.name);
|
|
560
|
+
if (existing !== undefined) {
|
|
561
|
+
return existing;
|
|
562
|
+
}
|
|
489
563
|
// A criterion is an ATOM: exactly one field, enforced by `@oneOf`. That is
|
|
490
564
|
// what keeps `where` a flat conjunction of disjunctions – a criterion that
|
|
491
565
|
// could carry two keys would be a conjunction nested inside an `or`, and
|
|
492
566
|
// skip-own-filter (ADR 5) has no answer for a clause buried inside another.
|
|
567
|
+
// Created here rather than on its own, because it exists for exactly one
|
|
568
|
+
// `where` and shares its memoization.
|
|
493
569
|
const criterionInput = new GraphQLInputObjectType({
|
|
494
|
-
name: `${
|
|
570
|
+
name: `${searchType.name}Criterion`,
|
|
495
571
|
description: 'A condition on exactly one field. Used inside `or`, where the criteria are alternatives.',
|
|
496
572
|
isOneOf: true,
|
|
497
|
-
fields: fieldKeys,
|
|
573
|
+
fields: () => fieldKeys(searchType),
|
|
498
574
|
});
|
|
499
575
|
const orKey = {
|
|
500
576
|
type: new GraphQLList(new GraphQLNonNull(criterionInput)),
|
|
@@ -506,18 +582,41 @@ export function buildGraphQLSchema(schema, options = {}) {
|
|
|
506
582
|
// `or` holds only `@oneOf` criteria. So `where` stays a flat conjunction of
|
|
507
583
|
// disjunctions at any nesting depth, and a reader meets one input type
|
|
508
584
|
// instead of two near-identical ones.
|
|
509
|
-
const
|
|
510
|
-
name: `${
|
|
585
|
+
const created = new GraphQLInputObjectType({
|
|
586
|
+
name: `${searchType.name}Where`,
|
|
511
587
|
description: 'Sibling keys are combined with AND. Use `or` for a disjunction, and `and` when a query needs more than one of them.',
|
|
512
588
|
fields: () => ({
|
|
513
|
-
...fieldKeys(),
|
|
589
|
+
...fieldKeys(searchType),
|
|
514
590
|
[OR_KEY]: orKey,
|
|
515
591
|
[AND_KEY]: {
|
|
516
|
-
type: new GraphQLList(new GraphQLNonNull(
|
|
592
|
+
type: new GraphQLList(new GraphQLNonNull(created)),
|
|
517
593
|
description: 'Further groups of conditions, all of which apply. The way to carry a second `or` disjunction alongside the first.',
|
|
518
594
|
},
|
|
519
595
|
}),
|
|
520
596
|
});
|
|
597
|
+
whereInputs.set(searchType.name, created);
|
|
598
|
+
return created;
|
|
599
|
+
}
|
|
600
|
+
/** The root query field for one {@link RootType}, with its derived types. */
|
|
601
|
+
function rootField(searchType, typeOptions) {
|
|
602
|
+
const typeName = searchType.name;
|
|
603
|
+
const outputType = new GraphQLObjectType({
|
|
604
|
+
name: typeName,
|
|
605
|
+
fields: () => {
|
|
606
|
+
const fields = {
|
|
607
|
+
id: { type: new GraphQLNonNull(iriScalar) },
|
|
608
|
+
};
|
|
609
|
+
for (const field of outputFields(searchType)) {
|
|
610
|
+
fields[field.name] = outputFieldConfig(field);
|
|
611
|
+
}
|
|
612
|
+
return fields;
|
|
613
|
+
},
|
|
614
|
+
});
|
|
615
|
+
// Every type is filterable on `id`, so the input always has at least one
|
|
616
|
+
// field and always exists – no type is unaddressable by IRI, whatever it
|
|
617
|
+
// declares. It may already have been created as a joined type’s filter
|
|
618
|
+
// shape; `whereInputFor` hands back the same instance either way.
|
|
619
|
+
const whereInput = whereInputFor(searchType);
|
|
521
620
|
const sortValues = {
|
|
522
621
|
RELEVANCE: { value: 'relevance' },
|
|
523
622
|
};
|
|
@@ -540,7 +639,7 @@ export function buildGraphQLSchema(schema, options = {}) {
|
|
|
540
639
|
});
|
|
541
640
|
// Keyed facets object: one field per facetable field, typed by its kind
|
|
542
641
|
// (range fields → [RangeBucket!], boolean fields → [BooleanBucket!],
|
|
543
|
-
// reference fields → [
|
|
642
|
+
// reference fields → [IRIBucket!], else [ValueBucket!]). Only the selected
|
|
544
643
|
// fields are resolved (GraphQL prunes the rest), so the selection IS the
|
|
545
644
|
// request; how they are computed – skip-own-filter, batched into one
|
|
546
645
|
// engine dispatch – lives in facet-batch.ts.
|
|
@@ -593,7 +692,7 @@ export function buildGraphQLSchema(schema, options = {}) {
|
|
|
593
692
|
},
|
|
594
693
|
},
|
|
595
694
|
resolve: async (_source, args, context) => {
|
|
596
|
-
const built = argsToQuery(args, context, searchType, maxPerPage);
|
|
695
|
+
const built = argsToQuery(args, context, searchType, maxPerPage, joins);
|
|
597
696
|
const finalQuery = typeOptions?.queryDefaults
|
|
598
697
|
? typeOptions.queryDefaults(built, context)
|
|
599
698
|
: built;
|
|
@@ -634,12 +733,28 @@ export function buildGraphQLSchema(schema, options = {}) {
|
|
|
634
733
|
fields: () => {
|
|
635
734
|
const fields = {};
|
|
636
735
|
for (const field of facetable) {
|
|
736
|
+
const bucketType = bucketTypeFor(field);
|
|
637
737
|
fields[field.name] = {
|
|
638
|
-
type: nonNullListOf(
|
|
738
|
+
type: nonNullListOf(bucketType),
|
|
639
739
|
// The skip-own-filter query building, the batching into one
|
|
640
740
|
// engine dispatch and the degrade-to-[] error handling all live
|
|
641
741
|
// in the loader (facet-batch.ts).
|
|
642
|
-
resolve: (source) =>
|
|
742
|
+
resolve: async (source) => {
|
|
743
|
+
const buckets = await source.loadFacet(field.name);
|
|
744
|
+
if (bucketType !== iriBucket) {
|
|
745
|
+
return buckets;
|
|
746
|
+
}
|
|
747
|
+
// A bucket keyed on something that is not an IRI is not a
|
|
748
|
+
// selection key – it cannot be sent back as the filter that
|
|
749
|
+
// selects it – so it is dropped rather than served under `IRI`.
|
|
750
|
+
// Dropping, not throwing: `value: IRI!` sits inside a chain of
|
|
751
|
+
// non-nulls up to the root field, so a raising coercion here
|
|
752
|
+
// would null the WHOLE response and take `items` with it, which
|
|
753
|
+
// is exactly what facet-batch.ts’s degradation contract forbids
|
|
754
|
+
// a supplementary sidebar count from doing.
|
|
755
|
+
return buckets.filter((bucket) => typeof bucket.value === 'string' &&
|
|
756
|
+
isAbsoluteIri(bucket.value));
|
|
757
|
+
},
|
|
643
758
|
};
|
|
644
759
|
}
|
|
645
760
|
return fields;
|
|
@@ -673,7 +788,7 @@ export function printGraphQLSchema(schema, options = {}) {
|
|
|
673
788
|
/** Pure args → {@link SearchQuery} mapping. Rejects out-of-bounds paging
|
|
674
789
|
* (`page < 1`, `perPage` outside `1..maxPerPage`) with a clear error – a
|
|
675
790
|
* negative offset or an unbounded page size must not reach the engine. */
|
|
676
|
-
function argsToQuery(args, context, searchType, maxPerPage) {
|
|
791
|
+
function argsToQuery(args, context, searchType, maxPerPage, joins) {
|
|
677
792
|
const perPage = args.perPage ?? 20;
|
|
678
793
|
const page = args.page ?? 1;
|
|
679
794
|
if (page < 1) {
|
|
@@ -685,7 +800,7 @@ function argsToQuery(args, context, searchType, maxPerPage) {
|
|
|
685
800
|
}
|
|
686
801
|
return {
|
|
687
802
|
text: args.query,
|
|
688
|
-
where: whereToFilters(args.where, searchType),
|
|
803
|
+
where: whereToFilters(args.where, searchType, joins),
|
|
689
804
|
orderBy: args.orderBy
|
|
690
805
|
? [{ field: args.orderBy.field, direction: args.orderBy.direction }]
|
|
691
806
|
: [],
|
|
@@ -696,18 +811,37 @@ function argsToQuery(args, context, searchType, maxPerPage) {
|
|
|
696
811
|
locale: context.acceptLanguage[0] ?? 'und',
|
|
697
812
|
};
|
|
698
813
|
}
|
|
699
|
-
|
|
814
|
+
/**
|
|
815
|
+
* Compile one `Where` input into the flat conjunction of disjunctions the IR
|
|
816
|
+
* holds, at `on` hops out from the searched type (`[]` at the top level).
|
|
817
|
+
*
|
|
818
|
+
* A **joined** key carrying `where` recurses with the path extended by that
|
|
819
|
+
* key, and its clauses join this list – exactly how `and` already flattens. The
|
|
820
|
+
* result is still flat: the nesting lives in each criterion’s `on` path, never
|
|
821
|
+
* in the clause structure, so skip-own-filter (ADR 5) still scans one level.
|
|
822
|
+
*/
|
|
823
|
+
function whereToFilters(where, searchType, joins, on = []) {
|
|
700
824
|
// `== null` deliberately: an explicit `where: null` is as absent as an omitted
|
|
701
825
|
// one, and reading keys off it would throw.
|
|
702
826
|
if (where == null) {
|
|
703
827
|
return [];
|
|
704
828
|
}
|
|
705
|
-
const filters =
|
|
829
|
+
const filters = [];
|
|
706
830
|
// Sibling field keys AND, so each becomes a one-criterion filter of its own,
|
|
707
831
|
// while `or` becomes a SINGLE filter carrying every alternative. That is the
|
|
708
832
|
// whole AND/OR mapping: which bucket a criterion lands in, never a combinator
|
|
709
833
|
// inferred from how deeply it is nested.
|
|
710
|
-
const
|
|
834
|
+
for (const entry of keyEntriesOf(where, searchType, joins)) {
|
|
835
|
+
if ('nested' in entry) {
|
|
836
|
+
filters.push(...whereToFilters(entry.nested, entry.target, joins, [
|
|
837
|
+
...on,
|
|
838
|
+
entry.name,
|
|
839
|
+
]));
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
filters.push(filterOn(withPath(entry.criterion, on)));
|
|
843
|
+
}
|
|
844
|
+
const alternatives = (where[OR_KEY] ?? []).flatMap((criterion) => criteriaOf(criterion, searchType, joins, on));
|
|
711
845
|
if (alternatives.length > 0) {
|
|
712
846
|
filters.push({ or: alternatives });
|
|
713
847
|
}
|
|
@@ -716,47 +850,105 @@ function whereToFilters(where, searchType) {
|
|
|
716
850
|
// result flat: nested conjunctions collapse into this one list, and an `and`
|
|
717
851
|
// can never sit inside an `or` (which holds only criteria).
|
|
718
852
|
for (const nested of where[AND_KEY] ?? []) {
|
|
719
|
-
filters.push(...whereToFilters(nested, searchType));
|
|
853
|
+
filters.push(...whereToFilters(nested, searchType, joins, on));
|
|
720
854
|
}
|
|
721
855
|
return filters;
|
|
722
856
|
}
|
|
857
|
+
/** A criterion with its join path attached, or unchanged at the top level –
|
|
858
|
+
* so an ordinary query’s IR is byte-for-byte what it was. */
|
|
859
|
+
function withPath(criterion, on) {
|
|
860
|
+
return on.length === 0 ? criterion : { ...criterion, on };
|
|
861
|
+
}
|
|
723
862
|
/**
|
|
724
|
-
* The criteria a keyed object carries, in declaration order
|
|
725
|
-
* `@oneOf`, so it yields exactly one; a `Where
|
|
726
|
-
* key it sets.
|
|
727
|
-
*
|
|
863
|
+
* The criteria a keyed object carries, in declaration order, `on` hops out. A
|
|
864
|
+
* `Criterion` is `@oneOf`, so it yields exactly one; a `Where` yields one per
|
|
865
|
+
* field key it sets.
|
|
866
|
+
*
|
|
867
|
+
* A joined key carrying `where` is compiled through {@link whereToFilters} and
|
|
868
|
+
* must come back as exactly ONE one-criterion clause. Anything else – two field
|
|
869
|
+
* keys, an `or`, an `and` – is a conjunction, and a conjunction cannot sit
|
|
870
|
+
* inside the `or` this feeds (ADR 18’s flat IR has nowhere to put it), so it is
|
|
871
|
+
* rejected with the rewrite that expresses it instead.
|
|
728
872
|
*/
|
|
729
|
-
function criteriaOf(keyed, searchType) {
|
|
873
|
+
function criteriaOf(keyed, searchType, joins, on = []) {
|
|
730
874
|
const criteria = [];
|
|
875
|
+
for (const entry of keyEntriesOf(keyed, searchType, joins)) {
|
|
876
|
+
if (!('nested' in entry)) {
|
|
877
|
+
criteria.push(withPath(entry.criterion, on));
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
const path = [...on, entry.name];
|
|
881
|
+
const nested = whereToFilters(entry.nested, entry.target, joins, path);
|
|
882
|
+
const [only] = nested;
|
|
883
|
+
// An `or` alternative must be exactly one criterion. Empty and
|
|
884
|
+
// more-than-one are opposite mistakes, so they are named separately rather
|
|
885
|
+
// than sharing a message that fits only one of them.
|
|
886
|
+
if (only === undefined) {
|
|
887
|
+
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.`);
|
|
888
|
+
}
|
|
889
|
+
if (nested.length !== 1 || only.or.length !== 1) {
|
|
890
|
+
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”.`);
|
|
891
|
+
}
|
|
892
|
+
criteria.push(only.or[0]);
|
|
893
|
+
}
|
|
894
|
+
return criteria;
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* The field keys a keyed input sets, in declaration order. `id` is read first:
|
|
898
|
+
* no type declares it, so the field loop never sees it.
|
|
899
|
+
*
|
|
900
|
+
* A joinable reference takes a `‹Target›ReferenceFilter` rather than a plain
|
|
901
|
+
* `StringFilter`, and its two `@oneOf` arms mean different things: `in` filters
|
|
902
|
+
* by the ids the field itself holds – no join, the same membership every other
|
|
903
|
+
* reference gets – while `where` states a condition on the referent and yields
|
|
904
|
+
* a nested entry the caller walks into.
|
|
905
|
+
*/
|
|
906
|
+
function keyEntriesOf(keyed, searchType, joins) {
|
|
907
|
+
const entries = [];
|
|
731
908
|
const id = keyed[ID_FIELD];
|
|
732
909
|
if (id !== undefined && id !== null) {
|
|
733
|
-
|
|
910
|
+
entries.push({ criterion: { field: ID_FIELD, in: id.in ?? [] } });
|
|
734
911
|
}
|
|
735
912
|
for (const field of filterableFields(searchType)) {
|
|
736
913
|
const value = keyed[field.name];
|
|
737
914
|
if (value === undefined || value === null) {
|
|
738
915
|
continue;
|
|
739
916
|
}
|
|
917
|
+
const target = joins.resolve(searchType, [field.name]);
|
|
918
|
+
if (target !== undefined) {
|
|
919
|
+
const nested = value
|
|
920
|
+
.where;
|
|
921
|
+
if (nested !== undefined && nested !== null) {
|
|
922
|
+
entries.push({ name: field.name, nested, target });
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
740
926
|
switch (filterOperatorFor(field.kind)) {
|
|
741
927
|
case 'in':
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
928
|
+
entries.push({
|
|
929
|
+
criterion: {
|
|
930
|
+
field: field.name,
|
|
931
|
+
in: value.in ?? [],
|
|
932
|
+
},
|
|
745
933
|
});
|
|
746
934
|
break;
|
|
747
935
|
case 'range': {
|
|
748
936
|
const range = value;
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
937
|
+
entries.push({
|
|
938
|
+
criterion: {
|
|
939
|
+
field: field.name,
|
|
940
|
+
range: { min: range.min, max: range.max },
|
|
941
|
+
},
|
|
752
942
|
});
|
|
753
943
|
break;
|
|
754
944
|
}
|
|
755
945
|
default:
|
|
756
|
-
|
|
946
|
+
entries.push({
|
|
947
|
+
criterion: { field: field.name, is: value },
|
|
948
|
+
});
|
|
757
949
|
}
|
|
758
950
|
}
|
|
759
|
-
return
|
|
951
|
+
return entries;
|
|
760
952
|
}
|
|
761
953
|
function rangeInput(name, bound) {
|
|
762
954
|
return new GraphQLInputObjectType({
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"facet-batch.d.ts","sourceRoot":"","sources":["../src/facet-batch.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,
|
|
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"}
|
package/dist/facet-batch.js
CHANGED
|
@@ -115,7 +115,21 @@ function ownedField(filter) {
|
|
|
115
115
|
if (first === undefined) {
|
|
116
116
|
return undefined;
|
|
117
117
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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.
|
|
3
|
+
"version": "0.23.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.
|
|
40
|
+
"@lde/search": "^0.21.0",
|
|
41
41
|
"commander": "^15.0.0",
|
|
42
42
|
"dataloader": "^2.2.3",
|
|
43
43
|
"graphql": "^16.9.0",
|