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:
@@ -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.
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user