diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index bd557cfdd2..2bdf63cc49 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -20,7 +20,8 @@ import { parseEntityRef, stringifyLocationRef, } from '@backstage/catalog-model'; -import { ResponseError } from '@backstage/errors'; +import { InputError, ResponseError } from '@backstage/errors'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { AddLocationRequest, AddLocationResponse, @@ -47,6 +48,7 @@ import { ValidateEntityResponse, } from './types/api'; import { + convertFilterToPredicate, isQueryEntitiesInitialRequest, splitRefsIntoChunks, cursorContainsQuery, @@ -352,11 +354,37 @@ export class CatalogClient implements CatalogApi { const body: QueryEntitiesByPredicateRequest = {}; if (isQueryEntitiesInitialRequest(request)) { - const { query, limit, offset, orderFields, fullTextFilter, fields } = - request; - if (query && typeof query === 'object') { - body.query = query; + const { + filter, + query, + limit, + offset, + orderFields, + fullTextFilter, + fields, + } = request; + + let filterPredicate: FilterPredicate | undefined; + if (query !== undefined) { + if ( + typeof query !== 'object' || + query === null || + Array.isArray(query) + ) { + throw new InputError('Query must be an object'); + } + filterPredicate = query; } + if (filter !== undefined) { + const converted = convertFilterToPredicate(filter); + filterPredicate = filterPredicate + ? { $all: [filterPredicate, converted] } + : converted; + } + if (filterPredicate !== undefined) { + body.query = filterPredicate as unknown as { [key: string]: any }; + } + if (limit !== undefined) { body.limit = limit; } diff --git a/packages/catalog-client/src/utils.test.ts b/packages/catalog-client/src/utils.test.ts index 2f00859985..ce60cc3083 100644 --- a/packages/catalog-client/src/utils.test.ts +++ b/packages/catalog-client/src/utils.test.ts @@ -14,7 +14,87 @@ * limitations under the License. */ -import { splitRefsIntoChunks } from './utils'; +import { CATALOG_FILTER_EXISTS } from './types/api'; +import { convertFilterToPredicate, splitRefsIntoChunks } from './utils'; + +describe('convertFilterToPredicate', () => { + it('converts a single string value', () => { + expect(convertFilterToPredicate({ kind: 'component' })).toEqual({ + kind: 'component', + }); + }); + + it('converts multiple keys into $all', () => { + expect( + convertFilterToPredicate({ + kind: 'component', + 'spec.type': 'service', + }), + ).toEqual({ + $all: [{ kind: 'component' }, { 'spec.type': 'service' }], + }); + }); + + it('converts an array of string values into $in', () => { + expect( + convertFilterToPredicate({ 'spec.type': ['service', 'website'] }), + ).toEqual({ + 'spec.type': { $in: ['service', 'website'] }, + }); + }); + + it('converts CATALOG_FILTER_EXISTS into $exists', () => { + expect( + convertFilterToPredicate({ 'spec.owner': CATALOG_FILTER_EXISTS }), + ).toEqual({ + 'spec.owner': { $exists: true }, + }); + }); + + it('converts an array of records into $any (OR)', () => { + expect( + convertFilterToPredicate([{ kind: 'component' }, { kind: 'api' }]), + ).toEqual({ + $any: [{ kind: 'component' }, { kind: 'api' }], + }); + }); + + it('converts array of records with multiple keys each', () => { + expect( + convertFilterToPredicate([ + { kind: 'component', 'spec.type': 'service' }, + { kind: 'api' }, + ]), + ).toEqual({ + $any: [ + { $all: [{ kind: 'component' }, { 'spec.type': 'service' }] }, + { kind: 'api' }, + ], + }); + }); + + it('treats CATALOG_FILTER_EXISTS mixed with string values as just existence', () => { + expect( + convertFilterToPredicate({ + 'spec.owner': [CATALOG_FILTER_EXISTS, 'team-a'], + }), + ).toEqual({ + 'spec.owner': { $exists: true }, + }); + }); + + it('converts a single-element array filter without wrapping in $any', () => { + expect(convertFilterToPredicate([{ kind: 'component' }])).toEqual({ + kind: 'component', + }); + }); + + it('ignores entries with no valid values', () => { + expect( + convertFilterToPredicate({ kind: 'component', other: [] as string[] }), + ).toEqual({ kind: 'component' }); + }); +}); describe('splitRefsIntoChunks', () => { it('splits by count limit', () => { diff --git a/packages/catalog-client/src/utils.ts b/packages/catalog-client/src/utils.ts index fc867d8670..231aa2409f 100644 --- a/packages/catalog-client/src/utils.ts +++ b/packages/catalog-client/src/utils.ts @@ -14,7 +14,13 @@ * limitations under the License. */ +import type { + FilterPredicate, + FilterPredicateExpression, +} from '@backstage/filter-predicates'; import { + CATALOG_FILTER_EXISTS, + EntityFilterQuery, QueryEntitiesCursorRequest, QueryEntitiesInitialRequest, QueryEntitiesRequest, @@ -39,6 +45,45 @@ export function cursorContainsQuery(cursor: string): boolean { } } +/** + * Converts an {@link EntityFilterQuery} into a predicate query object. + * @internal + */ +export function convertFilterToPredicate(filter: EntityFilterQuery): + | FilterPredicateExpression + | { + $all: FilterPredicate[]; + } + | { + $any: FilterPredicate[]; + } { + const records = [filter].flat(); + + const clauses = records.map(record => { + const parts: FilterPredicateExpression[] = []; + + for (const [key, value] of Object.entries(record)) { + const values = [value].flat(); + const strings = values.filter((v): v is string => typeof v === 'string'); + const hasExists = values.some(v => v === CATALOG_FILTER_EXISTS); + + if (hasExists) { + // Ignore whether there ALSO were some strings - that would boil down to + // just existence anyway since there's effectively an OR between them + parts.push({ [key]: { $exists: true } } as FilterPredicateExpression); + } else if (strings.length === 1) { + parts.push({ [key]: strings[0] } as FilterPredicateExpression); + } else if (strings.length > 1) { + parts.push({ [key]: { $in: strings } } as FilterPredicateExpression); + } + } + + return parts.length === 1 ? parts[0] : { $all: parts }; + }); + + return clauses.length === 1 ? clauses[0] : { $any: clauses }; +} + /** * Takes a set of entity refs, and splits them into chunks (groups) such that * the total string length in each chunk does not exceed the default Express.js