catalog-client: improve InMemoryCatalogClient to fully implement query features

Add support for ordering, pagination, full-text search, and field
projection to the InMemoryCatalogClient test utility, matching the
behavior of the catalog backend.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Patrik Oldsberg
2026-02-06 21:47:17 +01:00
parent 7eda810329
commit 9cf676284e
5 changed files with 1139 additions and 123 deletions
@@ -0,0 +1,5 @@
---
'@backstage/catalog-client': patch
---
Improved the `InMemoryCatalogClient` test utility to support ordering, pagination, full-text search, and field projection for entity query methods. Also fixed `getEntityFacets` to correctly handle multi-valued fields.
+2
View File
@@ -51,11 +51,13 @@
"@backstage/catalog-model": "workspace:^",
"@backstage/errors": "workspace:^",
"cross-fetch": "^4.0.0",
"lodash": "^4.17.21",
"uri-template": "^2.0.0"
},
"devDependencies": {
"@backstage/cli": "workspace:^",
"@backstage/plugin-catalog-common": "workspace:^",
"@types/lodash": "^4.14.151",
"msw": "^1.0.0"
}
}
File diff suppressed because it is too large Load Diff
@@ -20,6 +20,7 @@ import {
CATALOG_FILTER_EXISTS,
CatalogApi,
EntityFilterQuery,
EntityOrderQuery,
GetEntitiesByRefsRequest,
GetEntitiesByRefsResponse,
GetEntitiesRequest,
@@ -43,6 +44,7 @@ import {
stringifyEntityRef,
} from '@backstage/catalog-model';
import { NotFoundError, NotImplementedError } from '@backstage/errors';
import lodash from 'lodash';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { traverse } from '../../../../plugins/catalog-backend/src/database/operations/stitcher/buildEntitySearch';
import type {
@@ -51,6 +53,14 @@ import type {
} from '@backstage/plugin-catalog-common';
import { DEFAULT_STREAM_ENTITIES_LIMIT } from '../constants.ts';
function makeCursor(data: Record<string, unknown>): string {
return Buffer.from(JSON.stringify(data)).toString('base64');
}
function parseCursor(cursor: string): Record<string, unknown> {
return JSON.parse(Buffer.from(cursor, 'base64').toString('utf8'));
}
function buildEntitySearch(entity: Entity) {
const rows = traverse(entity);
@@ -130,15 +140,125 @@ function createFilter(
};
}
// Resolves a dot-separated field path against an entity, handling keys that
// themselves contain dots (e.g. annotation keys like "backstage.io/orphan").
// This matches the backend's parseEntityTransformParams implementation.
function getPathArrayAndValue(
input: Entity,
field: string,
): [string[], unknown] {
return field.split('.').reduce(
([pathArray, inputSubset], pathPart, index, fieldParts) => {
if (lodash.hasIn(inputSubset, pathPart)) {
return [pathArray.concat(pathPart), (inputSubset as any)[pathPart]];
} else if (fieldParts[index + 1] !== undefined) {
fieldParts[index + 1] = `${pathPart}.${fieldParts[index + 1]}`;
return [pathArray, inputSubset];
}
return [pathArray, undefined];
},
[[] as string[], input as unknown],
);
}
function applyFieldsFilter(entity: Entity, fields?: string[]): Entity {
if (!fields?.length) {
return entity;
}
const output: Record<string, any> = {};
for (const field of fields) {
const [pathArray, value] = getPathArrayAndValue(entity, field);
if (value !== undefined) {
lodash.set(output, pathArray, value);
}
}
return output as Entity;
}
function applyOrdering(entities: Entity[], order?: EntityOrderQuery): Entity[] {
if (!order) {
return entities;
}
const orders = [order].flat();
if (orders.length === 0) {
return entities;
}
const searchMap = new Map<Entity, Array<{ key: string; value: unknown }>>();
for (const entity of entities) {
searchMap.set(entity, buildEntitySearch(entity));
}
return [...entities].sort((a, b) => {
const aRows = searchMap.get(a)!;
const bRows = searchMap.get(b)!;
for (const { field, order: dir } of orders) {
const key = field.toLocaleLowerCase('en-US');
const aRow = aRows.find(r => r.key.toLocaleLowerCase('en-US') === key);
const bRow = bRows.find(r => r.key.toLocaleLowerCase('en-US') === key);
const aValue =
aRow?.value != null
? String(aRow.value).toLocaleLowerCase('en-US')
: null;
const bValue =
bRow?.value != null
? String(bRow.value).toLocaleLowerCase('en-US')
: null;
if (aValue === null && bValue === null) continue;
if (aValue === null) return 1;
if (bValue === null) return -1;
if (aValue < bValue) return dir === 'asc' ? -1 : 1;
if (aValue > bValue) return dir === 'asc' ? 1 : -1;
}
// Stable tie-breaker by entity ref, matching backend behavior
const aRef = stringifyEntityRef(a);
const bRef = stringifyEntityRef(b);
return aRef < bRef ? -1 : aRef > bRef ? 1 : 0;
});
}
function applyFullTextFilter(
entities: Entity[],
fullTextFilter?: { term: string; fields?: string[] },
): Entity[] {
if (!fullTextFilter?.term?.trim()) {
return entities;
}
const term = fullTextFilter.term.trim().toLocaleLowerCase('en-US');
const fields = fullTextFilter.fields?.map(f => f.toLocaleLowerCase('en-US'));
return entities.filter(entity => {
const rows = buildEntitySearch(entity);
return rows.some(row => {
if (
fields?.length &&
!fields.includes(row.key.toLocaleLowerCase('en-US'))
) {
return false;
}
const value =
row.value != null ? String(row.value).toLocaleLowerCase('en-US') : null;
return value != null && value.includes(term);
});
});
}
/**
* Implements a VERY basic fake catalog client that stores entities in memory.
* It has severely limited functionality, and is only useful under certain
* circumstances in tests.
* Implements a fake catalog client that stores entities in memory.
* Supports filtering, ordering, pagination, full-text search, and field
* projection for entity query methods. Location and validation methods
* throw {@link @backstage/errors#NotImplementedError}.
*
* @public
*/
export class InMemoryCatalogClient implements CatalogApi {
#entities: Entity[];
#queryCache = new Map<string, Entity[]>();
#nextQueryId = 0;
constructor(options?: { entities?: Entity[] }) {
this.#entities = options?.entities?.slice() ?? [];
@@ -148,7 +268,34 @@ export class InMemoryCatalogClient implements CatalogApi {
request?: GetEntitiesRequest,
): Promise<GetEntitiesResponse> {
const filter = createFilter(request?.filter);
return { items: this.#entities.filter(filter) };
let items = this.#entities.filter(filter);
items = applyOrdering(items, request?.order);
let offset = request?.offset ?? 0;
let limit = request?.limit;
if (request?.after) {
try {
const cursor = parseCursor(request.after);
if (cursor.offset != null) offset = cursor.offset as number;
if (cursor.limit != null) limit = cursor.limit as number;
} catch {
// ignore invalid cursor
}
}
if (offset > 0) {
items = items.slice(offset);
}
if (limit != null) {
items = items.slice(0, limit);
}
return {
items: request?.fields
? items.map(e => applyFieldsFilter(e, request.fields))
: items,
};
}
async getEntitiesByRefs(
@@ -156,10 +303,13 @@ export class InMemoryCatalogClient implements CatalogApi {
): Promise<GetEntitiesByRefsResponse> {
const filter = createFilter(request.filter);
const refMap = this.#createEntityRefMap();
const items = request.entityRefs
.map(ref => refMap.get(ref))
.map(e => (e && filter(e) ? e : undefined));
return {
items: request.entityRefs
.map(ref => refMap.get(ref))
.map(e => (e && filter(e) ? e : undefined)),
items: request.fields
? items.map(e => (e ? applyFieldsFilter(e, request.fields) : undefined))
: items,
};
}
@@ -167,15 +317,108 @@ export class InMemoryCatalogClient implements CatalogApi {
request?: QueryEntitiesRequest,
): Promise<QueryEntitiesResponse> {
if (request && 'cursor' in request) {
return { items: [], pageInfo: {}, totalItems: 0 };
const cursorData = parseCursor(request.cursor);
const { id, offset, totalItems } = cursorData as {
id: string;
offset: number;
totalItems: number;
};
const cachedItems = this.#queryCache.get(id);
if (!cachedItems) {
return { items: [], pageInfo: {}, totalItems: 0 };
}
const limit = request.limit ?? cachedItems.length;
const pageItems = cachedItems.slice(offset, offset + limit);
const pageInfo: QueryEntitiesResponse['pageInfo'] = {};
if (offset + limit < totalItems) {
pageInfo.nextCursor = makeCursor({
id,
offset: offset + limit,
totalItems,
});
}
if (offset > 0) {
pageInfo.prevCursor = makeCursor({
id,
offset: Math.max(0, offset - limit),
totalItems,
});
}
return {
items: request.fields
? pageItems.map(e => applyFieldsFilter(e, request.fields))
: pageItems,
totalItems,
pageInfo,
};
}
const filter = createFilter(request?.filter);
const items = this.#entities.filter(filter);
// TODO(Rugvip): Pagination
let items = this.#entities.filter(filter);
// Resolve full-text filter default fields to match catalog backend behavior:
// when no fields are specified, search the first sort field, or metadata.uid
if (request?.fullTextFilter) {
const orderFields = request.orderFields
? [request.orderFields].flat()
: [];
items = applyFullTextFilter(items, {
...request.fullTextFilter,
fields: request.fullTextFilter.fields ?? [
orderFields[0]?.field ?? 'metadata.uid',
],
});
}
items = applyOrdering(items, request?.orderFields);
const totalItems = items.length;
const offset = request?.offset ?? 0;
const limit = request?.limit;
// No pagination requested, return all items
if (limit == null && offset === 0) {
return {
items: request?.fields
? items.map(e => applyFieldsFilter(e, request?.fields))
: items,
pageInfo: {},
totalItems,
};
}
const effectiveLimit = limit ?? totalItems;
// Cache the full result set for cursor-based pagination
const id = String(this.#nextQueryId++);
this.#queryCache.set(id, items);
const pageItems = items.slice(offset, offset + effectiveLimit);
const pageInfo: QueryEntitiesResponse['pageInfo'] = {};
if (offset + effectiveLimit < totalItems) {
pageInfo.nextCursor = makeCursor({
id,
offset: offset + effectiveLimit,
totalItems,
});
}
if (offset > 0) {
pageInfo.prevCursor = makeCursor({
id,
offset: Math.max(0, offset - effectiveLimit),
totalItems,
});
}
return {
items,
pageInfo: {},
totalItems: items.length,
items: request?.fields
? pageItems.map(e => applyFieldsFilter(e, request?.fields))
: pageItems,
totalItems,
pageInfo,
};
}
@@ -219,14 +462,21 @@ export class InMemoryCatalogClient implements CatalogApi {
const facetValues = new Map<string, number>();
for (const entity of filteredEntities) {
const rows = buildEntitySearch(entity);
const value = rows.find(
row => row.key === facet.toLocaleLowerCase('en-US'),
)?.value;
if (value) {
facetValues.set(
String(value),
(facetValues.get(String(value)) ?? 0) + 1,
);
// Use a Set to count each distinct value once per entity,
// matching the backend's count(DISTINCT entity_id) behavior
const uniqueValues = new Set(
rows
.filter(
row =>
row.key.toLocaleLowerCase('en-US') ===
facet.toLocaleLowerCase('en-US'),
)
.map(row => row.value)
.filter(v => v != null)
.map(v => String(v)),
);
for (const value of uniqueValues) {
facetValues.set(value, (facetValues.get(value) ?? 0) + 1);
}
}
const counts = Array.from(facetValues.entries()).map(
+2
View File
@@ -3218,7 +3218,9 @@ __metadata:
"@backstage/cli": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/plugin-catalog-common": "workspace:^"
"@types/lodash": "npm:^4.14.151"
cross-fetch: "npm:^4.0.0"
lodash: "npm:^4.17.21"
msw: "npm:^1.0.0"
uri-template: "npm:^2.0.0"
languageName: unknown