Add totalItems mode, sort key lowercasing, and simplified ordering

Layer the remaining concepts from the original PR on top of the
split-query foundation:

- totalItems parameter on REST API (GET and POST) and CatalogApi
- TotalItemsMode type replacing internal skipTotalItems boolean
- Sort field key lowercasing for search table comparison
- whereNotNull(search.value) on the list CTE to exclude truncated values
- Simplified ORDER BY (NULLS LAST removed since NULL values are
  already excluded by whereNotNull)
- New tests for NULL sort field values and multi-valued sort field
  totalItems accuracy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2026-05-21 11:52:37 +02:00
parent 74c5fbbafd
commit 8f20cc2f52
20 changed files with 339 additions and 69 deletions
@@ -0,0 +1,9 @@
---
'@backstage/plugin-catalog-backend': minor
---
`/entities/by-query` now accepts a `totalItems` parameter (`'include'` or `'exclude'`, default `'include'`) that controls whether the response's `totalItems` count is computed. Pass `'exclude'` to skip the count entirely when the caller doesn't need it — useful for cursor-paginated user interfaces that only display the count cosmetically. The accepted values list is forward-compatible: future modes (e.g. approximate counts) can be added without breaking existing callers.
The internal `queryEntities` implementation has also been refactored to run the list and count queries concurrently via `Promise.all`. The list query is now a single statement that the planner can drive with `LIMIT` short-circuiting (no longer wrapped in a multi-reference CTE that forced materialization). Together with `totalItems: 'exclude'` this materially improves the wall-clock time of paginated catalog list views — particularly cursor-paginated user interfaces and second-page-and-onwards traffic where the count is already cached on the cursor.
The internal `QueryEntitiesInitialRequest.skipTotalItems` option has been replaced by `totalItems: 'include' | 'exclude'`. Note that `skipTotalItems` was never exposed as a REST API parameter, so this is only a TypeScript-level change affecting direct callers of `EntitiesCatalog.queryEntities`.
@@ -0,0 +1,5 @@
---
'@backstage/catalog-client': minor
---
`CatalogApi.queryEntities` now accepts a `totalItems` option (`'include'` or `'exclude'`, default `'include'`) on initial requests. Pass `'exclude'` to skip the `totalItems` count when the caller doesn't need it.
+1
View File
@@ -346,6 +346,7 @@ export type QueryEntitiesInitialRequest = {
term: string;
fields?: string[];
};
totalItems?: 'include' | 'exclude';
};
// @public
@@ -326,6 +326,7 @@ export class CatalogClient implements CatalogApi {
offset,
orderFields,
fullTextFilter,
totalItems,
} = request;
params.filter = this.getFilterValue(filter);
@@ -343,6 +344,9 @@ export class CatalogClient implements CatalogApi {
if (fields.length) {
params.fields = fields;
}
if (totalItems !== undefined) {
params.totalItems = totalItems;
}
const normalizedFullTextFilterTerm = fullTextFilter?.term?.trim();
if (normalizedFullTextFilterTerm) {
@@ -387,6 +391,7 @@ export class CatalogClient implements CatalogApi {
orderFields,
fullTextFilter,
fields,
totalItems,
} = request;
let filterPredicate: FilterPredicate | undefined;
@@ -425,6 +430,9 @@ export class CatalogClient implements CatalogApi {
if (fields?.length) {
body.fields = fields;
}
if (totalItems !== undefined) {
body.totalItems = totalItems;
}
} else {
body.cursor = request.cursor;
if (request.limit !== undefined) {
@@ -92,6 +92,7 @@ export type GetEntitiesByQuery = {
orderField?: Array<string>;
cursor?: string;
filter?: Array<string>;
totalItems?: 'include' | 'exclude';
fullTextFilterTerm?: string;
fullTextFilterFields?: Array<string>;
};
@@ -308,6 +309,7 @@ export class DefaultApiClient {
* @param orderField - By default the entities are returned ordered by their internal uid. You can customize the &#x60;orderField&#x60; query parameters to affect that ordering. For example, to return entities by their name: &#x60;/entities/by-query?orderField&#x3D;metadata.name,asc&#x60; Each parameter can be followed by &#x60;asc&#x60; for ascending lexicographical order or &#x60;desc&#x60; for descending (reverse) lexicographical order.
* @param cursor - You may pass the &#x60;cursor&#x60; query parameters to perform cursor based pagination through the set of entities. The value of &#x60;cursor&#x60; will be returned in the response, under the &#x60;pageInfo&#x60; property: &#x60;&#x60;&#x60;json \&quot;pageInfo\&quot;: { \&quot;nextCursor\&quot;: \&quot;a-cursor\&quot;, \&quot;prevCursor\&quot;: \&quot;another-cursor\&quot; } &#x60;&#x60;&#x60; If &#x60;nextCursor&#x60; exists, it can be used to retrieve the next batch of entities. Following the same approach, if &#x60;prevCursor&#x60; exists, it can be used to retrieve the previous batch of entities. - [&#x60;filter&#x60;](#filtering), for selecting only a subset of all entities - [&#x60;fields&#x60;](#field-selection), for selecting only parts of the full data structure of each entity - &#x60;limit&#x60; for limiting the number of entities returned (20 is the default) - [&#x60;orderField&#x60;](#ordering), for deciding the order of the entities - &#x60;fullTextFilter&#x60; **NOTE**: [&#x60;filter&#x60;, &#x60;orderField&#x60;, &#x60;fullTextFilter&#x60;] and &#x60;cursor&#x60; are mutually exclusive. This means that, it isn\&#39;t possible to change any of [&#x60;filter&#x60;, &#x60;orderField&#x60;, &#x60;fullTextFilter&#x60;] when passing &#x60;cursor&#x60; as query parameters, as changing any of these properties will affect pagination. If any of &#x60;filter&#x60;, &#x60;orderField&#x60;, &#x60;fullTextFilter&#x60; is specified together with &#x60;cursor&#x60;, only the latter is taken into consideration.
* @param filter - You can pass in one or more filter sets that get matched against each entity. Each filter set is a number of conditions that all have to match for the condition to be true (conditions effectively have an AND between them). At least one filter set has to be true for the entity to be part of the result set (filter sets effectively have an OR between them). Example: &#x60;&#x60;&#x60;text /entities/by-query?filter&#x3D;kind&#x3D;user,metadata.namespace&#x3D;default&amp;filter&#x3D;kind&#x3D;group,spec.type Return entities that match Filter set 1: Condition 1: kind &#x3D; user AND Condition 2: metadata.namespace &#x3D; default OR Filter set 2: Condition 1: kind &#x3D; group AND Condition 2: spec.type exists &#x60;&#x60;&#x60; Each condition is either on the form &#x60;&lt;key&gt;&#x60;, or on the form &#x60;&lt;key&gt;&#x3D;&lt;value&gt;&#x60;. The first form asserts on the existence of a certain key (with any value), and the second asserts that the key exists and has a certain value. All checks are always case _insensitive_. In all cases, the key is a simplified JSON path in a given piece of entity data. Each part of the path is a key of an object, and the traversal also descends through arrays. There are two special forms: - Array items that are simple value types (such as strings) match on a key-value pair where the key is the item as a string, and the value is the string &#x60;true&#x60; - Relations can be matched on a &#x60;relations.&lt;type&gt;&#x3D;&lt;targetRef&gt;&#x60; form Let\&#39;s look at a simplified example to illustrate the concept: &#x60;&#x60;&#x60;json { \&quot;a\&quot;: { \&quot;b\&quot;: [\&quot;c\&quot;, { \&quot;d\&quot;: 1 }], \&quot;e\&quot;: 7 } } &#x60;&#x60;&#x60; This would match any one of the following conditions: - &#x60;a&#x60; - &#x60;a.b&#x60; - &#x60;a.b.c&#x60; - &#x60;a.b.c&#x3D;true&#x60; - &#x60;a.b.d&#x60; - &#x60;a.b.d&#x3D;1&#x60; - &#x60;a.e&#x60; - &#x60;a.e&#x3D;7&#x60; Some more real world usable examples: - Return all orphaned entities: &#x60;/entities/by-query?filter&#x3D;metadata.annotations.backstage.io/orphan&#x3D;true&#x60; - Return all users and groups: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user&amp;filter&#x3D;kind&#x3D;group&#x60; - Return all service components: &#x60;/entities/by-query?filter&#x3D;kind&#x3D;component,spec.type&#x3D;service&#x60; - Return all entities with the &#x60;java&#x60; tag: &#x60;/entities/by-query?filter&#x3D;metadata.tags.java&#x60; - Return all users who are members of the &#x60;ops&#x60; group (note that the full [reference](references.md) of the group is used): &#x60;/entities/by-query?filter&#x3D;kind&#x3D;user,relations.memberof&#x3D;group:default/ops&#x60;
* @param totalItems - Controls whether the response\&#39;s &#x60;totalItems&#x60; field is computed. Computing the total may be expensive for large catalogs; pass &#x60;exclude&#x60; if the caller does not need it (e.g. cursor-paginated UIs that only display the count cosmetically). Defaults to &#x60;include&#x60;. New values may be added in the future, such as an approximate mode.
* @param fullTextFilterTerm - Text search term.
* @param fullTextFilterFields - A comma separated list of fields to sort returned results by.
*/
@@ -318,7 +320,7 @@ export class DefaultApiClient {
): Promise<TypedResponse<EntitiesQueryResponse>> {
const baseUrl = await this.discoveryApi.getBaseUrl(pluginId);
const uriTemplate = `/entities/by-query{?fields,limit,offset,orderField*,cursor,filter*,fullTextFilterTerm,fullTextFilterFields}`;
const uriTemplate = `/entities/by-query{?fields,limit,offset,orderField*,cursor,filter*,totalItems,fullTextFilterTerm,fullTextFilterFields}`;
const uri = parser.parse(uriTemplate).expand({
...request.query,
@@ -30,8 +30,19 @@ export interface QueryEntitiesByPredicateRequest {
orderBy?: Array<QueryEntitiesByPredicateRequestOrderByInner>;
fullTextFilter?: QueryEntitiesByPredicateRequestFullTextFilter;
fields?: Array<string>;
/**
* Controls whether the response\'s `totalItems` field is computed. Pass `exclude` to skip the count when the caller doesn\'t need it. Defaults to `include`.
*/
totalItems?: QueryEntitiesByPredicateRequestTotalItemsEnum;
/**
* A type representing all allowed JSON object values.
*/
query?: { [key: string]: any };
}
/**
* @public
*/
export type QueryEntitiesByPredicateRequestTotalItemsEnum =
| 'include'
| 'exclude';
+9
View File
@@ -488,6 +488,15 @@ export type QueryEntitiesInitialRequest = {
term: string;
fields?: string[];
};
/**
* Controls whether the response's `totalItems` field is computed.
*
* `'include'` (default) — compute it. `'exclude'` — skip the count entirely;
* the response `totalItems` will be `0`. Useful for cursor-paginated UIs
* that only display the count cosmetically. Additional values may be added
* in the future.
*/
totalItems?: 'include' | 'exclude';
};
/**
+14 -1
View File
@@ -230,9 +230,22 @@ export interface QueryEntitiesInitialRequest {
term: string;
fields?: string[];
};
skipTotalItems?: boolean;
/**
* Controls whether the response's `totalItems` is computed.
*
* `'include'` (default) — compute it. `'exclude'` — skip the count query
* entirely; the response `totalItems` will be `0`. Additional modes (e.g.
* approximate counts) may be added in the future.
*/
totalItems?: TotalItemsMode;
}
/**
* Controls whether {@link EntitiesCatalog.queryEntities} computes the
* `totalItems` field on the response.
*/
export type TotalItemsMode = 'include' | 'exclude';
/**
* Request for {@link EntitiesCatalog.queryEntities} used to
* move forward or backward on the data.
@@ -285,6 +285,22 @@ components:
Order descending by owner:
value:
- spec.owner,desc
totalItems:
name: totalItems
in: query
description: |
Controls whether the response's `totalItems` field is computed. Computing
the total may be expensive for large catalogs; pass `exclude` if the
caller does not need it (e.g. cursor-paginated UIs that only display the
count cosmetically). Defaults to `include`. New values may be added in
the future, such as an approximate mode.
required: false
allowReserved: true
schema:
type: string
enum:
- include
- exclude
requestBodies: {}
responses:
ErrorResponse:
@@ -1084,6 +1100,7 @@ paths:
- $ref: '#/components/parameters/orderField'
- $ref: '#/components/parameters/cursor'
- $ref: '#/components/parameters/filter'
- $ref: '#/components/parameters/totalItems'
- name: fullTextFilterTerm
in: query
description: Text search term.
@@ -1162,6 +1179,15 @@ paths:
type: array
items:
type: string
totalItems:
type: string
enum:
- include
- exclude
description: |
Controls whether the response's `totalItems` field is
computed. Pass `exclude` to skip the count when the caller
doesn't need it. Defaults to `include`.
query:
$ref: '#/components/schemas/JsonObject'
/entity-facets:
@@ -65,6 +65,7 @@ export type GetEntitiesByQuery = {
orderField?: Array<string>;
cursor?: string;
filter?: Array<string>;
totalItems?: 'include' | 'exclude';
fullTextFilterTerm?: string;
fullTextFilterFields?: Array<string>;
};
@@ -30,8 +30,19 @@ export interface QueryEntitiesByPredicateRequest {
orderBy?: Array<QueryEntitiesByPredicateRequestOrderByInner>;
fullTextFilter?: QueryEntitiesByPredicateRequestFullTextFilter;
fields?: Array<string>;
/**
* Controls whether the response\'s `totalItems` field is computed. Pass `exclude` to skip the count when the caller doesn\'t need it. Defaults to `include`.
*/
totalItems?: QueryEntitiesByPredicateRequestTotalItemsEnum;
/**
* A type representing all allowed JSON object values.
*/
query?: { [key: string]: any };
}
/**
* @public
*/
export type QueryEntitiesByPredicateRequestTotalItemsEnum =
| 'include'
| 'exclude';
@@ -199,6 +199,18 @@ export const spec = {
},
},
},
totalItems: {
name: 'totalItems',
in: 'query',
description:
"Controls whether the response's `totalItems` field is computed. Computing\nthe total may be expensive for large catalogs; pass `exclude` if the\ncaller does not need it (e.g. cursor-paginated UIs that only display the\ncount cosmetically). Defaults to `include`. New values may be added in\nthe future, such as an approximate mode.\n",
required: false,
allowReserved: true,
schema: {
type: 'string',
enum: ['include', 'exclude'],
},
},
},
requestBodies: {},
responses: {
@@ -1224,6 +1236,9 @@ export const spec = {
{
$ref: '#/components/parameters/filter',
},
{
$ref: '#/components/parameters/totalItems',
},
{
name: 'fullTextFilterTerm',
in: 'query',
@@ -1332,6 +1347,12 @@ export const spec = {
type: 'string',
},
},
totalItems: {
type: 'string',
enum: ['include', 'exclude'],
description:
"Controls whether the response's `totalItems` field is\ncomputed. Pass `exclude` to skip the count when the caller\ndoesn't need it. Defaults to `include`.\n",
},
query: {
$ref: '#/components/schemas/JsonObject',
},
@@ -1678,7 +1678,7 @@ describe.each(databases.eachSupportedId())(
const request: QueryEntitiesInitialRequest = {
limit: 10,
credentials: mockCredentials.none(),
skipTotalItems: true,
totalItems: 'exclude',
};
let response = await catalog.queryEntities(request);
expect(response).toEqual({
@@ -2177,6 +2177,127 @@ describe.each(databases.eachSupportedId())(
entityFrom('A', { kind: 'component' }),
]);
});
it('should exclude entities with NULL sort-field values from all pages', async () => {
await createDatabase();
// n1, n2, n3 have spec.b with real values
// n4 has spec.b with a value exceeding MAX_VALUE_LENGTH (200 chars),
// which buildEntitySearch stores as value=NULL in the search table
// n5 has no spec.b at all
//
// When sorting by spec.b, queryEntities should exclude both n4
// (NULL value from truncation) and n5 (missing key) from the
// result set AND the totalItems count, so that cursor pagination
// covers exactly the reachable set with no unreachable entities.
await addEntityToSearch({
apiVersion: 'a',
kind: 'k',
metadata: { name: 'n1', uid: 'uid-n1' },
spec: { b: 'alpha', should_include_this: 'yes' },
});
await addEntityToSearch({
apiVersion: 'a',
kind: 'k',
metadata: { name: 'n2', uid: 'uid-n2' },
spec: { b: 'beta', should_include_this: 'yes' },
});
await addEntityToSearch({
apiVersion: 'a',
kind: 'k',
metadata: { name: 'n3', uid: 'uid-n3' },
spec: { b: 'gamma', should_include_this: 'yes' },
});
await addEntityToSearch({
apiVersion: 'a',
kind: 'k',
metadata: { name: 'n4', uid: 'uid-n4' },
spec: { b: 'x'.repeat(201), should_include_this: 'yes' },
});
await addEntityToSearch({
apiVersion: 'a',
kind: 'k',
metadata: { name: 'n5', uid: 'uid-n5' },
spec: { should_include_this: 'yes' },
});
const catalog = new DefaultEntitiesCatalog({
database: knex,
logger: mockServices.logger.mock(),
stitcher,
});
const filter = { key: 'spec.should_include_this' };
// Page through all entities with limit=2, sorting by spec.b ASC.
// We expect to see n1(alpha), n2(beta), n3(gamma) — and NOT n4 or n5.
const response1 = await catalog.queryEntities({
filter,
limit: 2,
orderFields: [{ field: 'spec.b', order: 'asc' }],
credentials: mockCredentials.none(),
});
const page1 = entitiesResponseToObjects(response1.items).map(
e => e!.metadata.name,
);
expect(page1).toEqual(['n1', 'n2']);
expect(response1.pageInfo.nextCursor).toBeDefined();
expect(response1.totalItems).toBe(3);
// Page 2 via cursor
const response2 = await catalog.queryEntities({
cursor: response1.pageInfo.nextCursor!,
limit: 2,
credentials: mockCredentials.none(),
});
const page2 = entitiesResponseToObjects(response2.items).map(
e => e!.metadata.name,
);
expect(page2).toEqual(['n3']);
expect(response2.pageInfo.nextCursor).toBeUndefined();
// Verify: all entities across all pages = n1, n2, n3 (no n4, no n5)
expect([...page1, ...page2]).toEqual(['n1', 'n2', 'n3']);
});
it('should not inflate totalItems when a sort field has multiple search rows per entity', async () => {
await createDatabase();
// Entity e1 has TWO search rows for spec.tags: 'java' and 'go'.
// When sorting by spec.tags, the list query may return e1 twice
// (one row per tag value), but totalItems should still count e1
// only once — not inflate the count.
const e1 = {
apiVersion: 'a',
kind: 'k',
metadata: { name: 'e1', uid: 'uid-e1', tags: ['java', 'go'] },
spec: {},
};
const e2 = {
apiVersion: 'a',
kind: 'k',
metadata: { name: 'e2', uid: 'uid-e2', tags: ['rust'] },
spec: {},
};
await addEntityToSearch(e1);
await addEntityToSearch(e2);
const catalog = new DefaultEntitiesCatalog({
database: knex,
logger: mockServices.logger.mock(),
stitcher,
});
const response = await catalog.queryEntities({
orderFields: [{ field: 'metadata.tags', order: 'asc' }],
limit: 100,
credentials: mockCredentials.none(),
});
// totalItems counts distinct entities, not search rows
expect(response.totalItems).toBe(2);
});
});
describe('removeEntityByUid', () => {
@@ -32,6 +32,7 @@ import {
EntityPagination,
QueryEntitiesRequest,
QueryEntitiesResponse,
TotalItemsMode,
} from '../catalog/types';
import {
DbFinalEntitiesRow,
@@ -375,21 +376,17 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
): Promise<QueryEntitiesResponse> {
const limit = request.limit ?? DEFAULT_LIMIT;
const cursor: Omit<Cursor, 'orderFieldValues'> & {
orderFieldValues?: (string | null)[];
skipTotalItems: boolean;
} = {
orderFields: [],
const { totalItemsMode, ...cursor } = {
orderFields: [] as EntityOrder[],
isPrevious: false,
...parseCursorFromRequest(request),
} satisfies Omit<Cursor, 'orderFieldValues'> & {
orderFieldValues?: (string | null)[];
totalItemsMode: TotalItemsMode;
};
// For performance reasons we invoke the count query only on the first
// request. The result is then embedded into the cursor for subsequent
// requests. Therefore this can be undefined here, but will then get
// populated further down.
const shouldComputeTotalItems =
cursor.totalItems === undefined && !cursor.skipTotalItems;
cursor.totalItems === undefined && totalItemsMode !== 'exclude';
const isFetchingBackwards = cursor.isPrevious;
if (cursor.orderFields.length > 1) {
@@ -397,10 +394,11 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
}
const sortField = cursor.orderFields.at(0);
const sortKey = sortField?.field.toLocaleLowerCase('en-US');
const normalizedFullTextFilterTerm = cursor.fullTextFilter?.term?.trim();
const textFilterFields = cursor.fullTextFilter?.fields ?? [
sortField?.field || 'metadata.uid',
sortKey || 'metadata.uid',
];
// Shared predicate logic applied to both the list CTE and the
@@ -425,8 +423,9 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
if (normalizedFullTextFilterTerm) {
if (
options?.searchInScope &&
sortField &&
textFilterFields.length === 1 &&
textFilterFields[0] === sortField?.field
textFilterFields[0] === sortKey
) {
q.andWhereRaw(
'search.value like ?',
@@ -467,7 +466,8 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
'final_entities.entity_id',
'search.entity_id',
)
.where('search.key', sortField.field)
.where('search.key', sortKey!)
.whereNotNull('search.value')
.whereNotNull('final_entities.final_entity')
.select({
entity_id: 'final_entities.entity_id',
@@ -505,7 +505,7 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
this.database('search')
.select(this.database.raw(1))
.whereRaw('search.entity_id = final_entities.entity_id')
.where('search.key', sortField.field)
.where('search.key', sortKey!)
.whereNotNull('search.value'),
);
}
@@ -540,52 +540,14 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
}
}
// Add the ordering
let order = sortField?.order ?? 'asc';
if (isFetchingBackwards) {
order = invertOrder(order);
}
if (this.database.client.config.client === 'pg') {
// pg correctly orders by the column value and handling nulls in one go
dbQuery.orderBy([
...(sortField
? [
{
column: 'filtered.value',
order,
nulls: 'last',
},
]
: []),
{
column: 'filtered.entity_id',
order,
},
]);
} else {
// sqlite and mysql translate the above statement ONLY into "order by (value is null) asc"
// no matter what the order is, for some reason, so we have to manually add back the statement
// that translates to "order by value <order>" while avoiding to give an order
dbQuery.orderBy([
...(sortField
? [
{
column: 'filtered.value',
order: undefined,
nulls: 'last',
},
{
column: 'filtered.value',
order,
},
]
: []),
{
column: 'filtered.entity_id',
order,
},
]);
}
dbQuery.orderBy([
...(sortField ? [{ column: 'filtered.value', order }] : []),
{ column: 'filtered.entity_id', order },
]);
// Apply a manually set initial offset
if (
@@ -606,7 +568,7 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
let totalItems: number;
if (cursor.totalItems !== undefined) {
totalItems = cursor.totalItems;
} else if (cursor.skipTotalItems) {
} else if (totalItemsMode === 'exclude') {
totalItems = 0;
} else if (countResult?.[0]) {
totalItems = Number(countResult[0].count);
@@ -888,32 +850,31 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
function parseCursorFromRequest(
request?: QueryEntitiesRequest,
): Partial<Cursor> & { skipTotalItems: boolean } {
): Partial<Cursor> & { totalItemsMode: TotalItemsMode } {
if (isQueryEntitiesInitialRequest(request)) {
const {
filter,
query,
orderFields: sortFields = [],
fullTextFilter,
skipTotalItems = false,
totalItems: totalItemsMode = 'include',
} = request;
return {
filter,
query,
orderFields: sortFields,
fullTextFilter,
skipTotalItems,
totalItemsMode,
};
}
if (isQueryEntitiesCursorRequest(request)) {
return {
...request.cursor,
// Doesn't matter here
skipTotalItems: false,
totalItemsMode: 'include',
};
}
return {
skipTotalItems: false,
totalItemsMode: 'include',
};
}
@@ -210,7 +210,7 @@ describe('createRouter readonly disabled', () => {
},
limit: 10000,
credentials: mockCredentials.user(),
skipTotalItems: true,
totalItems: 'exclude',
});
});
@@ -221,7 +221,7 @@ export async function createRouter(
limit,
filter,
orderFields: order,
skipTotalItems: true,
totalItems: 'exclude',
}
: { credentials, fields, limit, cursor },
);
@@ -152,6 +152,27 @@ describe('parseEntityQuery', () => {
}),
).toThrow(/Invalid order field order/);
});
it('parses totalItems include / exclude', () => {
expect(parseEntityQuery({ totalItems: 'exclude' })).toEqual(
expect.objectContaining({ totalItems: 'exclude' }),
);
expect(parseEntityQuery({ totalItems: 'include' })).toEqual(
expect.objectContaining({ totalItems: 'include' }),
);
});
it('omits totalItems when not provided', () => {
const result = parseEntityQuery({});
expect(result).not.toHaveProperty('totalItems');
});
it('throws on invalid totalItems value', () => {
expect(() =>
// @ts-expect-error - invalid enum value
parseEntityQuery({ totalItems: 'maybe' }),
).toThrow(/Invalid totalItems value/);
});
});
describe('cursor request', () => {
@@ -22,10 +22,29 @@ import {
import { z } from 'zod/v3';
import { fromZodError } from 'zod-validation-error/v3';
import { QueryEntitiesByPredicateRequest } from '../../schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model';
import { EntityOrder } from '../../catalog/types';
import { EntityOrder, TotalItemsMode } from '../../catalog/types';
import { Cursor } from '../../catalog/types';
import { decodeCursor } from '../util';
const TOTAL_ITEMS_MODES: TotalItemsMode[] = ['include', 'exclude'];
function parseTotalItems(value: unknown): TotalItemsMode | undefined {
if (value === undefined) {
return undefined;
}
if (
typeof value !== 'string' ||
!TOTAL_ITEMS_MODES.includes(value as TotalItemsMode)
) {
throw new InputError(
`Invalid totalItems value "${value}", expected one of: ${TOTAL_ITEMS_MODES.join(
', ',
)}`,
);
}
return value as TotalItemsMode;
}
const filterPredicateSchema = createZodV3FilterPredicateSchema(z);
function isSupportedFilterPredicateRoot(
@@ -67,6 +86,7 @@ export type ParsedEntityQuery =
fields?: string[];
limit?: number;
offset?: number;
totalItems?: TotalItemsMode;
};
export function parseEntityQuery(
@@ -98,6 +118,7 @@ export function parseEntityQuery(
}
const orderFields = parseOrderFields(request.orderBy);
const totalItemsMode = parseTotalItems(request.totalItems);
return {
query,
@@ -111,5 +132,6 @@ export function parseEntityQuery(
fields: request.fields,
limit: request.limit,
offset: request.offset,
...(totalItemsMode !== undefined && { totalItems: totalItemsMode }),
};
}
@@ -33,6 +33,7 @@ describe('parseQueryEntitiesParams', () => {
orderField: ['metadata.name,desc'],
fullTextFilterTerm: 'query',
fullTextFilterFields: ['metadata.name', 'metadata.namespace'],
totalItems: 'exclude' as const,
};
const parsedObj = parseQueryEntitiesParams(
validRequest,
@@ -46,6 +47,7 @@ describe('parseQueryEntitiesParams', () => {
term: 'query',
fields: ['metadata.name', 'metadata.namespace'],
});
expect(parsedObj.totalItems).toBe('exclude');
expect(parsedObj).not.toHaveProperty('authorizationToken');
expect(parsedObj).not.toHaveProperty('cursor');
});
@@ -57,6 +59,7 @@ describe('parseQueryEntitiesParams', () => {
expect(parsedObj.orderFields).toBeUndefined();
expect(parsedObj.filter).toBeUndefined();
expect(parsedObj.fullTextFilter).toEqual({ term: '', fields: undefined });
expect(parsedObj.totalItems).toBeUndefined();
expect(parsedObj).not.toHaveProperty('authorizationToken');
expect(parsedObj).not.toHaveProperty('cursor');
});
@@ -65,6 +68,8 @@ describe('parseQueryEntitiesParams', () => {
{ filter: 3 },
{ orderField: ['metadata.uid,diagonal'] },
{ fields: [4] },
{ totalItems: 'maybe' },
{ totalItems: 42 },
])('should throw if some parameter is not valid %p', (params: any) => {
expect(() => parseQueryEntitiesParams(params)).toThrow();
});
@@ -14,10 +14,12 @@
* limitations under the License.
*/
import { InputError } from '@backstage/errors';
import {
QueryEntitiesCursorRequest,
QueryEntitiesInitialRequest,
QueryEntitiesRequest,
TotalItemsMode,
} from '../../catalog/types';
import { decodeCursor } from '../util';
import { parseEntityFilterParams } from './parseEntityFilterParams';
@@ -25,6 +27,25 @@ import { parseEntityOrderFieldParams } from './parseEntityOrderFieldParams';
import { parseEntityTransformParams } from './parseEntityTransformParams';
import { GetEntitiesByQuery } from '../../schema/openapi';
const TOTAL_ITEMS_MODES: TotalItemsMode[] = ['include', 'exclude'];
function parseTotalItems(value: unknown): TotalItemsMode | undefined {
if (value === undefined) {
return undefined;
}
if (
typeof value !== 'string' ||
!TOTAL_ITEMS_MODES.includes(value as TotalItemsMode)
) {
throw new InputError(
`Invalid totalItems parameter "${value}", expected one of: ${TOTAL_ITEMS_MODES.join(
', ',
)}`,
);
}
return value as TotalItemsMode;
}
export function parseQueryEntitiesParams(
params: GetEntitiesByQuery['query'],
): Omit<QueryEntitiesRequest, 'credentials' | 'limit'> {
@@ -41,6 +62,7 @@ export function parseQueryEntitiesParams(
const filter = parseEntityFilterParams(params);
const orderFields = parseEntityOrderFieldParams(params);
const totalItemsMode = parseTotalItems(params.totalItems);
const response: Omit<QueryEntitiesInitialRequest, 'credentials'> = {
fields,
@@ -50,6 +72,7 @@ export function parseQueryEntitiesParams(
term: params.fullTextFilterTerm || '',
fields: params.fullTextFilterFields,
},
...(totalItemsMode !== undefined && { totalItems: totalItemsMode }),
};
return response;