Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions docs/reference/search-api-graphql.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,10 +287,11 @@ role: { in: ["etser"] } } }` asks for _this person in this role_, where the
and buckets are always [date-times](#dates) and the client picks the grain
it draws at. A field with
[`facetRanges`](./search#range-facets) returns the
`‹Kind›BinnedRangeFacet` variant instead, whose `buckets:
[RangeBucket!]!` hold one bucket per declared half-open `[min, max)` bin,
carrying `min`/`max` (null on an open end) and `count`, with no `value`
or `label`;
`‹Kind›BinnedRangeFacet` variant instead, whose buckets hold one bucket
per declared half-open `[min, max)` bin, carrying `min`/`max` (null on an
open end) and `count`, with no `value` or `label`: `[RangeBucket!]!` for a
number, `[DateRangeBucket!]!` for a date, whose bounds are served at the
precision they were declared in (`1900`, not `1900-01-01T00:00:00Z`);
- a `boolean` field returns `[BooleanBucket!]!` – `value: Boolean!` +
`count`, and **no `label` field at all**. The value is a real boolean, so
the bucket a client selects is exactly the term the `where` filter takes
Expand Down Expand Up @@ -415,7 +416,7 @@ reports a source location instead.
## Dates

Every date in the API – a `date` field, a `DateExtent` bound, a `DateBucket`
value and a `DateRange` bound – is an `ISO8601Date`: an ISO 8601 date written as
value, a `DateRangeBucket` bound and a `DateRange` bound – is an `ISO8601Date`: an ISO 8601 date written as
a string, in one of four forms.

| Precision | Form | Example |
Expand Down Expand Up @@ -446,10 +447,11 @@ in [XML Schema 1.1 Part 2](https://www.w3.org/TR/xmlschema11-2/#dateTime):

As input, a `DateRange` bound may take any of the four forms, and also accepts
an offset (`+02:00`) and fractional seconds. A bound that is none of these is
rejected when the query is coerced, with `BAD_USER_INPUT`. A bound is the first
second of the period it names, so `max: "1999"` stops at 1 January 1999; write
`max: "1999-12-31T23:59:59Z"` to include the whole year. How each source literal
is read is in [the field model](./search#field-model).
rejected when the query is coerced, with `BAD_USER_INPUT`. Both bounds include
the whole period they name, so `{ min: "1900", max: "1999" }` is the years 1900
to 1999, and a `DateRangeBucket`’s bounds select exactly the documents it
counts. How each source literal is read is in
[the field model](./search#field-model).

## Pagination

Expand Down
28 changes: 21 additions & 7 deletions docs/reference/search.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,10 +343,11 @@ is parsed like any literal; one that returns seconds states no precision and is
stored as a date-time. An index built before precision was stored has no such
field, and its dates are served as date-times until a reindex fills it in.

A filter bound is read by the same codec, so `max: "1999"` means the first
second of 1999, and a document dated `1999-05` falls outside it. The range is
inclusive, so a filter covering the whole of 1999 takes
`max: "1999-12-31T23:59:59Z"`.
A filter bound is read by the same codec, and both bounds include the whole
period they name: `min: "1999"` starts at the first second of 1999 and
`max: "1999"` runs through its last, so `{ min: "1999", max: "1999" }` is the
year 1999, a document dated `1999-05` included. A date-time bound is just that
second.

**`array` decides a field’s shape**, whatever the graph carries: a declared
`array` field stores a list, and a single-valued one stores the first value –
Expand Down Expand Up @@ -1009,15 +1010,28 @@ when no matching document holds a value. Its `buckets` count each distinct
value, like any other facet’s, so a document dated `1999` and one dated
`1999-01-01T00:00:00Z` fall in one bucket: they store the same second.

It may instead declare **`facetRanges`**: fixed half-open `[min, max)` bins (a
`FacetRange` each), which make it a **binned range facet** that buckets into
It may instead declare **`facetRanges`**: fixed bins, half-open `[min, max)`
on a number (a `FacetRange` each), which make it a **binned range facet** that buckets into
those bins – the per-bucket counts to draw over that extent. `min` is inclusive, `max`
exclusive, so contiguous bins partition cleanly with no boundary
double-counting; omit `min` (or `max`) for an open-ended bin (`< max`, resp.
`≥ min`). Each bin’s `key` is its stable label, echoed back as the
`FacetBucket` `value`, and the bucket also carries the bin’s declared
`min`/`max` bounds, so it is self-describing – a consumer never hardcodes the
bin formula. Bins are **query-time**: they shape the facet clause the adapter
bin formula.

A `date` field’s bins are different: ISO 8601, like any date a caller writes (a
`DateFacetRange` each), and inclusive at both ends like a date filter, so a bin
and the filter that selects it count the same documents.
`{ key: '20th century', min: '1900', max: '1999' }` holds the years 1900 to
1999, and the next bin starts at `2000`. Contiguous periods still partition
cleanly, because one ends before the next begins. `validateSearchType` rejects
a bound the codec cannot read. At the port the bucket echoes its bounds in
stored terms: half-open Unix seconds, `max` the first second after its period
(`storedFacetRanges` gives an adapter the bins in those terms). The GraphQL
surface serves the declared ones.

Bins are **query-time**: they shape the facet clause the adapter
compiles per query, so changing them needs no reindex. Giving a field its
first bins may: the Typesense adapter indexes a binned field for sorting.
Engine-neutral by design – the Typesense adapter emits a `facet_by`
Expand Down
60 changes: 50 additions & 10 deletions packages/search-api-graphql/src/build-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,16 @@ export function buildGraphQLSchema(
count: { type: new GraphQLNonNull(GraphQLInt) },
},
});
// A binned date facet’s bin: like RangeBucket, with its bounds served as the
// ISO 8601 the bin was declared in, so they feed straight into DateRange.
const dateRangeBucket = new GraphQLObjectType({
name: 'DateRangeBucket',
fields: {
min: { type: dateScalar },
max: { type: dateScalar },
count: { type: new GraphQLNonNull(GraphQLInt) },
},
});
// A boolean-facet bucket: the value as a real boolean, so the bucket a client
// selects round-trips straight into the `is` filter that selects it. No label
// – a boolean has no data label to resolve, and the sensible rendering (“with
Expand Down Expand Up @@ -453,26 +463,45 @@ export function buildGraphQLSchema(
},
buckets: {
type: nonNullListOf(
binned
? rangeBucket
: field.kind === 'date'
? dateBucket
field.kind === 'date'
? binned
? dateRangeBucket
: dateBucket
: binned
? rangeBucket
: valueBucket,
),
description: binned
? 'The declared bins over the same documents as “matching”.'
: 'One bucket per distinct value over the same documents as “matching”.',
resolve: async (source: Source) => {
const { buckets } = await load(source, 'matching');
if (field.kind !== 'date') {
return buckets;
}
if (isBinnedRangeFacet(field)) {
// A bin’s bounds are served as declared, in canonical form, by
// the key the bucket echoes – not from the stored seconds, which
// would turn `1900` into a date-time.
const bins = new Map(
field.facetRanges.map((range) => [range.key, range]),
);
return buckets.map((bucket) => {
const bin = bins.get(bucket.value);
return {
count: bucket.count,
min: canonicalDate(bin?.min),
max: canonicalDate(bin?.max),
};
});
}
// A date’s distinct value is stored as Unix seconds and served as
// an ISO 8601 date-time (an `xsd:gYear` in deep time), like its
// extent: a bucket spans every precision that stored its second.
return field.kind === 'date' && !binned
? buckets.map((bucket) => ({
...bucket,
value: servedDateTime(Number(bucket.value)),
}))
: buckets;
return buckets.map((bucket) => ({
...bucket,
value: servedDateTime(Number(bucket.value)),
}));
},
},
},
Expand Down Expand Up @@ -675,6 +704,7 @@ export function buildGraphQLSchema(
iriBucket.name,
rangeBucket.name,
dateBucket.name,
dateRangeBucket.name,
booleanBucket.name,
paginationType.name,
sortDirection.name,
Expand Down Expand Up @@ -1920,6 +1950,16 @@ function servedDateTime(seconds: number): string {
return unixSecondsToIso(seconds, 'dateTime');
}

/** A declared date in the canonical form of its own precision (`+001900` is
* served `1900`); undefined for an open bound. */
function canonicalDate(declared: string | undefined): string | undefined {
const stored =
declared === undefined ? undefined : isoToUnixSeconds(declared);
return stored === undefined
? undefined
: unixSecondsToIso(stored.seconds, stored.precision);
}

function extentType(
name: string,
bound: typeof GraphQLInt | typeof GraphQLFloat | typeof GraphQLString,
Expand Down
64 changes: 64 additions & 0 deletions packages/search-api-graphql/test/build-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type FacetsOutcome,
type SearchEngine,
type SearchQuery,
type SearchField,
type SearchResult,
type RootType,
type SearchType,
Expand Down Expand Up @@ -521,6 +522,69 @@ describe('buildGraphQLSchema', () => {
expect(facets.keyword).toEqual([{ value: 'kaarten', count: 3 }]);
});

it('serves a binned date facet’s bins in the ISO 8601 they were declared in', async () => {
const binned: SearchType = {
...schema,
fields: schema.fields.map((field): SearchField =>
field.name === 'datePosted'
? {
name: 'datePosted',
kind: 'date',
facetable: true,
output: true,
facetRanges: [
{ key: 'older', max: '+001899' },
{ key: 'twentieth', min: '1900', max: '1999-12-31' },
{ key: 'recent', min: '2000-01-01T00:00:00+01:00' },
],
}
: field,
),
};
const searchSchemaWithBins = searchSchema(binned, organization, term);
const facets: SearchResult['facets'] = {
datePosted: {
// The adapter echoes each bin’s bounds in stored seconds; the surface
// serves the declared ones instead.
buckets: [
{ value: 'older', count: 1, max: -2_208_988_800 },
{ value: 'twentieth', count: 4, min: -2_208_988_800 },
{ value: 'recent', count: 2, min: 946_681_200 },
],
},
};
const engine: SearchEngine = {
schema: searchSchemaWithBins,
search: async () => ({ total: 0, hits: [], facets }),
searchFacets: async (_searchType, queries) =>
queries.map(() => ({ facets })),
};
const built = buildGraphQLSchema(searchSchemaWithBins, datasetOptions);
expect(printSchema(built)).toMatch(
/type DateBinnedRangeFacet {[^}]*buckets: \[DateRangeBucket!\]!/,
);
expect(printSchema(built)).toMatch(
/type DateRangeBucket \{\s+min: ISO8601Date\s+max: ISO8601Date\s+count: Int!/,
);
const result = await graphql({
schema: built,
source: `{ datasets { facets { datePosted { buckets { min max count } } } } }`,
contextValue: { engine, acceptLanguage: ['nl'] },
});
expect(result.errors).toBeUndefined();
expect(
(
(result.data?.datasets as Record<string, unknown>).facets as {
datePosted: { buckets: unknown[] };
}
).datePosted.buckets,
).toEqual([
{ min: null, max: '1899', count: 1 },
{ min: '1900', max: '1999-12-31', count: 4 },
{ min: '1999-12-31T23:00:00Z', max: null, count: 2 },
]);
});

it('serves a range facet’s extent over the collection and over the other filters, in one dispatch', async () => {
const bce = -96_890_000_000;
const modern = 1_700_000_000;
Expand Down
2 changes: 1 addition & 1 deletion packages/search-api-graphql/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export default mergeConfig(
// from a direct caller, since GraphQL validates the query first.
// Re-anchored when facet grouping moved to `@lde/search`, taking its
// covered branches with it.
branches: 98.5,
branches: 98.52,
statements: 100,
},
},
Expand Down
24 changes: 17 additions & 7 deletions packages/search-typesense/src/query-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
filterOperatorFor,
ID_FIELD,
isoToUnixSeconds,
lastSecondOf,
storedFacetRanges,
isInternalField,
isBinnedRangeFacet,
isWelded,
Expand Down Expand Up @@ -174,7 +176,7 @@ function compileFacetBy(
return name;
}
return isBinnedRangeFacet(field)
? compileRangeFacet(field.name, field.facetRanges)
? compileRangeFacet(field.name, storedFacetRanges(field))
: (physicalFields(field, schema).facet ?? name);
})
.join(',');
Expand Down Expand Up @@ -617,8 +619,8 @@ function compileRange(
// the surface passes through – is a bound NOT SET, not a bound of `null`.
// Read literally it reaches the engine as `datePosted:[null..…]`, which
// Typesense rejects outright.
const min = storedBound(field, range.min ?? undefined);
const max = storedBound(field, range.max ?? undefined);
const min = storedBound(field, range.min ?? undefined, 'min');
const max = storedBound(field, range.max ?? undefined, 'max');
if (min !== undefined && max !== undefined) {
return `${name}:[${min}..${max}]`;
}
Expand All @@ -632,14 +634,22 @@ function compileRange(
}

/** A range bound as stored: a `date` field’s ISO 8601 bound becomes the indexed
* Unix seconds ({@link isoToUnixSeconds}); an unparseable bound is dropped. */
* Unix seconds ({@link isoToUnixSeconds}) – a `min` its period’s first second,
* a `max` its last ({@link lastSecondOf}), so the inclusive clause takes in the
* whole period each names. An unparseable bound is dropped. */
function storedBound(
field: SearchField,
bound: number | string | undefined,
side: 'min' | 'max',
): number | string | undefined {
return field.kind === 'date' && typeof bound === 'string'
? isoToUnixSeconds(bound)?.seconds
: bound;
if (field.kind !== 'date' || typeof bound !== 'string') {
return bound;
}
const stored = isoToUnixSeconds(bound);
if (stored === undefined) {
return undefined;
}
return side === 'min' ? stored.seconds : lastSecondOf(stored);
}

/**
Expand Down
7 changes: 4 additions & 3 deletions packages/search-typesense/src/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
physicalFields,
referencedTargetsOf,
referenceFields,
storedFacetRanges,
storedTargetOf,
type FacetSearch,
} from '@lde/search/adapter';
Expand Down Expand Up @@ -1189,12 +1190,12 @@ export function parseSearchResponse(
for (const facet of response.facet_counts ?? []) {
const labelled = referenceFacets.has(facet.field_name);
// A binned facet echoes the declared range key as the bucket value; look
// the bin’s half-open bounds back up by key so the bucket is
// self-describing.
// the bin’s half-open bounds back up by key, in stored terms, so the
// bucket is self-describing.
const field = fieldNamed(searchType, facet.field_name);
const rangesByKey =
field !== undefined && isBinnedRangeFacet(field)
? new Map(field.facetRanges.map((range) => [range.key, range]))
? new Map(storedFacetRanges(field).map((range) => [range.key, range]))
: undefined;
// A boolean facet is reported with the values stringified; recover the
// boolean so the bucket carries the term the `is` filter expects.
Expand Down
30 changes: 30 additions & 0 deletions packages/search-typesense/test/parse-response.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,36 @@ describe('parseSearchResponse range facets', () => {
]);
});

it('echoes a date bin’s bounds in stored seconds', () => {
const dateSchema: SearchType = {
...rangeSchema,
fields: [
{
name: 'created',
kind: 'date',
facetable: true,
output: true,
facetRanges: [{ key: 'seventies', min: '1970', max: '1979' }],
},
],
};
const result = parseSearchResponse(
{
found: 1,
hits: [],
facet_counts: [
{ field_name: 'created', counts: [{ value: 'seventies', count: 1 }] },
],
},
dateSchema,
new Map(),
searchSchema(dateSchema),
);
expect(result.facets.created?.buckets).toEqual([
{ value: 'seventies', count: 1, min: 0, max: 315_532_800 },
]);
});

it('reports the extent from the facet’s stats, not from its bins', () => {
const result = parseSearchResponse(
{
Expand Down
Loading
Loading