@lde/search-api-graphql 0.21.0 → 0.22.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 +268 -79
- 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;AAqGD;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,YAAY,EACpB,OAAO,GAAE,yBAA8B,GACtC,aAAa,CA6uBf;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)));
|
|
@@ -41,18 +41,39 @@ const iriScalar = new GraphQLScalarType({
|
|
|
41
41
|
});
|
|
42
42
|
function assertIri(value) {
|
|
43
43
|
if (!isAbsoluteIri(value)) {
|
|
44
|
-
throw
|
|
44
|
+
throw userError(`IRI cannot represent “${value}”: an IRI needs a scheme (for example “https:”, “urn:” or “doi:”) and no whitespace. A value like this is usually a label or a token, which selects nothing on a field that keys on identity.`);
|
|
45
45
|
}
|
|
46
46
|
return value;
|
|
47
47
|
}
|
|
48
48
|
/** Outbound the fault is the index, not the caller, so the message points at
|
|
49
|
-
* the fix rather than at the query
|
|
49
|
+
* the fix rather than at the query – and it carries no `BAD_USER_INPUT`,
|
|
50
|
+
* since there is nothing the query could have done differently. */
|
|
50
51
|
function assertIriOut(value) {
|
|
51
52
|
if (!isAbsoluteIri(value)) {
|
|
52
53
|
throw new GraphQLError(`IRI cannot serialize “${value}”: the index holds a value that is not an absolute IRI on a field that keys on identity. The projection drops such values at the source, so this document predates that rule – reindex it.`);
|
|
53
54
|
}
|
|
54
55
|
return value;
|
|
55
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* An error the CALLER can fix, marked as such: a `GraphQLError` carrying
|
|
59
|
+
* `extensions.code = 'BAD_USER_INPUT'`.
|
|
60
|
+
*
|
|
61
|
+
* Both halves matter, and both are about the consumer rather than about us. A
|
|
62
|
+
* plain `Error` thrown from a resolver is a server fault as far as the
|
|
63
|
+
* transport is concerned, so graphql-yoga masks it to `“Unexpected error.”`;
|
|
64
|
+
* the message then survives only in the API container’s log, where a
|
|
65
|
+
* presentation-layer developer building against a hosted endpoint cannot read
|
|
66
|
+
* it. Throwing a `GraphQLError` keeps the sentence that says what was wrong.
|
|
67
|
+
* The `code` says whose fault it is, so a client can distinguish “fix your
|
|
68
|
+
* query” from “retry later” without matching on prose – the convention every
|
|
69
|
+
* major GraphQL server shares. `“Unexpected error.”` is left for what the name
|
|
70
|
+
* says: faults we did not anticipate.
|
|
71
|
+
*/
|
|
72
|
+
function userError(message) {
|
|
73
|
+
return new GraphQLError(message, {
|
|
74
|
+
extensions: { code: 'BAD_USER_INPUT' },
|
|
75
|
+
});
|
|
76
|
+
}
|
|
56
77
|
/** SCREAMING_SNAKE_CASE for an enum value name, e.g. `datePosted` → `DATE_POSTED`. */
|
|
57
78
|
function screamingSnake(name) {
|
|
58
79
|
return name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase();
|
|
@@ -247,6 +268,12 @@ export function buildGraphQLSchema(schema, options = {}) {
|
|
|
247
268
|
? DEFAULT_LABEL_FIELD
|
|
248
269
|
: labelFieldNameOf(rootTypesByName.get(field.labelSource));
|
|
249
270
|
}
|
|
271
|
+
// The declared joins, and the input types they make shareable. Each map is
|
|
272
|
+
// keyed by the SearchType `name`, so a type reached as a join target and the
|
|
273
|
+
// same type queried in its own right meet exactly one `‹Name›Where`.
|
|
274
|
+
const joins = joinGraph(schema);
|
|
275
|
+
const whereInputs = new Map();
|
|
276
|
+
const referenceFilters = new Map();
|
|
250
277
|
/**
|
|
251
278
|
* Register the GraphQL type one reference field is served as, once per
|
|
252
279
|
* referenced shape. A **surfaced inline** reference is served as the nested
|
|
@@ -400,10 +427,22 @@ export function buildGraphQLSchema(schema, options = {}) {
|
|
|
400
427
|
};
|
|
401
428
|
}
|
|
402
429
|
}
|
|
430
|
+
function whereFieldType(field, owner) {
|
|
431
|
+
// A joinable reference is filterable in two ways at once – by the ids it
|
|
432
|
+
// holds, and by a condition on the referent – so it earns a richer input
|
|
433
|
+
// than the plain membership every other reference gets. Making the
|
|
434
|
+
// difference visible in the schema is the point: a consumer sees which
|
|
435
|
+
// references can be filtered through, instead of discovering it from a
|
|
436
|
+
// runtime error.
|
|
437
|
+
const target = joins.resolve(owner, [field.name]);
|
|
438
|
+
return target !== undefined
|
|
439
|
+
? referenceFilterFor(target)
|
|
440
|
+
: plainWhereFieldType(field);
|
|
441
|
+
}
|
|
403
442
|
/** The filter input one field accepts, fixed by what the field keys on: a
|
|
404
443
|
* `reference` keys on identity (its target’s filter, or `IRIFilter` when it
|
|
405
444
|
* names no target), a `keyword` on literals. */
|
|
406
|
-
function
|
|
445
|
+
function plainWhereFieldType(field) {
|
|
407
446
|
switch (filterOperatorFor(field.kind)) {
|
|
408
447
|
case 'in':
|
|
409
448
|
if (field.kind !== 'reference') {
|
|
@@ -422,58 +461,88 @@ export function buildGraphQLSchema(schema, options = {}) {
|
|
|
422
461
|
return GraphQLBoolean;
|
|
423
462
|
}
|
|
424
463
|
}
|
|
425
|
-
/**
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
464
|
+
/**
|
|
465
|
+
* The filter input a joinable reference to `target` takes, created once per
|
|
466
|
+
* target type and shared by EVERY field pointing at it – `publisher` and
|
|
467
|
+
* `creator` both resolving to `Organization` take the same
|
|
468
|
+
* `OrganizationReferenceFilter`, because what it can express is a property of
|
|
469
|
+
* the referenced type, not of the referring field.
|
|
470
|
+
*
|
|
471
|
+
* `@oneOf`, so a criterion stays an atom: filter by the ids the field holds,
|
|
472
|
+
* **or** by a condition on the referent, never both in one term.
|
|
473
|
+
*/
|
|
474
|
+
function referenceFilterFor(target) {
|
|
475
|
+
const existing = referenceFilters.get(target.name);
|
|
476
|
+
if (existing !== undefined) {
|
|
477
|
+
return existing;
|
|
478
|
+
}
|
|
479
|
+
const created = new GraphQLInputObjectType({
|
|
480
|
+
name: `${target.name}ReferenceFilter`,
|
|
481
|
+
description: `A condition on a joinable reference to ${target.name}: the ids it holds, or a condition on the referenced ${target.name} itself.`,
|
|
482
|
+
isOneOf: true,
|
|
483
|
+
// A thunk: the target’s own `where` may point back through further
|
|
484
|
+
// joinable references (the join graph is acyclic, so this terminates).
|
|
485
|
+
fields: () => ({
|
|
486
|
+
// `IRI`, exactly as `‹Target›Filter` types it: this arm asks the same
|
|
487
|
+
// question that filter asks – which of the target’s ids the field
|
|
488
|
+
// holds – so it must accept the same variable a consumer declares
|
|
489
|
+
// `[IRI!]` for. Typing it `String` here would make the join arm the
|
|
490
|
+
// one place identity is not `IRI`-keyed.
|
|
491
|
+
in: { type: new GraphQLList(new GraphQLNonNull(iriScalar)) },
|
|
492
|
+
where: { type: whereInputFor(target) },
|
|
493
|
+
}),
|
|
439
494
|
});
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
[ID_FIELD]: { type: targetFilter(typeName) },
|
|
459
|
-
};
|
|
460
|
-
for (const field of filterable) {
|
|
461
|
-
fields[field.name] = {
|
|
462
|
-
type: whereFieldType(field),
|
|
463
|
-
description: field.description,
|
|
464
|
-
};
|
|
465
|
-
}
|
|
466
|
-
return fields;
|
|
495
|
+
referenceFilters.set(target.name, created);
|
|
496
|
+
return created;
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* One key per filterable field (plus the undeclared `id`), each typed by its
|
|
500
|
+
* OWN kind – so the operator a key accepts is fixed by the field it names,
|
|
501
|
+
* and a range on a keyword field cannot be written at all. This is the
|
|
502
|
+
* single vocabulary every level of `where` is built from: the same keys
|
|
503
|
+
* appear on the clause and on a criterion, so a field is never named a
|
|
504
|
+
* second way.
|
|
505
|
+
*/
|
|
506
|
+
function fieldKeys(searchType) {
|
|
507
|
+
const fields = {
|
|
508
|
+
// Typed self-referentially – `TermWhere.id: TermFilter` – so a consumer
|
|
509
|
+
// can resolve “the collection I am browsing” to “the filter type that
|
|
510
|
+
// accepts its IRIs”, which is what makes the refined discovery strategy
|
|
511
|
+
// work without hardcoding a single domain name.
|
|
512
|
+
[ID_FIELD]: { type: targetFilter(searchType.name) },
|
|
467
513
|
};
|
|
514
|
+
for (const field of filterableFields(searchType)) {
|
|
515
|
+
fields[field.name] = {
|
|
516
|
+
type: whereFieldType(field, searchType),
|
|
517
|
+
description: field.description,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
return fields;
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* The `where` input of one root type, created once and shared: it is both the
|
|
524
|
+
* argument of that type’s own query field and – through a
|
|
525
|
+
* {@link referenceFilterFor} – the shape a *joined* condition on it takes
|
|
526
|
+
* from another type. One input either way, so the vocabulary a consumer
|
|
527
|
+
* learns for `datasets(where: …)` is the same one they write inside
|
|
528
|
+
* `creativeWorks(where: { dataset: { where: … } })`.
|
|
529
|
+
*/
|
|
530
|
+
function whereInputFor(searchType) {
|
|
531
|
+
const existing = whereInputs.get(searchType.name);
|
|
532
|
+
if (existing !== undefined) {
|
|
533
|
+
return existing;
|
|
534
|
+
}
|
|
468
535
|
// A criterion is an ATOM: exactly one field, enforced by `@oneOf`. That is
|
|
469
536
|
// what keeps `where` a flat conjunction of disjunctions – a criterion that
|
|
470
537
|
// could carry two keys would be a conjunction nested inside an `or`, and
|
|
471
538
|
// skip-own-filter (ADR 5) has no answer for a clause buried inside another.
|
|
539
|
+
// Created here rather than on its own, because it exists for exactly one
|
|
540
|
+
// `where` and shares its memoization.
|
|
472
541
|
const criterionInput = new GraphQLInputObjectType({
|
|
473
|
-
name: `${
|
|
542
|
+
name: `${searchType.name}Criterion`,
|
|
474
543
|
description: 'A condition on exactly one field. Used inside `or`, where the criteria are alternatives.',
|
|
475
544
|
isOneOf: true,
|
|
476
|
-
fields: fieldKeys,
|
|
545
|
+
fields: () => fieldKeys(searchType),
|
|
477
546
|
});
|
|
478
547
|
const orKey = {
|
|
479
548
|
type: new GraphQLList(new GraphQLNonNull(criterionInput)),
|
|
@@ -485,18 +554,41 @@ export function buildGraphQLSchema(schema, options = {}) {
|
|
|
485
554
|
// `or` holds only `@oneOf` criteria. So `where` stays a flat conjunction of
|
|
486
555
|
// disjunctions at any nesting depth, and a reader meets one input type
|
|
487
556
|
// instead of two near-identical ones.
|
|
488
|
-
const
|
|
489
|
-
name: `${
|
|
557
|
+
const created = new GraphQLInputObjectType({
|
|
558
|
+
name: `${searchType.name}Where`,
|
|
490
559
|
description: 'Sibling keys are combined with AND. Use `or` for a disjunction, and `and` when a query needs more than one of them.',
|
|
491
560
|
fields: () => ({
|
|
492
|
-
...fieldKeys(),
|
|
561
|
+
...fieldKeys(searchType),
|
|
493
562
|
[OR_KEY]: orKey,
|
|
494
563
|
[AND_KEY]: {
|
|
495
|
-
type: new GraphQLList(new GraphQLNonNull(
|
|
564
|
+
type: new GraphQLList(new GraphQLNonNull(created)),
|
|
496
565
|
description: 'Further groups of conditions, all of which apply. The way to carry a second `or` disjunction alongside the first.',
|
|
497
566
|
},
|
|
498
567
|
}),
|
|
499
568
|
});
|
|
569
|
+
whereInputs.set(searchType.name, created);
|
|
570
|
+
return created;
|
|
571
|
+
}
|
|
572
|
+
/** The root query field for one {@link RootType}, with its derived types. */
|
|
573
|
+
function rootField(searchType, typeOptions) {
|
|
574
|
+
const typeName = searchType.name;
|
|
575
|
+
const outputType = new GraphQLObjectType({
|
|
576
|
+
name: typeName,
|
|
577
|
+
fields: () => {
|
|
578
|
+
const fields = {
|
|
579
|
+
id: { type: new GraphQLNonNull(iriScalar) },
|
|
580
|
+
};
|
|
581
|
+
for (const field of outputFields(searchType)) {
|
|
582
|
+
fields[field.name] = outputFieldConfig(field);
|
|
583
|
+
}
|
|
584
|
+
return fields;
|
|
585
|
+
},
|
|
586
|
+
});
|
|
587
|
+
// Every type is filterable on `id`, so the input always has at least one
|
|
588
|
+
// field and always exists – no type is unaddressable by IRI, whatever it
|
|
589
|
+
// declares. It may already have been created as a joined type’s filter
|
|
590
|
+
// shape; `whereInputFor` hands back the same instance either way.
|
|
591
|
+
const whereInput = whereInputFor(searchType);
|
|
500
592
|
const sortValues = {
|
|
501
593
|
RELEVANCE: { value: 'relevance' },
|
|
502
594
|
};
|
|
@@ -545,14 +637,34 @@ export function buildGraphQLSchema(schema, options = {}) {
|
|
|
545
637
|
return {
|
|
546
638
|
type: new GraphQLNonNull(resultType),
|
|
547
639
|
args: {
|
|
548
|
-
query: {
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
640
|
+
query: {
|
|
641
|
+
type: GraphQLString,
|
|
642
|
+
description: 'Free-text query. Omit it to browse by filter alone.',
|
|
643
|
+
},
|
|
644
|
+
where: {
|
|
645
|
+
type: whereInput,
|
|
646
|
+
description: 'Conditions every result must satisfy.',
|
|
647
|
+
},
|
|
648
|
+
orderBy: {
|
|
649
|
+
type: orderByInput,
|
|
650
|
+
description: 'Sort order. Defaults to relevance for a free-text query.',
|
|
651
|
+
},
|
|
652
|
+
page: {
|
|
653
|
+
type: GraphQLInt,
|
|
654
|
+
defaultValue: 1,
|
|
655
|
+
// The bound in the SDL, so the playground’s own documentation
|
|
656
|
+
// answers “how large may a page be?” before a request has to fail
|
|
657
|
+
// to say it.
|
|
658
|
+
description: '1-based page number; at least 1.',
|
|
659
|
+
},
|
|
660
|
+
perPage: {
|
|
661
|
+
type: GraphQLInt,
|
|
662
|
+
defaultValue: 20,
|
|
663
|
+
description: `Results per page, between 0 and ${maxPerPage}. Use 0 for a facet-only query: no results are fetched, and the facet counts still come back.`,
|
|
664
|
+
},
|
|
553
665
|
},
|
|
554
666
|
resolve: async (_source, args, context) => {
|
|
555
|
-
const built = argsToQuery(args, context, searchType, maxPerPage);
|
|
667
|
+
const built = argsToQuery(args, context, searchType, maxPerPage, joins);
|
|
556
668
|
const finalQuery = typeOptions?.queryDefaults
|
|
557
669
|
? typeOptions.queryDefaults(built, context)
|
|
558
670
|
: built;
|
|
@@ -632,19 +744,19 @@ export function printGraphQLSchema(schema, options = {}) {
|
|
|
632
744
|
/** Pure args → {@link SearchQuery} mapping. Rejects out-of-bounds paging
|
|
633
745
|
* (`page < 1`, `perPage` outside `1..maxPerPage`) with a clear error – a
|
|
634
746
|
* negative offset or an unbounded page size must not reach the engine. */
|
|
635
|
-
function argsToQuery(args, context, searchType, maxPerPage) {
|
|
747
|
+
function argsToQuery(args, context, searchType, maxPerPage, joins) {
|
|
636
748
|
const perPage = args.perPage ?? 20;
|
|
637
749
|
const page = args.page ?? 1;
|
|
638
750
|
if (page < 1) {
|
|
639
|
-
throw
|
|
751
|
+
throw userError(`page must be at least 1; got ${page}.`);
|
|
640
752
|
}
|
|
641
753
|
// perPage: 0 is a legitimate facet-only query (no hits, page pins to 1).
|
|
642
754
|
if (perPage < 0 || perPage > maxPerPage) {
|
|
643
|
-
throw
|
|
755
|
+
throw userError(`perPage must be between 0 and ${maxPerPage}; got ${perPage}.`);
|
|
644
756
|
}
|
|
645
757
|
return {
|
|
646
758
|
text: args.query,
|
|
647
|
-
where: whereToFilters(args.where, searchType),
|
|
759
|
+
where: whereToFilters(args.where, searchType, joins),
|
|
648
760
|
orderBy: args.orderBy
|
|
649
761
|
? [{ field: args.orderBy.field, direction: args.orderBy.direction }]
|
|
650
762
|
: [],
|
|
@@ -655,18 +767,37 @@ function argsToQuery(args, context, searchType, maxPerPage) {
|
|
|
655
767
|
locale: context.acceptLanguage[0] ?? 'und',
|
|
656
768
|
};
|
|
657
769
|
}
|
|
658
|
-
|
|
770
|
+
/**
|
|
771
|
+
* Compile one `Where` input into the flat conjunction of disjunctions the IR
|
|
772
|
+
* holds, at `on` hops out from the searched type (`[]` at the top level).
|
|
773
|
+
*
|
|
774
|
+
* A **joined** key carrying `where` recurses with the path extended by that
|
|
775
|
+
* key, and its clauses join this list – exactly how `and` already flattens. The
|
|
776
|
+
* result is still flat: the nesting lives in each criterion’s `on` path, never
|
|
777
|
+
* in the clause structure, so skip-own-filter (ADR 5) still scans one level.
|
|
778
|
+
*/
|
|
779
|
+
function whereToFilters(where, searchType, joins, on = []) {
|
|
659
780
|
// `== null` deliberately: an explicit `where: null` is as absent as an omitted
|
|
660
781
|
// one, and reading keys off it would throw.
|
|
661
782
|
if (where == null) {
|
|
662
783
|
return [];
|
|
663
784
|
}
|
|
664
|
-
const filters =
|
|
785
|
+
const filters = [];
|
|
665
786
|
// Sibling field keys AND, so each becomes a one-criterion filter of its own,
|
|
666
787
|
// while `or` becomes a SINGLE filter carrying every alternative. That is the
|
|
667
788
|
// whole AND/OR mapping: which bucket a criterion lands in, never a combinator
|
|
668
789
|
// inferred from how deeply it is nested.
|
|
669
|
-
const
|
|
790
|
+
for (const entry of keyEntriesOf(where, searchType, joins)) {
|
|
791
|
+
if ('nested' in entry) {
|
|
792
|
+
filters.push(...whereToFilters(entry.nested, entry.target, joins, [
|
|
793
|
+
...on,
|
|
794
|
+
entry.name,
|
|
795
|
+
]));
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
filters.push(filterOn(withPath(entry.criterion, on)));
|
|
799
|
+
}
|
|
800
|
+
const alternatives = (where[OR_KEY] ?? []).flatMap((criterion) => criteriaOf(criterion, searchType, joins, on));
|
|
670
801
|
if (alternatives.length > 0) {
|
|
671
802
|
filters.push({ or: alternatives });
|
|
672
803
|
}
|
|
@@ -675,47 +806,105 @@ function whereToFilters(where, searchType) {
|
|
|
675
806
|
// result flat: nested conjunctions collapse into this one list, and an `and`
|
|
676
807
|
// can never sit inside an `or` (which holds only criteria).
|
|
677
808
|
for (const nested of where[AND_KEY] ?? []) {
|
|
678
|
-
filters.push(...whereToFilters(nested, searchType));
|
|
809
|
+
filters.push(...whereToFilters(nested, searchType, joins, on));
|
|
679
810
|
}
|
|
680
811
|
return filters;
|
|
681
812
|
}
|
|
813
|
+
/** A criterion with its join path attached, or unchanged at the top level –
|
|
814
|
+
* so an ordinary query’s IR is byte-for-byte what it was. */
|
|
815
|
+
function withPath(criterion, on) {
|
|
816
|
+
return on.length === 0 ? criterion : { ...criterion, on };
|
|
817
|
+
}
|
|
682
818
|
/**
|
|
683
|
-
* The criteria a keyed object carries, in declaration order
|
|
684
|
-
* `@oneOf`, so it yields exactly one; a `Where
|
|
685
|
-
* key it sets.
|
|
686
|
-
*
|
|
819
|
+
* The criteria a keyed object carries, in declaration order, `on` hops out. A
|
|
820
|
+
* `Criterion` is `@oneOf`, so it yields exactly one; a `Where` yields one per
|
|
821
|
+
* field key it sets.
|
|
822
|
+
*
|
|
823
|
+
* A joined key carrying `where` is compiled through {@link whereToFilters} and
|
|
824
|
+
* must come back as exactly ONE one-criterion clause. Anything else – two field
|
|
825
|
+
* keys, an `or`, an `and` – is a conjunction, and a conjunction cannot sit
|
|
826
|
+
* inside the `or` this feeds (ADR 18’s flat IR has nowhere to put it), so it is
|
|
827
|
+
* rejected with the rewrite that expresses it instead.
|
|
687
828
|
*/
|
|
688
|
-
function criteriaOf(keyed, searchType) {
|
|
829
|
+
function criteriaOf(keyed, searchType, joins, on = []) {
|
|
689
830
|
const criteria = [];
|
|
831
|
+
for (const entry of keyEntriesOf(keyed, searchType, joins)) {
|
|
832
|
+
if (!('nested' in entry)) {
|
|
833
|
+
criteria.push(withPath(entry.criterion, on));
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
const path = [...on, entry.name];
|
|
837
|
+
const nested = whereToFilters(entry.nested, entry.target, joins, path);
|
|
838
|
+
const [only] = nested;
|
|
839
|
+
// An `or` alternative must be exactly one criterion. Empty and
|
|
840
|
+
// more-than-one are opposite mistakes, so they are named separately rather
|
|
841
|
+
// than sharing a message that fits only one of them.
|
|
842
|
+
if (only === undefined) {
|
|
843
|
+
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.`);
|
|
844
|
+
}
|
|
845
|
+
if (nested.length !== 1 || only.or.length !== 1) {
|
|
846
|
+
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”.`);
|
|
847
|
+
}
|
|
848
|
+
criteria.push(only.or[0]);
|
|
849
|
+
}
|
|
850
|
+
return criteria;
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* The field keys a keyed input sets, in declaration order. `id` is read first:
|
|
854
|
+
* no type declares it, so the field loop never sees it.
|
|
855
|
+
*
|
|
856
|
+
* A joinable reference takes a `‹Target›ReferenceFilter` rather than a plain
|
|
857
|
+
* `StringFilter`, and its two `@oneOf` arms mean different things: `in` filters
|
|
858
|
+
* by the ids the field itself holds – no join, the same membership every other
|
|
859
|
+
* reference gets – while `where` states a condition on the referent and yields
|
|
860
|
+
* a nested entry the caller walks into.
|
|
861
|
+
*/
|
|
862
|
+
function keyEntriesOf(keyed, searchType, joins) {
|
|
863
|
+
const entries = [];
|
|
690
864
|
const id = keyed[ID_FIELD];
|
|
691
865
|
if (id !== undefined && id !== null) {
|
|
692
|
-
|
|
866
|
+
entries.push({ criterion: { field: ID_FIELD, in: id.in ?? [] } });
|
|
693
867
|
}
|
|
694
868
|
for (const field of filterableFields(searchType)) {
|
|
695
869
|
const value = keyed[field.name];
|
|
696
870
|
if (value === undefined || value === null) {
|
|
697
871
|
continue;
|
|
698
872
|
}
|
|
873
|
+
const target = joins.resolve(searchType, [field.name]);
|
|
874
|
+
if (target !== undefined) {
|
|
875
|
+
const nested = value
|
|
876
|
+
.where;
|
|
877
|
+
if (nested !== undefined && nested !== null) {
|
|
878
|
+
entries.push({ name: field.name, nested, target });
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
699
882
|
switch (filterOperatorFor(field.kind)) {
|
|
700
883
|
case 'in':
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
884
|
+
entries.push({
|
|
885
|
+
criterion: {
|
|
886
|
+
field: field.name,
|
|
887
|
+
in: value.in ?? [],
|
|
888
|
+
},
|
|
704
889
|
});
|
|
705
890
|
break;
|
|
706
891
|
case 'range': {
|
|
707
892
|
const range = value;
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
893
|
+
entries.push({
|
|
894
|
+
criterion: {
|
|
895
|
+
field: field.name,
|
|
896
|
+
range: { min: range.min, max: range.max },
|
|
897
|
+
},
|
|
711
898
|
});
|
|
712
899
|
break;
|
|
713
900
|
}
|
|
714
901
|
default:
|
|
715
|
-
|
|
902
|
+
entries.push({
|
|
903
|
+
criterion: { field: field.name, is: value },
|
|
904
|
+
});
|
|
716
905
|
}
|
|
717
906
|
}
|
|
718
|
-
return
|
|
907
|
+
return entries;
|
|
719
908
|
}
|
|
720
909
|
function rangeInput(name, bound) {
|
|
721
910
|
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.22.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.20.0",
|
|
41
41
|
"commander": "^15.0.0",
|
|
42
42
|
"dataloader": "^2.2.3",
|
|
43
43
|
"graphql": "^16.9.0",
|