Skip to content
Open
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
22 changes: 22 additions & 0 deletions docs/reference/search-api-graphql.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,28 @@ const gqlSchema = buildGraphQLSchema(searchSchema(DATASET, PERSON), {
});
```

A `tieBreak` orders the results that the primary sort leaves tied. Sorted by
date, all datasets registered in one go share a date and come back in whatever
order the engine stored them. A reindex can change that order, so a client
paging through the block may see a dataset twice or miss one:

```ts
types: {
Dataset: { tieBreak: [{ field: 'title', direction: 'asc' }] },
},
```

The tie-break is appended after the sort that the request and `queryDefaults`
settled on. Terms on a field that is already sorted on are skipped. A query
without a sort keeps the engine’s default order – relevance for a free-text
query, the collection’s default sorting field otherwise. To have ties broken
there too, set a default sort in `queryDefaults`. A facet-only query
(`perPage: 0`) gets no tie-break at all. Clients never see the tie-break in the
`orderBy` input. It may have at most two terms, each on a `sortable` field,
because Typesense sorts on at most three terms and one is the primary sort.
Documents that tie on every term, such as two datasets with the same title,
still come back in storage order.

Shared types (`LanguageString`, the facet buckets, filter inputs and reference
types such as a common `Agent`) are created once and reused across root types.

Expand Down
78 changes: 71 additions & 7 deletions packages/search-api-graphql/src/build-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ import {
type SearchQuery,
type SearchSchema,
type SearchType,
type Sort,
} from '@lde/search';
import {
AND_KEY,
facetableFields,
fieldNamed,
filterableFields,
labelTargetNameOf,
localLookupTypeOf,
Expand Down Expand Up @@ -84,12 +86,31 @@ export interface SearchTypeOptions {
/** Root query field; defaults to the lowercased plural of the type’s `name`
* (e.g. `Dataset` → `datasets`). */
readonly queryField?: string;
/** Consumer policy applied to every query of this type (default status, sort,
* tie-breaks). */
/** Consumer policy applied to every query of this type (default status,
* default sort). */
readonly queryDefaults?: (
query: SearchQuery,
context: SearchContext,
) => SearchQuery;
/**
* Sorts that order results the primary sort leaves tied, in precedence
* order – e.g. `[{ field: 'title', direction: 'asc' }]`, so a block of
* datasets sharing a date comes back in the same order on every page.
*
* Appended after the sort the request and {@link queryDefaults} settled on;
* a term on a field already sorted on is skipped. A query with no sort keeps
* the engine’s default order untouched – set a default sort in
* {@link queryDefaults} to have the tie-break follow it. A facet-only query
* (`perPage: 0`) fetches no results to order and gets none.
*
* Deployment policy rather than a client choice, so it adds nothing to the
* `orderBy` input. Each term names a `sortable` field – the only kind an
* engine indexes for sorting – and there are at most two, so a single
* primary sort plus the tie-break stays within Typesense’s cap of three
* terms. A {@link queryDefaults} sort of two terms or more leaves room for
* less; the engine rejects a longer sort per query, naming its terms.
*/
readonly tieBreak?: readonly Sort[];
}

export interface BuildGraphQLSchemaOptions {
Expand Down Expand Up @@ -231,12 +252,28 @@ export function buildGraphQLSchema(
[...schema.values()].map((searchType) => [searchType.name, searchType]),
);
const rootTypeNames = new Set(rootTypesByName.keys());
for (const name of Object.keys(options.types ?? {})) {
if (!rootTypeNames.has(name)) {
for (const [name, typeOptions] of Object.entries(options.types ?? {})) {
const rootType = rootTypesByName.get(name);
if (rootType === undefined) {
throw new Error(
`Options given for type “${name}”, which is not in the search schema.`,
);
}
const tieBreak = typeOptions.tieBreak ?? [];
if (tieBreak.length > MAX_TIE_BREAK_TERMS) {
throw new Error(
`Tie-break for type “${name}” may have at most ${MAX_TIE_BREAK_TERMS} terms; got ${tieBreak.length}. It follows a primary sort, and Typesense sorts on 3 terms at most.`,
);
}
for (const sort of tieBreak) {
// Only a `sortable` field is indexed for sorting, so any other would
// build fine and fail every query.
if (fieldNamed(rootType, sort.field)?.sortable !== true) {
throw new Error(
`Tie-break for type “${name}” sorts on “${sort.field}”, which is not sortable. Declare it on the type with \`sortable: true\`.`,
);
}
}
}

const languageString = new GraphQLObjectType({
Expand Down Expand Up @@ -1045,9 +1082,12 @@ export function buildGraphQLSchema(
// What the client selected decides what each lookup fetches, so the
// engine carries the referent fields this query asked for and no more.
const resolve = projectionFor(info, searchType, schema);
const finalQuery = typeOptions?.queryDefaults
? typeOptions.queryDefaults(built, context)
: built;
const finalQuery = withTieBreak(
typeOptions?.queryDefaults
? typeOptions.queryDefaults(built, context)
: built,
typeOptions?.tieBreak ?? [],
);
// Items + total only; facets are resolved lazily per selected key.
const result = await context.engine.search(searchType, {
...finalQuery,
Expand Down Expand Up @@ -1213,6 +1253,30 @@ function argsToQuery(
};
}

/** One primary sort plus the tie-break stays within Typesense’s three terms. */
const MAX_TIE_BREAK_TERMS = 2;

/** Append the {@link SearchTypeOptions.tieBreak} terms to a query’s `orderBy`,
* as that option documents. */
function withTieBreak(
query: SearchQuery,
tieBreak: readonly Sort[],
): SearchQuery {
// An empty `orderBy` leaves the order to the engine’s own default (relevance,
// a default sorting field), which a tie-break alone would replace.
if (query.orderBy.length === 0 || query.limit === 0) {
return query;
}
const sorted = new Set(query.orderBy.map((sort) => sort.field));
return {
...query,
orderBy: [
...query.orderBy,
...tieBreak.filter((sort) => !sorted.has(sort.field)),
],
};
}

/**
* Compile one `Where` input into the flat conjunction of disjunctions the IR
* holds, at `on` hops out from the searched type (`[]` at the top level).
Expand Down
126 changes: 126 additions & 0 deletions packages/search-api-graphql/test/build-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,132 @@ describe('buildGraphQLSchema', () => {
]);
});

describe('tieBreak', () => {
const tieBreak = [{ field: 'title', direction: 'asc' }] as const;

async function orderByFor(
source: string,
typeOptions: Parameters<typeof buildGraphQLSchema>[1] = {
types: { [schema.name]: { tieBreak } },
},
) {
const { engine, received } = fakeEngine(canned);
const result = await graphql({
schema: buildGraphQLSchema(
searchSchema(schema, organization, term),
typeOptions,
),
source,
contextValue: { engine, acceptLanguage: ['nl'] },
});
expect(result.errors).toBeUndefined();
return received().orderBy;
}

it('appends the tie-break after the requested sort', async () => {
expect(
await orderByFor(
`{ datasets(orderBy: { field: DATE_POSTED }) { pagination { total } } }`,
),
).toEqual([
{ field: 'datePosted', direction: 'desc' },
{ field: 'title', direction: 'asc' },
]);
});

it('skips a tie-break term on the field already sorted on', async () => {
expect(
await orderByFor(
`{ datasets(orderBy: { field: TITLE }) { pagination { total } } }`,
),
).toEqual([{ field: 'title', direction: 'desc' }]);
});

it('leaves a query without a sort to the engine’s default order', async () => {
expect(
await orderByFor(
`{ datasets(query: "atlas") { pagination { total } } }`,
),
).toEqual([]);
});

it('follows an explicit relevance sort', async () => {
expect(
await orderByFor(
`{ datasets(query: "atlas", orderBy: { field: RELEVANCE }) { pagination { total } } }`,
),
).toEqual([
{ field: 'relevance', direction: 'desc' },
{ field: 'title', direction: 'asc' },
]);
});

it('adds no tie-break to a facet-only query', async () => {
expect(
await orderByFor(
`{ datasets(perPage: 0, orderBy: { field: DATE_POSTED }) { pagination { total } } }`,
),
).toEqual([{ field: 'datePosted', direction: 'desc' }]);
});

it('breaks ties in the sort a queryDefaults policy chose', async () => {
expect(
await orderByFor(`{ datasets { pagination { total } } }`, {
types: {
[schema.name]: {
tieBreak,
queryDefaults: (query) => ({
...query,
orderBy: [{ field: 'datePosted', direction: 'desc' }],
}),
},
},
}),
).toEqual([
{ field: 'datePosted', direction: 'desc' },
{ field: 'title', direction: 'asc' },
]);
});

it('rejects a tie-break on an undeclared field', () => {
expect(() =>
buildGraphQLSchema(searchSchema(schema, organization, term), {
types: {
[schema.name]: { tieBreak: [{ field: 'nope', direction: 'asc' }] },
},
}),
).toThrow(/tie-break.*“nope”.*not sortable/i);
});

it('rejects a tie-break on a field the engine does not sort', () => {
expect(() =>
buildGraphQLSchema(searchSchema(schema, organization, term), {
types: {
[schema.name]: {
tieBreak: [{ field: 'keyword', direction: 'asc' }],
},
},
}),
).toThrow(/tie-break.*“keyword”.*not sortable/i);
});

it('rejects a tie-break too long to follow a primary sort', () => {
expect(() =>
buildGraphQLSchema(searchSchema(schema, organization, term), {
types: {
[schema.name]: {
tieBreak: [
{ field: 'title', direction: 'asc' },
{ field: 'size', direction: 'asc' },
{ field: 'datePosted', direction: 'asc' },
],
},
},
}),
).toThrow(/at most 2 terms; got 3/);
});
});

it('derives nullability: required scalar non-null, optional scalar nullable, arrays/booleans non-null', () => {
const sdl = printSchema(
buildGraphQLSchema(
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 @@ -29,7 +29,7 @@ export default mergeConfig(
// Re-anchored for the selection-set projection: its defensive reads
// (an unresolvable target, an absent field list) are reachable only
// from a direct caller, since GraphQL validates the query first.
branches: 97.05,
branches: 97.16,
statements: 100,
},
},
Expand Down
13 changes: 13 additions & 0 deletions packages/search-typesense/src/query-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,16 @@ export function buildSearchParams(
options.schema,
);
const filterBy = compileFilterBy(query.where, searchType, options);
// Typesense answers a longer `sort_by` with an error naming no field; say
// which terms, since some came from a policy (a tie-break) the client
// never wrote.
if (query.orderBy.length > MAX_SORT_TERMS) {
throw new Error(
`Typesense sorts on at most ${MAX_SORT_TERMS} sort terms; got ${query.orderBy.length} (${query.orderBy
.map((sort) => sort.field)
.join(', ')}). Shorten the tie-break or the default sort.`,
);
}
const sortBy = query.orderBy
.map((sort) => compileSort(sort, searchType, query.locale))
.join(',');
Expand Down Expand Up @@ -630,6 +640,9 @@ function storedBound(
: bound;
}

/** Typesense’s cap on `sort_by` terms, `_text_match` included. */
const MAX_SORT_TERMS = 3;

/**
* One `sort_by` term. `relevance` maps to Typesense’s `_text_match`; a localized
* text field sorts on its active-locale folded key; any other field (including a
Expand Down
17 changes: 17 additions & 0 deletions packages/search-typesense/test/query-compiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,23 @@ describe('buildSearchParams', () => {
).toBe('title_sort_nl:asc,status_rank:asc');
});

it('rejects more sort terms than Typesense accepts', () => {
expect(() =>
buildSearchParams(
{
...base,
orderBy: [
{ field: 'datePosted', direction: 'desc' },
{ field: 'title', direction: 'asc' },
{ field: 'size', direction: 'asc' },
{ field: 'status_rank', direction: 'asc' },
],
},
schema,
),
).toThrow(/at most 3 sort terms.*datePosted, title, size, status_rank/);
});

it('pins page to 1 for a facet-only (limit:0) query instead of dividing by zero', () => {
const params = buildSearchParams({ ...base, limit: 0 }, schema);
expect(params.per_page).toBe(0);
Expand Down
Loading
Loading