From e5ee4d8da7f8315df8254274d12eed2eeedd75ba Mon Sep 17 00:00:00 2001 From: Nilay1999 Date: Mon, 12 Jan 2026 11:30:26 +0530 Subject: [PATCH 01/37] feat(catalog-backend): :sparkles: add predicate-based entity filtering - Add EntityPredicate and EntityPredicateRequest types to catalog types - Implement queryEntitiesByPredicate method in DefaultEntitiesCatalog for predicate-based filtering - Create applyPredicateEntityFilterToQuery utility to convert predicates to database queries Signed-off-by: Nilay1999 --- plugins/catalog-backend/src/catalog/types.ts | 18 +- .../src/service/DefaultEntitiesCatalog.ts | 89 +++++++ .../applyPredicateEntityFilterToQuery.ts | 239 ++++++++++++++++++ plugins/catalog-node/src/api/common.ts | 66 +++++ plugins/catalog-node/src/api/index.ts | 4 + 5 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index 1fbf337986..1b87352318 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -16,7 +16,7 @@ import { BackstageCredentials } from '@backstage/backend-plugin-api'; import { Entity } from '@backstage/catalog-model'; -import { EntityFilter } from '@backstage/plugin-catalog-node'; +import { EntityFilter, EntityPredicate } from '@backstage/plugin-catalog-node'; /** * A pagination rule for entities. @@ -52,6 +52,13 @@ export type EntitiesRequest = { credentials: BackstageCredentials; }; +export type EntityPredicateRequest = { + filter?: EntityPredicate; + order?: EntityOrder[]; + pagination?: EntityPagination; + credentials: BackstageCredentials; +}; + /** * Encapsulates either a deserialized or serialized entities to be sent in a response. * @internal @@ -165,6 +172,15 @@ export interface EntitiesCatalog { */ queryEntities(request: QueryEntitiesRequest): Promise; + /** + * Fetch entities using predicate-based filters. + * + * @param request - Request options with predicate filter + */ + queryEntitiesByPredicate( + request?: EntityPredicateRequest, + ): Promise; + /** * Removes a single entity. * diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts index 92c0bb0ecf..c7b14282ef 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts @@ -31,6 +31,7 @@ import { EntityFacetsResponse, EntityOrder, EntityPagination, + EntityPredicateRequest, QueryEntitiesRequest, QueryEntitiesResponse, } from '../catalog/types'; @@ -52,6 +53,7 @@ import { import { EntityFilter } from '@backstage/plugin-catalog-node'; import { LoggerService } from '@backstage/backend-plugin-api'; import { applyEntityFilterToQuery } from './request/applyEntityFilterToQuery'; +import { applyPredicateEntityFilterToQuery } from './request/applyPredicateEntityFilterToQuery'; import { processRawEntitiesResult } from './response'; const DEFAULT_LIMIT = 200; @@ -213,6 +215,93 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { }; } + async queryEntitiesByPredicate( + request?: EntityPredicateRequest, + ): Promise { + const db = this.database; + const { limit, offset } = parsePagination(request?.pagination); + + let entitiesQuery = + db('final_entities').select('final_entities.*'); + + request?.order?.forEach(({ field }, index) => { + const alias = `order_${index}`; + entitiesQuery = entitiesQuery.leftOuterJoin( + { [alias]: 'search' }, + function search(inner) { + inner + .on(`${alias}.entity_id`, 'final_entities.entity_id') + .andOn(`${alias}.key`, db.raw('?', [field])); + }, + ); + }); + + entitiesQuery = entitiesQuery.whereNotNull('final_entities.final_entity'); + + if (request?.filter) { + entitiesQuery = applyPredicateEntityFilterToQuery({ + filter: request.filter, + targetQuery: entitiesQuery, + onEntityIdField: 'final_entities.entity_id', + knex: db, + }); + } + + request?.order?.forEach(({ order }, index) => { + if (db.client.config.client === 'pg') { + entitiesQuery = entitiesQuery.orderBy([ + { column: `order_${index}.value`, order, nulls: 'last' }, + ]); + } else { + entitiesQuery = entitiesQuery.orderBy([ + { column: `order_${index}.value`, order: undefined, nulls: 'last' }, + { column: `order_${index}.value`, order }, + ]); + } + }); + + if (!request?.order) { + entitiesQuery = entitiesQuery.orderBy('final_entities.entity_ref', 'asc'); + } else { + entitiesQuery.orderBy('final_entities.entity_id', 'asc'); + } + + if (limit !== undefined) { + entitiesQuery = entitiesQuery.limit(limit + 1); + } + if (offset !== undefined) { + entitiesQuery = entitiesQuery.offset(offset); + } + + let rows = await entitiesQuery; + let pageInfo: DbPageInfo; + if (limit === undefined || rows.length <= limit) { + pageInfo = { hasNextPage: false }; + } else { + rows = rows.slice(0, -1); + pageInfo = { + hasNextPage: true, + endCursor: stringifyPagination({ + limit, + offset: (offset ?? 0) + limit, + }), + }; + } + + return { + entities: processRawEntitiesResult( + rows.map(r => r.final_entity!), + this.enableRelationsCompatibility + ? e => { + expandLegacyCompoundRelationsInEntity(e); + return e; + } + : undefined, + ), + pageInfo, + }; + } + async entitiesBatch( request: EntitiesBatchRequest, ): Promise { diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts new file mode 100644 index 0000000000..59e08e7d9e --- /dev/null +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -0,0 +1,239 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + EntityPredicate, + EntityPredicatePrimitive, + EntityPredicateValue, +} from '@backstage/plugin-catalog-node'; +import { Knex } from 'knex'; +import { DbSearchRow } from '../../database/tables'; + +function isAllPredicate( + filter: EntityPredicate, +): filter is { $all: EntityPredicate[] } { + return typeof filter === 'object' && filter !== null && '$all' in filter; +} + +function isAnyPredicate( + filter: EntityPredicate, +): filter is { $any: EntityPredicate[] } { + return typeof filter === 'object' && filter !== null && '$any' in filter; +} + +function isNotPredicate( + filter: EntityPredicate, +): filter is { $not: EntityPredicate } { + return typeof filter === 'object' && filter !== null && '$not' in filter; +} + +function isPrimitive(value: unknown): value is EntityPredicatePrimitive { + return ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ); +} + +function isExistsValue( + value: EntityPredicateValue, +): value is { $exists: boolean } { + return typeof value === 'object' && value !== null && '$exists' in value; +} + +function isInValue( + value: EntityPredicateValue, +): value is { $in: EntityPredicatePrimitive[] } { + return typeof value === 'object' && value !== null && '$in' in value; +} + +function isFieldExpression(filter: EntityPredicate): boolean { + if (typeof filter !== 'object' || filter === null) { + return false; + } + // Not a logical operator + return !('$all' in filter || '$any' in filter || '$not' in filter); +} + +/** + * example generated query + * Selects all non-null final entities that: + * + * - Are of kind "component" + * - Have spec.type = "service" + * - Are owned by either: + * - backend-team + * - platform-team + * - Are NOT in experimental lifecycle + * + * Results are ordered by entity_ref in ascending order. + * + * SQL Reference: + * ``` + * SELECT final_entities.* + * FROM final_entities + * WHERE final_entities.final_entity IS NOT NULL + * AND final_entities.entity_id IN ( + * SELECT entity_id FROM search WHERE key = 'kind' AND value = 'component' + * ) + * AND final_entities.entity_id IN ( + * SELECT entity_id FROM search WHERE key = 'spec.type' AND value = 'service' + * ) + * AND ( + * final_entities.entity_id IN ( + * SELECT entity_id FROM search WHERE key = 'spec.owner' AND value = 'backend-team' + * ) + * OR + * final_entities.entity_id IN ( + * SELECT entity_id FROM search WHERE key = 'spec.owner' AND value = 'platform-team' + * ) + * ) + * AND final_entities.entity_id NOT IN ( + * SELECT entity_id FROM search WHERE key = 'spec.lifecycle' AND value = 'experimental' + * ) + * ORDER BY final_entities.entity_ref ASC; + * ``` + */ + +function applyPredicateInStrategy( + filter: EntityPredicate, + targetQuery: Knex.QueryBuilder, + onEntityIdField: string, + knex: Knex, + negate: boolean, +): Knex.QueryBuilder { + // Handle $not + if (isNotPredicate(filter)) { + return applyPredicateInStrategy( + filter.$not, + targetQuery, + onEntityIdField, + knex, + !negate, + ); + } + + // Handle $all (AND) + if (isAllPredicate(filter)) { + return targetQuery[negate ? 'andWhereNot' : 'andWhere']( + function allFilter() { + for (const subFilter of filter.$all) { + this.andWhere(subQuery => + applyPredicateInStrategy( + subFilter, + subQuery, + onEntityIdField, + knex, + false, + ), + ); + } + }, + ); + } + + // Handle $any (OR) + if (isAnyPredicate(filter)) { + return targetQuery[negate ? 'andWhereNot' : 'andWhere']( + function anyFilter() { + for (const subFilter of filter.$any) { + this.orWhere(subQuery => + applyPredicateInStrategy( + subFilter, + subQuery, + onEntityIdField, + knex, + false, + ), + ); + } + }, + ); + } + + // Handle primitive value at top level (e.g., "component" shorthand) + if (isPrimitive(filter)) { + const matchQuery = knex('search') + .select('search.entity_id') + .where({ value: String(filter).toLowerCase() }); + return targetQuery.andWhere( + onEntityIdField, + negate ? 'not in' : 'in', + matchQuery, + ); + } + + // Handle field expressions like { "kind": "component" } or { "spec.type": { "$in": ["service", "website"] } } + if (isFieldExpression(filter)) { + return targetQuery[negate ? 'andWhereNot' : 'andWhere']( + function fieldFilter() { + for (const [key, value] of Object.entries(filter)) { + const normalizedKey = key.toLowerCase(); + + if (isExistsValue(value)) { + // Handle $exists + const existsQuery = knex('search') + .select('search.entity_id') + .where({ key: normalizedKey }); + + if (value.$exists) { + this.andWhere(onEntityIdField, 'in', existsQuery); + } else { + this.andWhere(onEntityIdField, 'not in', existsQuery); + } + } else if (isInValue(value)) { + // Handle $in + const values = value.$in.map(v => String(v).toLowerCase()); + const matchQuery = knex('search') + .select('search.entity_id') + .where({ key: normalizedKey }) + .whereIn('value', values); + this.andWhere(onEntityIdField, 'in', matchQuery); + } else if (isPrimitive(value)) { + // Handle direct value match + const matchQuery = knex('search') + .select('search.entity_id') + .where({ + key: normalizedKey, + value: String(value).toLowerCase(), + }); + this.andWhere(onEntityIdField, 'in', matchQuery); + } + } + }, + ); + } + + return targetQuery; +} + +export function applyPredicateEntityFilterToQuery(options: { + filter: EntityPredicate; + targetQuery: Knex.QueryBuilder; + onEntityIdField: string; + knex: Knex; + strategy?: 'in' | 'join'; +}): Knex.QueryBuilder { + const { filter, targetQuery, onEntityIdField, knex } = options; + + return applyPredicateInStrategy( + filter, + targetQuery, + onEntityIdField, + knex, + false, + ); +} diff --git a/plugins/catalog-node/src/api/common.ts b/plugins/catalog-node/src/api/common.ts index 7f9bd3eabf..f88857a312 100644 --- a/plugins/catalog-node/src/api/common.ts +++ b/plugins/catalog-node/src/api/common.ts @@ -85,3 +85,69 @@ export type EntitiesSearchFilter = { */ values?: string[]; }; + +/** + * Defines a predicate used to filter entities using logical and comparison operators. + * + * This type supports: + * - Primitive equality matching + * - Field existence checks + * - Inclusion checks + * - Logical composition using $all, $any, and $not + * + * @public + */ +export type EntityPredicate = + | EntityPredicateExpression + | EntityPredicatePrimitive + | { $all: EntityPredicate[] } + | { $any: EntityPredicate[] } + | { $not: EntityPredicate }; + +/** + * Represents a field-based predicate expression. + * + * Each key is treated as a JSON path into the entity object. + * Keys starting with `$` are reserved for operators and are not allowed. + * + * Example: + * ```ts + * { + * "spec.type": "service", + * "metadata.name": { $in: ["payments", "orders"] } + * } + * ``` + * + * @public + */ +export type EntityPredicateExpression = { + /** JSON path of the entity field */ + [KPath in string]: EntityPredicateValue; +} & { + /** Operator keys are not allowed as field paths */ + [KPath in `$${string}`]: never; +}; + +/** + * Represents a value condition for a predicate field. + * + * Supported forms: + * - Primitive equality match + * - Existence check using `$exists` + * - Inclusion check using `$in` + * + * @public + */ +export type EntityPredicateValue = + | EntityPredicatePrimitive + | { $exists: boolean } + | { $in: EntityPredicatePrimitive[] }; + +/** + * Represents a primitive predicate value. + * + * Used for direct equality comparison. + * + * @public + */ +export type EntityPredicatePrimitive = string | number | boolean; diff --git a/plugins/catalog-node/src/api/index.ts b/plugins/catalog-node/src/api/index.ts index 292a6cd872..5b7abbb6b9 100644 --- a/plugins/catalog-node/src/api/index.ts +++ b/plugins/catalog-node/src/api/index.ts @@ -20,6 +20,10 @@ export type { LocationSpec, EntitiesSearchFilter, EntityFilter, + EntityPredicate, + EntityPredicateExpression, + EntityPredicateValue, + EntityPredicatePrimitive, } from './common'; export type { CatalogProcessor, From b883042faccf42b4a7ac3b6298783667b6a9c5cc Mon Sep 17 00:00:00 2001 From: Nilay1999 Date: Mon, 12 Jan 2026 11:32:28 +0530 Subject: [PATCH 02/37] =?UTF-8?q?feat(catalog-backend):=20=E2=9C=A8=20add?= =?UTF-8?q?=20post=20api=20for=20predicate-based=20entity=20filtering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add new /entities/by-predicates POST endpoint for expressive entity queries - Introduce EntityPredicate schema supporting logical operators ($all, $any, $not) - Add EntityPredicateValue schema with field operators ($exists, $in) - Generate OpenAPI models for predicate-based filtering - Update failing test case files Signed-off-by: Nilay1999 --- .../catalog-backend/src/schema/openapi.yaml | 165 ++++++++++++ .../openapi/generated/apis/Api.server.ts | 17 ++ .../generated/models/EntityPredicate.model.ts | 36 +++ .../models/EntityPredicateOneOf.model.ts | 27 ++ .../models/EntityPredicateOneOf1.model.ts | 27 ++ .../models/EntityPredicateOneOf2.model.ts | 27 ++ .../models/EntityPredicateValue.model.ts | 32 +++ .../models/EntityPredicateValueOneOf.model.ts | 26 ++ .../EntityPredicateValueOneOf1.model.ts | 27 ++ ...EntityPredicateValueOneOf1InInner.model.ts | 24 ++ ...etEntitiesByPredicates200Response.model.ts | 32 +++ ...esByPredicates200ResponsePageInfo.model.ts | 29 ++ .../GetEntitiesByPredicatesRequest.model.ts | 27 ++ .../schema/openapi/generated/models/index.ts | 11 + .../src/schema/openapi/generated/router.ts | 247 ++++++++++++++++++ .../service/AuthorizedEntitiesCatalog.test.ts | 1 + .../src/service/AuthorizedEntitiesCatalog.ts | 31 +++ .../src/service/createRouter.test.ts | 3 + .../src/service/createRouter.ts | 52 +++- 19 files changed, 840 insertions(+), 1 deletion(-) create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf1.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf2.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1InInner.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200Response.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200ResponsePageInfo.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicatesRequest.model.ts diff --git a/plugins/catalog-backend/src/schema/openapi.yaml b/plugins/catalog-backend/src/schema/openapi.yaml index 9f78cc6210..d4c7560c30 100644 --- a/plugins/catalog-backend/src/schema/openapi.yaml +++ b/plugins/catalog-backend/src/schema/openapi.yaml @@ -338,6 +338,71 @@ components: properties: {} description: A type representing all allowed JSON object values. additionalProperties: {} + EntityPredicate: + description: | + A predicate-based filter supporting logical operators. + - $all: All conditions must match (AND) + - $any: At least one condition must match (OR) + - $not: Negates the condition + - $exists: Check if field exists + - $in: Match any value in array + oneOf: + - type: string + - type: number + - type: boolean + - type: object + additionalProperties: false + properties: + $all: + type: array + items: + $ref: '#/components/schemas/EntityPredicate' + required: + - $all + - type: object + additionalProperties: false + properties: + $any: + type: array + items: + $ref: '#/components/schemas/EntityPredicate' + required: + - $any + - type: object + additionalProperties: false + properties: + $not: + $ref: '#/components/schemas/EntityPredicate' + required: + - $not + - type: object + additionalProperties: + $ref: '#/components/schemas/EntityPredicateValue' + EntityPredicateValue: + description: Value for a field predicate + oneOf: + - type: string + - type: number + - type: boolean + - type: object + additionalProperties: false + properties: + $exists: + type: boolean + required: + - $exists + - type: object + additionalProperties: false + properties: + $in: + type: array + items: + oneOf: + - type: string + - type: number + - type: boolean + required: + - $in MapStringString: type: object properties: {} @@ -1095,6 +1160,106 @@ paths: type: string explode: false style: form + /entities/by-predicates: + post: + operationId: GetEntitiesByPredicates + tags: + - Entity + description: | + Query entities using predicate-based filters. This endpoint supports a more + expressive filter syntax with logical operators ($all, $any, $not) and + value operators ($exists, $in). + + Example filter: + ```json + { + "filter": { + "$all": [ + {"kind": "component"}, + {"$any": [ + {"spec.type": "service"}, + {"spec.type": "website"} + ]}, + {"$not": {"spec.lifecycle": "experimental"}} + ] + } + } + ``` + responses: + '200': + description: Ok + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/Entity' + description: The list of entities matching the predicate filter. + pageInfo: + type: object + properties: + nextCursor: + type: string + description: The cursor for the next batch of entities. + required: + - items + - pageInfo + '400': + $ref: '#/components/responses/ErrorResponse' + default: + $ref: '#/components/responses/ErrorResponse' + security: + - {} + - JWT: [] + parameters: + - $ref: '#/components/parameters/limit' + - $ref: '#/components/parameters/offset' + - $ref: '#/components/parameters/after' + - name: order + in: query + allowReserved: true + required: false + schema: + type: array + items: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + filter: + $ref: '#/components/schemas/EntityPredicate' + examples: + Get all service components: + value: + filter: + $all: + - kind: component + - spec.type: service + Get components owned by specific teams: + value: + filter: + $all: + - kind: component + - spec.owner: + $in: + - backend-team + - platform-team + Get non-production services: + value: + filter: + $all: + - kind: component + - spec.type: service + - $not: + spec.lifecycle: production /entity-facets: get: operationId: GetEntityFacets diff --git a/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts b/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts index 8254df6bab..a185f9a359 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts @@ -25,6 +25,8 @@ import { EntitiesQueryResponse } from '../models/EntitiesQueryResponse.model'; import { Entity } from '../models/Entity.model'; import { EntityAncestryResponse } from '../models/EntityAncestryResponse.model'; import { EntityFacetsResponse } from '../models/EntityFacetsResponse.model'; +import { GetEntitiesByPredicates200Response } from '../models/GetEntitiesByPredicates200Response.model'; +import { GetEntitiesByPredicatesRequest } from '../models/GetEntitiesByPredicatesRequest.model'; import { GetEntitiesByRefsRequest } from '../models/GetEntitiesByRefsRequest.model'; import { RefreshEntityRequest } from '../models/RefreshEntityRequest.model'; import { ValidateEntity400Response } from '../models/ValidateEntity400Response.model'; @@ -53,6 +55,19 @@ export type GetEntities = { }; response: Array | Error | Error; }; +/** + * @public + */ +export type GetEntitiesByPredicates = { + body: GetEntitiesByPredicatesRequest; + query: { + limit?: number; + offset?: number; + after?: string; + order?: Array; + }; + response: GetEntitiesByPredicates200Response | Error | Error; +}; /** * @public */ @@ -208,6 +223,8 @@ export type EndpointMap = { '#get|/entities': GetEntities; + '#post|/entities/by-predicates': GetEntitiesByPredicates; + '#get|/entities/by-query': GetEntitiesByQuery; '#post|/entities/by-refs': GetEntitiesByRefs; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts new file mode 100644 index 0000000000..18b9038228 --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { EntityPredicateOneOf } from '../models/EntityPredicateOneOf.model'; +import { EntityPredicateOneOf1 } from '../models/EntityPredicateOneOf1.model'; +import { EntityPredicateOneOf2 } from '../models/EntityPredicateOneOf2.model'; +import { EntityPredicateValue } from '../models/EntityPredicateValue.model'; + +/** + * A predicate-based filter supporting logical operators. - $all: All conditions must match (AND) - $any: At least one condition must match (OR) - $not: Negates the condition - $exists: Check if field exists - $in: Match any value in array + * @public + */ +export type EntityPredicate = + | EntityPredicateOneOf + | EntityPredicateOneOf1 + | EntityPredicateOneOf2 + | boolean + | number + | string + | { [key: string]: EntityPredicateValue }; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf.model.ts new file mode 100644 index 0000000000..da6f4678b9 --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf.model.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { EntityPredicate } from '../models/EntityPredicate.model'; + +/** + * @public + */ +export interface EntityPredicateOneOf { + $all: Array; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf1.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf1.model.ts new file mode 100644 index 0000000000..8e2b1217f5 --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf1.model.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { EntityPredicate } from '../models/EntityPredicate.model'; + +/** + * @public + */ +export interface EntityPredicateOneOf1 { + $any: Array; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf2.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf2.model.ts new file mode 100644 index 0000000000..af1ca66a3a --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf2.model.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { EntityPredicate } from '../models/EntityPredicate.model'; + +/** + * @public + */ +export interface EntityPredicateOneOf2 { + $not: EntityPredicate; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts new file mode 100644 index 0000000000..0ea00bef2f --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { EntityPredicateValueOneOf } from '../models/EntityPredicateValueOneOf.model'; +import { EntityPredicateValueOneOf1 } from '../models/EntityPredicateValueOneOf1.model'; + +/** + * Value for a field predicate + * @public + */ +export type EntityPredicateValue = + | EntityPredicateValueOneOf + | EntityPredicateValueOneOf1 + | boolean + | number + | string; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf.model.ts new file mode 100644 index 0000000000..e260d2456a --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf.model.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export interface EntityPredicateValueOneOf { + $exists: boolean; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1.model.ts new file mode 100644 index 0000000000..1bea769175 --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1.model.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { EntityPredicateValueOneOf1InInner } from '../models/EntityPredicateValueOneOf1InInner.model'; + +/** + * @public + */ +export interface EntityPredicateValueOneOf1 { + $in: Array; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1InInner.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1InInner.model.ts new file mode 100644 index 0000000000..45c7bfac4c --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1InInner.model.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export type EntityPredicateValueOneOf1InInner = boolean | number | string; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200Response.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200Response.model.ts new file mode 100644 index 0000000000..0acbfe559e --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200Response.model.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { Entity } from '../models/Entity.model'; +import { GetEntitiesByPredicates200ResponsePageInfo } from '../models/GetEntitiesByPredicates200ResponsePageInfo.model'; + +/** + * @public + */ +export interface GetEntitiesByPredicates200Response { + /** + * The list of entities matching the predicate filter. + */ + items: Array; + pageInfo: GetEntitiesByPredicates200ResponsePageInfo; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200ResponsePageInfo.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200ResponsePageInfo.model.ts new file mode 100644 index 0000000000..64ea958943 --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200ResponsePageInfo.model.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export interface GetEntitiesByPredicates200ResponsePageInfo { + /** + * The cursor for the next batch of entities. + */ + nextCursor?: string; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicatesRequest.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicatesRequest.model.ts new file mode 100644 index 0000000000..2937e27df5 --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicatesRequest.model.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { EntityPredicate } from '../models/EntityPredicate.model'; + +/** + * @public + */ +export interface GetEntitiesByPredicatesRequest { + filter?: EntityPredicate; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts index abdb58378c..abd35ed409 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts @@ -31,10 +31,21 @@ export * from '../models/EntityFacet.model'; export * from '../models/EntityFacetsResponse.model'; export * from '../models/EntityLink.model'; export * from '../models/EntityMeta.model'; +export * from '../models/EntityPredicate.model'; +export * from '../models/EntityPredicateOneOf.model'; +export * from '../models/EntityPredicateOneOf1.model'; +export * from '../models/EntityPredicateOneOf2.model'; +export * from '../models/EntityPredicateValue.model'; +export * from '../models/EntityPredicateValueOneOf.model'; +export * from '../models/EntityPredicateValueOneOf1.model'; +export * from '../models/EntityPredicateValueOneOf1InInner.model'; export * from '../models/EntityRelation.model'; export * from '../models/ErrorError.model'; export * from '../models/ErrorRequest.model'; export * from '../models/ErrorResponse.model'; +export * from '../models/GetEntitiesByPredicates200Response.model'; +export * from '../models/GetEntitiesByPredicates200ResponsePageInfo.model'; +export * from '../models/GetEntitiesByPredicatesRequest.model'; export * from '../models/GetEntitiesByRefsRequest.model'; export * from '../models/GetLocations200ResponseInner.model'; export * from '../models/GetLocationsByQueryRequest.model'; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/router.ts b/plugins/catalog-backend/src/schema/openapi/generated/router.ts index b4078df721..5fe2c642b7 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/router.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/router.ts @@ -262,6 +262,110 @@ export const spec = { description: 'A type representing all allowed JSON object values.', additionalProperties: {}, }, + EntityPredicate: { + description: + 'A predicate-based filter supporting logical operators.\n- $all: All conditions must match (AND)\n- $any: At least one condition must match (OR)\n- $not: Negates the condition\n- $exists: Check if field exists\n- $in: Match any value in array\n', + oneOf: [ + { + type: 'string', + }, + { + type: 'number', + }, + { + type: 'boolean', + }, + { + type: 'object', + additionalProperties: false, + properties: { + $all: { + type: 'array', + items: { + $ref: '#/components/schemas/EntityPredicate', + }, + }, + }, + required: ['$all'], + }, + { + type: 'object', + additionalProperties: false, + properties: { + $any: { + type: 'array', + items: { + $ref: '#/components/schemas/EntityPredicate', + }, + }, + }, + required: ['$any'], + }, + { + type: 'object', + additionalProperties: false, + properties: { + $not: { + $ref: '#/components/schemas/EntityPredicate', + }, + }, + required: ['$not'], + }, + { + type: 'object', + additionalProperties: { + $ref: '#/components/schemas/EntityPredicateValue', + }, + }, + ], + }, + EntityPredicateValue: { + description: 'Value for a field predicate', + oneOf: [ + { + type: 'string', + }, + { + type: 'number', + }, + { + type: 'boolean', + }, + { + type: 'object', + additionalProperties: false, + properties: { + $exists: { + type: 'boolean', + }, + }, + required: ['$exists'], + }, + { + type: 'object', + additionalProperties: false, + properties: { + $in: { + type: 'array', + items: { + oneOf: [ + { + type: 'string', + }, + { + type: 'number', + }, + { + type: 'boolean', + }, + ], + }, + }, + }, + required: ['$in'], + }, + ], + }, MapStringString: { type: 'object', properties: {}, @@ -1229,6 +1333,149 @@ export const spec = { ], }, }, + '/entities/by-predicates': { + post: { + operationId: 'GetEntitiesByPredicates', + tags: ['Entity'], + description: + 'Query entities using predicate-based filters. This endpoint supports a more\nexpressive filter syntax with logical operators ($all, $any, $not) and\nvalue operators ($exists, $in).\n\nExample filter:\n```json\n{\n "filter": {\n "$all": [\n {"kind": "component"},\n {"$any": [\n {"spec.type": "service"},\n {"spec.type": "website"}\n ]},\n {"$not": {"spec.lifecycle": "experimental"}}\n ]\n }\n}\n```\n', + responses: { + '200': { + description: 'Ok', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + items: { + type: 'array', + items: { + $ref: '#/components/schemas/Entity', + }, + description: + 'The list of entities matching the predicate filter.', + }, + pageInfo: { + type: 'object', + properties: { + nextCursor: { + type: 'string', + description: + 'The cursor for the next batch of entities.', + }, + }, + }, + }, + required: ['items', 'pageInfo'], + }, + }, + }, + }, + '400': { + $ref: '#/components/responses/ErrorResponse', + }, + default: { + $ref: '#/components/responses/ErrorResponse', + }, + }, + security: [ + {}, + { + JWT: [], + }, + ], + parameters: [ + { + $ref: '#/components/parameters/limit', + }, + { + $ref: '#/components/parameters/offset', + }, + { + $ref: '#/components/parameters/after', + }, + { + name: 'order', + in: 'query', + allowReserved: true, + required: false, + schema: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + properties: { + filter: { + $ref: '#/components/schemas/EntityPredicate', + }, + }, + }, + examples: { + 'Get all service components': { + value: { + filter: { + $all: [ + { + kind: 'component', + }, + { + 'spec.type': 'service', + }, + ], + }, + }, + }, + 'Get components owned by specific teams': { + value: { + filter: { + $all: [ + { + kind: 'component', + }, + { + 'spec.owner': { + $in: ['backend-team', 'platform-team'], + }, + }, + ], + }, + }, + }, + 'Get non-production services': { + value: { + filter: { + $all: [ + { + kind: 'component', + }, + { + 'spec.type': 'service', + }, + { + $not: { + 'spec.lifecycle': 'production', + }, + }, + ], + }, + }, + }, + }, + }, + }, + }, + }, + }, '/entity-facets': { get: { operationId: 'GetEntityFacets', diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts index 894949a691..cf262c7778 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts @@ -29,6 +29,7 @@ describe('AuthorizedEntitiesCatalog', () => { const fakeCatalog = { entities: jest.fn(), entitiesBatch: jest.fn(), + queryEntitiesByPredicate: jest.fn(), removeEntityByUid: jest.fn(), entityAncestry: jest.fn(), facets: jest.fn(), diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts index d3d0dba5b3..831a3bb76b 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts @@ -32,6 +32,7 @@ import { EntityAncestryResponse, EntityFacetsRequest, EntityFacetsResponse, + EntityPredicateRequest, QueryEntitiesRequest, QueryEntitiesResponse, } from '../catalog/types'; @@ -196,6 +197,36 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { return this.entitiesCatalog.queryEntities(request); } + async queryEntitiesByPredicate( + request?: EntityPredicateRequest, + ): Promise { + if (!request) { + return { + entities: { type: 'object', entities: [] }, + pageInfo: { hasNextPage: false }, + }; + } + + const authorizeDecision = ( + await this.permissionApi.authorizeConditional( + [{ permission: catalogEntityReadPermission }], + { credentials: request.credentials }, + ) + )[0]; + + if (authorizeDecision.result === AuthorizeResult.DENY) { + return { + entities: { type: 'object', entities: [] }, + pageInfo: { hasNextPage: false }, + }; + } + + // Note: For CONDITIONAL results, we pass through to the underlying catalog + // since EntityPredicate filters are separate from EntityFilter permission conditions. + // The permission filter would need to be applied separately if needed. + return this.entitiesCatalog.queryEntitiesByPredicate(request); + } + async removeEntityByUid( uid: string, options: { credentials: BackstageCredentials }, diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index aa3ed3a551..2a792f1de1 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -72,6 +72,7 @@ describe('createRouter readonly disabled', () => { beforeEach(async () => { entitiesCatalog = { entities: jest.fn(), + queryEntitiesByPredicate: jest.fn(), entitiesBatch: jest.fn(), removeEntityByUid: jest.fn(), entityAncestry: jest.fn(), @@ -1120,6 +1121,7 @@ describe('createRouter readonly and raw json enabled', () => { beforeAll(async () => { entitiesCatalog = { entities: jest.fn(), + queryEntitiesByPredicate: jest.fn(), entitiesBatch: jest.fn(), removeEntityByUid: jest.fn(), entityAncestry: jest.fn(), @@ -1336,6 +1338,7 @@ describe('NextRouter permissioning', () => { beforeAll(async () => { entitiesCatalog = { entities: jest.fn(), + queryEntitiesByPredicate: jest.fn(), entitiesBatch: jest.fn(), removeEntityByUid: jest.fn(), entityAncestry: jest.fn(), diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index 11e459ae82..e26a0c1233 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -30,7 +30,10 @@ import { } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { InputError, serializeError } from '@backstage/errors'; -import { LocationAnalyzer } from '@backstage/plugin-catalog-node'; +import { + EntityPredicate, + LocationAnalyzer, +} from '@backstage/plugin-catalog-node'; import express from 'express'; import yn from 'yn'; import { z } from 'zod'; @@ -258,6 +261,53 @@ export async function createRouter( throw err; } }) + .post('/entities/by-predicates', async (req, res) => { + const auditorEvent = await auditor.createEvent({ + eventId: 'entity-fetch', + request: req, + meta: { + queryType: 'by-predicates', + }, + }); + + try { + const filter = req.body.filter as EntityPredicate | undefined; + const order = parseEntityOrderParams(req.query); + const pagination = parseEntityPaginationParams(req.query); + const credentials = await httpAuth.credentials(req); + + const { entities, pageInfo } = + await entitiesCatalog.queryEntitiesByPredicate({ + filter, + order, + pagination, + credentials, + }); + + const meta = { + pageInfo: { + ...(pageInfo.hasNextPage && { + nextCursor: pageInfo.endCursor, + }), + }, + }; + + await auditorEvent?.success({ meta }); + + await writeEntitiesResponse({ + res, + items: entities, + alwaysUseObjectMode: enableRelationsCompatibility, + responseWrapper: items => ({ + items, + ...meta, + }), + }); + } catch (err) { + await auditorEvent?.fail({ error: err }); + throw err; + } + }) .get('/entities/by-query', async (req, res) => { const auditorEvent = await auditor.createEvent({ eventId: 'entity-fetch', From ebeebd9012886f139409fb37de50997b0c45826d Mon Sep 17 00:00:00 2001 From: Nilay1999 Date: Thu, 22 Jan 2026 22:39:07 +0530 Subject: [PATCH 03/37] refactor(client): :recycle: update queryEntities to support query payload - add query support to do advanced search using queryEntities - update core query of queryEntitiesByPredicate to handle cursor based pagination Signed-off-by: Nilay1999 --- .../catalog-client/src/CatalogClient.test.ts | 305 ++++++++++++++++++ packages/catalog-client/src/CatalogClient.ts | 96 +++++- packages/catalog-client/src/utils.ts | 13 + plugins/catalog-backend/src/catalog/types.ts | 12 +- .../src/service/DefaultEntitiesCatalog.ts | 138 ++++++-- plugins/catalog-backend/src/service/util.ts | 2 +- 6 files changed, 530 insertions(+), 36 deletions(-) diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index d62323852e..31c5d62046 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -540,6 +540,311 @@ describe('CatalogClient', () => { }); }); + describe('queryEntities with predicate-based queries (POST endpoint)', () => { + const defaultResponse = { + items: [ + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'service-1', + namespace: 'default', + }, + spec: { + type: 'service', + owner: 'team-a', + }, + }, + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'service-2', + namespace: 'default', + }, + spec: { + type: 'service', + owner: 'team-b', + }, + }, + ], + pageInfo: {}, + }; + + it('should use POST endpoint when query is provided', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + expect(req.method).toBe('POST'); + expect(req.body).toMatchObject({ + query: { + kind: 'component', + }, + }); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + const response = await client.queryEntities({ + query: { kind: 'component' }, + limit: 20, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + expect(response.items).toEqual(defaultResponse.items); + expect(response.totalItems).toBe(defaultResponse.items.length); + }); + + it('should throw error when both filter and query are provided', async () => { + await expect( + client.queryEntities({ + filter: { kind: 'component' }, + query: { kind: 'component' }, + } as any), + ).rejects.toThrow( + 'Cannot specify both "filter" and "query" in the same request', + ); + }); + + it('should support $all operator', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + expect(req.body).toMatchObject({ + query: { + $all: [{ kind: 'component' }, { 'spec.type': 'service' }], + }, + }); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await client.queryEntities({ + query: { + $all: [{ kind: 'component' }, { 'spec.type': 'service' }], + }, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + }); + + it('should support $any operator', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + expect(req.body).toMatchObject({ + query: { + $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], + }, + }); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await client.queryEntities({ + query: { + $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], + }, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + }); + + it('should support $not operator', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + expect(req.body).toMatchObject({ + query: { + $not: { 'spec.lifecycle': 'experimental' }, + }, + }); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await client.queryEntities({ + query: { + $not: { 'spec.lifecycle': 'experimental' }, + }, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + }); + + it('should support $exists operator', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + expect(req.body).toMatchObject({ + query: { + 'spec.owner': { $exists: true }, + }, + }); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await client.queryEntities({ + query: { + 'spec.owner': { $exists: true }, + }, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + }); + + it('should support $in operator', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + expect(req.body).toMatchObject({ + query: { + 'spec.owner': { $in: ['team-a', 'team-b', 'team-c'] }, + }, + }); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await client.queryEntities({ + query: { + 'spec.owner': { $in: ['team-a', 'team-b', 'team-c'] }, + }, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + }); + + it('should support complex nested predicates', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + expect(req.body).toMatchObject({ + query: { + $all: [ + { kind: 'component' }, + { + $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], + }, + { + $not: { + 'spec.lifecycle': 'experimental', + }, + }, + ], + }, + }); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await client.queryEntities({ + query: { + $all: [ + { kind: 'component' }, + { + $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], + }, + { + $not: { + 'spec.lifecycle': 'experimental', + }, + }, + ], + }, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + }); + + it('should send orderFields with correct format (field,order)', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + const url = new URL(req.url); + expect(url.searchParams.getAll('orderField')).toEqual([ + 'metadata.name,asc', + ]); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await client.queryEntities({ + query: { kind: 'component' }, + orderFields: { field: 'metadata.name', order: 'asc' }, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + }); + + it('should send multiple orderFields with correct format', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + const url = new URL(req.url); + expect(url.searchParams.getAll('orderField')).toEqual([ + 'metadata.name,asc', + 'spec.type,desc', + ]); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await client.queryEntities({ + query: { kind: 'component' }, + orderFields: [ + { field: 'metadata.name', order: 'asc' }, + { field: 'spec.type', order: 'desc' }, + ], + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + }); + + it('should send limit and offset parameters', async () => { + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + const url = new URL(req.url); + expect(url.searchParams.get('limit')).toBe('50'); + expect(url.searchParams.get('offset')).toBe('10'); + return res(ctx.json(defaultResponse)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await client.queryEntities({ + query: { kind: 'component' }, + limit: 50, + offset: 10, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + }); + + it('should not allow cursor with query (cursor takes precedence)', async () => { + // When cursor is provided, it's not an initial request, so the query + // parameter is ignored and it goes to GET endpoint + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + // Should use GET endpoint, not POST + expect(req.method).toBe('GET'); + return res(ctx.json({ items: [], totalItems: 0, pageInfo: {} })); + }); + + server.use(rest.get(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + // This will use GET endpoint with cursor, ignoring the query parameter + await client.queryEntities({ + cursor: 'some-cursor', + query: { kind: 'component' }, + } as any); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + }); + + it('should handle errors from POST endpoint', async () => { + const mockedEndpoint = jest + .fn() + .mockImplementation((_req, res, ctx) => res(ctx.status(400))); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + await expect(() => + client.queryEntities({ query: { kind: 'component' } }), + ).rejects.toThrow(/Request failed with 400/); + }); + }); + describe('streamEntities', () => { const defaultResponse: QueryEntitiesResponse = { items: [ diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index a537e8b3a5..2d7fa7d957 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -46,7 +46,11 @@ import { StreamEntitiesRequest, ValidateEntityResponse, } from './types/api'; -import { isQueryEntitiesInitialRequest, splitRefsIntoChunks } from './utils'; +import { + isQueryEntitiesInitialRequest, + splitRefsIntoChunks, + cursorContainsQuery, +} from './utils'; import { DefaultApiClient, GetLocationsByQueryRequest, @@ -266,6 +270,30 @@ export class CatalogClient implements CatalogApi { request: QueryEntitiesRequest = {}, options?: CatalogRequestOptions, ): Promise { + // Validate that filter and query are mutually exclusive + if ( + isQueryEntitiesInitialRequest(request) && + request.filter && + request.query + ) { + throw new Error( + 'Cannot specify both "filter" and "query" in the same request. Use "filter" for traditional key-value filtering or "query" for predicate-based filtering.', + ); + } + + // Route to POST endpoint if query predicate is provided (initial request) + if (isQueryEntitiesInitialRequest(request) && request.query) { + return this.queryEntitiesByPredicate(request, options); + } + + // Route to POST endpoint if cursor contains a query predicate (pagination) + if ( + !isQueryEntitiesInitialRequest(request) && + cursorContainsQuery(request.cursor) + ) { + return this.queryEntitiesByPredicate(request, options); + } + const params: Partial< Parameters[0]['query'] > = {}; @@ -320,6 +348,72 @@ export class CatalogClient implements CatalogApi { ); } + /** + * Query entities using predicate-based filters (POST endpoint). + * @internal + */ + private async queryEntitiesByPredicate( + request: QueryEntitiesRequest, + options?: CatalogRequestOptions, + ): Promise { + const params: { + limit?: number; + offset?: number; + after?: string; + orderField?: string[]; + cursor?: string; + fields?: string[]; + } = {}; + + let query; + + if (isQueryEntitiesInitialRequest(request)) { + // Initial request with query predicate + const { query: requestQuery, limit, offset, orderFields } = request; + query = requestQuery; + + if (limit !== undefined) { + params.limit = limit; + } + if (offset !== undefined) { + params.offset = offset; + } + if (orderFields !== undefined) { + params.orderField = ( + Array.isArray(orderFields) ? orderFields : [orderFields] + ).map(({ field, order }) => `${field},${order}`); + } + } else { + // Cursor-based pagination request + const { cursor, limit } = request; + + params.after = cursor; + if (limit !== undefined) { + params.limit = limit; + } + // Query will be extracted from cursor on the backend + query = undefined; + } + + const response = await this.apiClient.queryEntitiesByPredicate( + { + body: query ? { query } : {}, + query: params, + }, + options, + ); + + const result = await this.requestRequired(response); + + return { + items: result.items, + totalItems: result.items.length, + pageInfo: { + nextCursor: result.pageInfo?.nextCursor, + }, + }; + } + /** * {@inheritdoc CatalogApi.getEntityByRef} */ diff --git a/packages/catalog-client/src/utils.ts b/packages/catalog-client/src/utils.ts index a16cf53db1..e7713623b9 100644 --- a/packages/catalog-client/src/utils.ts +++ b/packages/catalog-client/src/utils.ts @@ -26,6 +26,19 @@ export function isQueryEntitiesInitialRequest( return !(request as QueryEntitiesCursorRequest).cursor; } +/** + * Check if a cursor contains a predicate query by attempting to decode it. + * @internal + */ +export function cursorContainsQuery(cursor: string): boolean { + try { + const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString('utf8')); + return !!decoded.query; + } catch { + return false; + } +} + /** * 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 diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index 1b87352318..aad176e390 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -53,7 +53,7 @@ export type EntitiesRequest = { }; export type EntityPredicateRequest = { - filter?: EntityPredicate; + query?: EntityPredicate; order?: EntityOrder[]; pagination?: EntityPagination; credentials: BackstageCredentials; @@ -228,6 +228,11 @@ export interface QueryEntitiesInitialRequest { limit?: number; offset?: number; filter?: EntityFilter; + /** + * Predicate-based query for filtering entities. + * Mutually exclusive with filter. + */ + query?: EntityPredicate; orderFields?: EntityOrder[]; fullTextFilter?: { term: string; @@ -289,6 +294,11 @@ export type Cursor = { * A filter to be applied to the full list of entities. */ filter?: EntityFilter; + /** + * A predicate-based query to be applied to the full list of entities. + * Mutually exclusive with filter. + */ + query?: EntityPredicate; /** * true if the cursor is a previous cursor. */ diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts index c7b14282ef..151e345f5b 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts @@ -46,6 +46,8 @@ import { import { Stitcher } from '../stitching/types'; import { + decodeCursor, + encodeCursor, expandLegacyCompoundRelationsInEntity, isQueryEntitiesCursorRequest, isQueryEntitiesInitialRequest, @@ -221,70 +223,133 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { const db = this.database; const { limit, offset } = parsePagination(request?.pagination); - let entitiesQuery = - db('final_entities').select('final_entities.*'); + const cursor = request?.pagination?.after + ? decodeCursor(request.pagination.after) + : { + query: request?.query, + orderFields: request?.order || [], + isPrevious: false, + orderFieldValues: undefined, + firstSortFieldValues: undefined, + }; - request?.order?.forEach(({ field }, index) => { - const alias = `order_${index}`; - entitiesQuery = entitiesQuery.leftOuterJoin( - { [alias]: 'search' }, - function search(inner) { + // Use query from cursor if not provided in request (pagination case) + const effectiveQuery = request?.query ?? cursor.query; + const sortField = cursor.orderFields?.[0]; + + let entitiesQuery = db('final_entities'); + + // Join with search table if we have a sort field + if (sortField) { + entitiesQuery = entitiesQuery + .distinct() + .leftOuterJoin({ order_0: 'search' }, function search(inner) { inner - .on(`${alias}.entity_id`, 'final_entities.entity_id') - .andOn(`${alias}.key`, db.raw('?', [field])); - }, - ); - }); + .on('order_0.entity_id', 'final_entities.entity_id') + .andOn('order_0.key', db.raw('?', [sortField.field])); + }) + .select({ + entity_id: 'final_entities.entity_id', + final_entity: 'final_entities.final_entity', + value: 'order_0.value', + }); + } else { + entitiesQuery = entitiesQuery.select({ + entity_id: 'final_entities.entity_id', + final_entity: 'final_entities.final_entity', + }); + } entitiesQuery = entitiesQuery.whereNotNull('final_entities.final_entity'); - if (request?.filter) { + // Apply predicate filter from cursor + if (effectiveQuery) { entitiesQuery = applyPredicateEntityFilterToQuery({ - filter: request.filter, + filter: effectiveQuery, targetQuery: entitiesQuery, onEntityIdField: 'final_entities.entity_id', knex: db, }); } - request?.order?.forEach(({ order }, index) => { + // Apply cursor-based pagination (keyset pagination) + if (cursor.orderFieldValues) { + if (cursor.orderFieldValues.length === 2) { + const [sortValue, entityId] = cursor.orderFieldValues; + const isOrderingDescending = sortField?.order === 'desc'; + entitiesQuery = entitiesQuery.andWhere(function nested() { + this.where( + 'order_0.value', + isOrderingDescending ? '<' : '>', + sortValue, + ) + .orWhere('order_0.value', '=', sortValue) + .andWhere('final_entities.entity_id', '>', entityId); + }); + } else if (cursor.orderFieldValues.length === 1) { + const [entityId] = cursor.orderFieldValues; + entitiesQuery = entitiesQuery.andWhere( + 'final_entities.entity_id', + '>', + entityId, + ); + } + } + + if (sortField) { if (db.client.config.client === 'pg') { entitiesQuery = entitiesQuery.orderBy([ - { column: `order_${index}.value`, order, nulls: 'last' }, + { column: 'order_0.value', order: sortField.order, nulls: 'last' }, + { column: 'final_entities.entity_id', order: 'asc' }, ]); } else { entitiesQuery = entitiesQuery.orderBy([ - { column: `order_${index}.value`, order: undefined, nulls: 'last' }, - { column: `order_${index}.value`, order }, + { column: 'order_0.value', order: undefined, nulls: 'last' }, + { column: 'order_0.value', order: sortField.order }, + { column: 'final_entities.entity_id', order: 'asc' }, ]); } - }); - - if (!request?.order) { - entitiesQuery = entitiesQuery.orderBy('final_entities.entity_ref', 'asc'); } else { - entitiesQuery.orderBy('final_entities.entity_id', 'asc'); + entitiesQuery = entitiesQuery.orderBy('final_entities.entity_id', 'asc'); } - if (limit !== undefined) { - entitiesQuery = entitiesQuery.limit(limit + 1); - } - if (offset !== undefined) { + // Apply a manually set initial offset (only when not using cursor pagination) + if (!request?.pagination?.after && offset !== undefined) { entitiesQuery = entitiesQuery.offset(offset); } + const effectiveLimit = limit ?? DEFAULT_LIMIT; + entitiesQuery = entitiesQuery.limit(effectiveLimit + 1); let rows = await entitiesQuery; let pageInfo: DbPageInfo; - if (limit === undefined || rows.length <= limit) { + + if (rows.length <= effectiveLimit) { pageInfo = { hasNextPage: false }; } else { + // Remove the extra row rows = rows.slice(0, -1); + + const lastRow = rows[rows.length - 1]; + const firstRow = rows[0]; + + // Create proper cursor with query field + const nextCursor: Cursor = { + query: effectiveQuery, + orderFields: cursor.orderFields || [], + orderFieldValues: sortField + ? [(lastRow as any).value, lastRow.entity_id] + : [lastRow.entity_id], + isPrevious: false, + firstSortFieldValues: + cursor.firstSortFieldValues || + (sortField + ? [(firstRow as any).value, firstRow.entity_id] + : [firstRow.entity_id]), + }; + pageInfo = { hasNextPage: true, - endCursor: stringifyPagination({ - limit, - offset: (offset ?? 0) + limit, - }), + endCursor: encodeCursor(nextCursor), }; } @@ -831,11 +896,18 @@ function parseCursorFromRequest( if (isQueryEntitiesInitialRequest(request)) { const { filter, + query, orderFields: sortFields = [], fullTextFilter, skipTotalItems = false, } = request; - return { filter, orderFields: sortFields, fullTextFilter, skipTotalItems }; + return { + filter, + query, + orderFields: sortFields, + fullTextFilter, + skipTotalItems, + }; } if (isQueryEntitiesCursorRequest(request)) { return { diff --git a/plugins/catalog-backend/src/service/util.ts b/plugins/catalog-backend/src/service/util.ts index 7e0e8b42f9..dbdbd374cf 100644 --- a/plugins/catalog-backend/src/service/util.ts +++ b/plugins/catalog-backend/src/service/util.ts @@ -122,7 +122,7 @@ export const cursorParser: z.ZodSchema = z.object({ orderFieldValues: z.array(z.string().or(z.null())), filter: entityFilterParser.optional(), isPrevious: z.boolean(), - query: z.string().optional(), + query: z.any().optional(), firstSortFieldValues: z.array(z.string().or(z.null())).optional(), totalItems: z.number().optional(), }); From 8ddce231154066516f618837e40d1938da1338c7 Mon Sep 17 00:00:00 2001 From: Nilay1999 Date: Thu, 22 Jan 2026 22:39:42 +0530 Subject: [PATCH 04/37] =?UTF-8?q?refactor(api):=20=E2=9C=A8=20regenerate?= =?UTF-8?q?=20OpenAPI=20models=20for=20updated=20predicate-based=20endpoin?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update `/by-predicate` endpoint name to POST `/by-query` - regenerate openapi models to fix generic type names Signed-off-by: Nilay1999 --- .../openapi/generated/apis/Api.client.ts | 45 +++++ .../generated/models/EntityPredicate.model.ts | 36 ++++ .../models/EntityPredicateAll.model.ts | 3 +- .../models/EntityPredicateAny.model.ts | 28 +++ .../models/EntityPredicateExists.model.ts | 3 +- .../models/EntityPredicateIn.model.ts | 28 +++ .../models/EntityPredicateInInInner.model.ts | 2 +- .../models/EntityPredicateNot.model.ts | 3 +- .../models/EntityPredicateValue.model.ts | 32 ++++ ...eryEntitiesByPredicate200Response.model.ts | 6 +- ...iesByPredicate200ResponsePageInfo.model.ts | 2 +- .../QueryEntitiesByPredicateRequest.model.ts | 4 +- .../schema/openapi/generated/models/index.ts | 11 ++ packages/catalog-client/src/types/api.ts | 28 ++- packages/catalog-client/src/types/index.ts | 9 + .../catalog-client/src/types/predicate.ts | 122 +++++++++++++ .../catalog-backend/src/schema/openapi.yaml | 133 +++++++------- .../openapi/generated/apis/Api.server.ts | 34 ++-- .../generated/models/EntityPredicate.model.ts | 12 +- .../models/EntityPredicateAll.model.ts | 28 +++ .../models/EntityPredicateAny.model.ts | 28 +++ .../models/EntityPredicateExists.model.ts | 27 +++ .../models/EntityPredicateIn.model.ts | 28 +++ .../models/EntityPredicateInInInner.model.ts | 24 +++ .../models/EntityPredicateNot.model.ts | 28 +++ .../models/EntityPredicateValue.model.ts | 8 +- .../EntityPredicateValueOneOf1.model.ts | 27 --- ...eryEntitiesByPredicate200Response.model.ts | 32 ++++ ...iesByPredicate200ResponsePageInfo.model.ts | 29 +++ ... QueryEntitiesByPredicateRequest.model.ts} | 6 +- .../schema/openapi/generated/models/index.ts | 18 +- .../src/schema/openapi/generated/router.ts | 165 +++++++++--------- .../src/service/createRouter.ts | 11 +- 33 files changed, 777 insertions(+), 223 deletions(-) create mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicate.model.ts rename plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf.model.ts => packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAll.model.ts (92%) create mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAny.model.ts rename plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf.model.ts => packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateExists.model.ts (92%) create mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateIn.model.ts rename plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1InInner.model.ts => packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts (91%) rename plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf2.model.ts => packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateNot.model.ts (93%) create mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateValue.model.ts rename plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200Response.model.ts => packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts (80%) rename plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200ResponsePageInfo.model.ts => packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts (93%) rename plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf1.model.ts => packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts (92%) create mode 100644 packages/catalog-client/src/types/predicate.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAll.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAny.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateExists.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateIn.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateNot.model.ts delete mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts rename plugins/catalog-backend/src/schema/openapi/generated/models/{GetEntitiesByPredicatesRequest.model.ts => QueryEntitiesByPredicateRequest.model.ts} (87%) diff --git a/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts b/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts index 4530cd795c..bc34d1c769 100644 --- a/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts +++ b/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts @@ -29,6 +29,8 @@ import { Entity } from '../models/Entity.model'; import { EntityAncestryResponse } from '../models/EntityAncestryResponse.model'; import { EntityFacetsResponse } from '../models/EntityFacetsResponse.model'; import { GetEntitiesByRefsRequest } from '../models/GetEntitiesByRefsRequest.model'; +import { QueryEntitiesByPredicate200Response } from '../models/QueryEntitiesByPredicate200Response.model'; +import { QueryEntitiesByPredicateRequest } from '../models/QueryEntitiesByPredicateRequest.model'; import { RefreshEntityRequest } from '../models/RefreshEntityRequest.model'; import { ValidateEntityRequest } from '../models/ValidateEntityRequest.model'; import { AnalyzeLocationRequest } from '../models/AnalyzeLocationRequest.model'; @@ -139,6 +141,18 @@ export type GetEntityFacets = { filter?: Array; }; }; +/** + * @public + */ +export type QueryEntitiesByPredicate = { + body: QueryEntitiesByPredicateRequest; + query: { + limit?: number; + offset?: number; + orderField?: Array; + after?: string; + }; +}; /** * @public */ @@ -449,6 +463,37 @@ export class DefaultApiClient { }); } + /** + * Query entities using predicate-based filters. This endpoint provides an alternative filtering method with a more expressive filter syntax supporting logical operators ($all, $any, $not) and value operators ($exists, $in). Example query: ```json { \"query\": { \"$all\": [ {\"kind\": \"component\"}, {\"$any\": [ {\"spec.type\": \"service\"}, {\"spec.type\": \"website\"} ]}, {\"$not\": {\"spec.lifecycle\": \"experimental\"}} ] } } ``` + * @param queryEntitiesByPredicateRequest - + * @param limit - Number of records to return in the response. + * @param offset - Number of records to skip in the query page. + * @param orderField - By default the entities are returned ordered by their internal uid. You can customize the `orderField` query parameters to affect that ordering. For example, to return entities by their name: `/entities/by-query?orderField=metadata.name,asc` Each parameter can be followed by `asc` for ascending lexicographical order or `desc` for descending (reverse) lexicographical order. + * @param after - Pointer to the previous page of results. + */ + public async queryEntitiesByPredicate( + // @ts-ignore + request: QueryEntitiesByPredicate, + options?: RequestOptions, + ): Promise> { + const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); + + const uriTemplate = `/entities/by-query{?limit,offset,orderField*,after}`; + + const uri = parser.parse(uriTemplate).expand({ + ...request.query, + }); + + return await this.fetchApi.fetch(`${baseUrl}${uri}`, { + headers: { + 'Content-Type': 'application/json', + ...(options?.token && { Authorization: `Bearer ${options?.token}` }), + }, + method: 'POST', + body: JSON.stringify(request.body), + }); + } + /** * Refresh the entity related to entityRef. * @param refreshEntityRequest - diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicate.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicate.model.ts new file mode 100644 index 0000000000..bdc862ca50 --- /dev/null +++ b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicate.model.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { EntityPredicateAll } from '../models/EntityPredicateAll.model'; +import { EntityPredicateAny } from '../models/EntityPredicateAny.model'; +import { EntityPredicateNot } from '../models/EntityPredicateNot.model'; +import { EntityPredicateValue } from '../models/EntityPredicateValue.model'; + +/** + * A predicate-based filter supporting logical operators. - $all: All conditions must match (AND) - $any: At least one condition must match (OR) - $not: Negates the condition - $exists: Check if field exists - $in: Match any value in array + * @public + */ +export type EntityPredicate = + | EntityPredicateAll + | EntityPredicateAny + | EntityPredicateNot + | boolean + | number + | string + | { [key: string]: EntityPredicateValue }; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAll.model.ts similarity index 92% rename from plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf.model.ts rename to packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAll.model.ts index da6f4678b9..080b8307b4 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf.model.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAll.model.ts @@ -20,8 +20,9 @@ import { EntityPredicate } from '../models/EntityPredicate.model'; /** + * All conditions must match (AND logic) * @public */ -export interface EntityPredicateOneOf { +export interface EntityPredicateAll { $all: Array; } diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAny.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAny.model.ts new file mode 100644 index 0000000000..170ea3ab0c --- /dev/null +++ b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAny.model.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { EntityPredicate } from '../models/EntityPredicate.model'; + +/** + * At least one condition must match (OR logic) + * @public + */ +export interface EntityPredicateAny { + $any: Array; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateExists.model.ts similarity index 92% rename from plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf.model.ts rename to packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateExists.model.ts index e260d2456a..7800fcd20c 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf.model.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateExists.model.ts @@ -19,8 +19,9 @@ // ****************************************************************** /** + * Check if field exists * @public */ -export interface EntityPredicateValueOneOf { +export interface EntityPredicateExists { $exists: boolean; } diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateIn.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateIn.model.ts new file mode 100644 index 0000000000..574ab4f45b --- /dev/null +++ b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateIn.model.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { EntityPredicateInInInner } from '../models/EntityPredicateInInInner.model'; + +/** + * Match any value in array + * @public + */ +export interface EntityPredicateIn { + $in: Array; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1InInner.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts similarity index 91% rename from plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1InInner.model.ts rename to packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts index 45c7bfac4c..2b4e3d7c2f 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1InInner.model.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts @@ -21,4 +21,4 @@ /** * @public */ -export type EntityPredicateValueOneOf1InInner = boolean | number | string; +export type EntityPredicateInInInner = boolean | number | string; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf2.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateNot.model.ts similarity index 93% rename from plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf2.model.ts rename to packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateNot.model.ts index af1ca66a3a..e56279b0fc 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf2.model.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateNot.model.ts @@ -20,8 +20,9 @@ import { EntityPredicate } from '../models/EntityPredicate.model'; /** + * Negates the condition * @public */ -export interface EntityPredicateOneOf2 { +export interface EntityPredicateNot { $not: EntityPredicate; } diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateValue.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateValue.model.ts new file mode 100644 index 0000000000..c230c2a984 --- /dev/null +++ b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateValue.model.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { EntityPredicateExists } from '../models/EntityPredicateExists.model'; +import { EntityPredicateIn } from '../models/EntityPredicateIn.model'; + +/** + * Value for a field predicate + * @public + */ +export type EntityPredicateValue = + | EntityPredicateExists + | EntityPredicateIn + | boolean + | number + | string; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200Response.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts similarity index 80% rename from plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200Response.model.ts rename to packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts index 0acbfe559e..3c4b74cd10 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200Response.model.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts @@ -18,15 +18,15 @@ // * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * // ****************************************************************** import { Entity } from '../models/Entity.model'; -import { GetEntitiesByPredicates200ResponsePageInfo } from '../models/GetEntitiesByPredicates200ResponsePageInfo.model'; +import { QueryEntitiesByPredicate200ResponsePageInfo } from '../models/QueryEntitiesByPredicate200ResponsePageInfo.model'; /** * @public */ -export interface GetEntitiesByPredicates200Response { +export interface QueryEntitiesByPredicate200Response { /** * The list of entities matching the predicate filter. */ items: Array; - pageInfo: GetEntitiesByPredicates200ResponsePageInfo; + pageInfo: QueryEntitiesByPredicate200ResponsePageInfo; } diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200ResponsePageInfo.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts similarity index 93% rename from plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200ResponsePageInfo.model.ts rename to packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts index 64ea958943..3ef9f73758 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicates200ResponsePageInfo.model.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts @@ -21,7 +21,7 @@ /** * @public */ -export interface GetEntitiesByPredicates200ResponsePageInfo { +export interface QueryEntitiesByPredicate200ResponsePageInfo { /** * The cursor for the next batch of entities. */ diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf1.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts similarity index 92% rename from plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf1.model.ts rename to packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts index 8e2b1217f5..8b8276c9da 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateOneOf1.model.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts @@ -22,6 +22,6 @@ import { EntityPredicate } from '../models/EntityPredicate.model'; /** * @public */ -export interface EntityPredicateOneOf1 { - $any: Array; +export interface QueryEntitiesByPredicateRequest { + query?: EntityPredicate; } diff --git a/packages/catalog-client/src/schema/openapi/generated/models/index.ts b/packages/catalog-client/src/schema/openapi/generated/models/index.ts index abdb58378c..9d7ee33602 100644 --- a/packages/catalog-client/src/schema/openapi/generated/models/index.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/index.ts @@ -31,6 +31,14 @@ export * from '../models/EntityFacet.model'; export * from '../models/EntityFacetsResponse.model'; export * from '../models/EntityLink.model'; export * from '../models/EntityMeta.model'; +export * from '../models/EntityPredicate.model'; +export * from '../models/EntityPredicateAll.model'; +export * from '../models/EntityPredicateAny.model'; +export * from '../models/EntityPredicateExists.model'; +export * from '../models/EntityPredicateIn.model'; +export * from '../models/EntityPredicateInInInner.model'; +export * from '../models/EntityPredicateNot.model'; +export * from '../models/EntityPredicateValue.model'; export * from '../models/EntityRelation.model'; export * from '../models/ErrorError.model'; export * from '../models/ErrorRequest.model'; @@ -45,6 +53,9 @@ export * from '../models/LocationsQueryResponse.model'; export * from '../models/LocationsQueryResponsePageInfo.model'; export * from '../models/ModelError.model'; export * from '../models/NullableEntity.model'; +export * from '../models/QueryEntitiesByPredicate200Response.model'; +export * from '../models/QueryEntitiesByPredicate200ResponsePageInfo.model'; +export * from '../models/QueryEntitiesByPredicateRequest.model'; export * from '../models/RecursivePartialEntity.model'; export * from '../models/RecursivePartialEntityMeta.model'; export * from '../models/RecursivePartialEntityMetaAllOf.model'; diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index 12c5e00c03..dda003ec90 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -418,16 +418,42 @@ export type QueryEntitiesRequest = * The method takes this type in an initial pagination request, * when requesting the first batch of entities. * - * The properties filter, sortField, query and sortFieldOrder, are going + * The properties filter, query, sortField and sortFieldOrder, are going * to be immutable for the entire lifecycle of the following requests. * + * @remarks + * + * Either `filter` or `query` can be provided, but not both: + * - `filter`: Uses the traditional key-value filter syntax (GET endpoint) + * - `query`: Uses the predicate-based filter syntax with logical operators (POST endpoint) + * * @public */ export type QueryEntitiesInitialRequest = { fields?: string[]; limit?: number; offset?: number; + /** + * Traditional key-value based filter. Mutually exclusive with `query`. + */ filter?: EntityFilterQuery; + /** + * Predicate-based filter with logical operators ($all, $any, $not, $exists, $in). + * Mutually exclusive with `filter`. + * + * @example + * ```typescript + * { + * query: { + * $all: [ + * { kind: 'component' }, + * { 'spec.type': 'service' } + * ] + * } + * } + * ``` + */ + query?: FilterPredicate; orderFields?: EntityOrderQuery; fullTextFilter?: { term: string; diff --git a/packages/catalog-client/src/types/index.ts b/packages/catalog-client/src/types/index.ts index b280f1874f..20a5f3303d 100644 --- a/packages/catalog-client/src/types/index.ts +++ b/packages/catalog-client/src/types/index.ts @@ -44,4 +44,13 @@ export type { QueryLocationsInitialRequest, QueryLocationsResponse, } from './api'; +export type { + EntityPredicate, + EntityPredicateAll, + EntityPredicateAny, + EntityPredicateNot, + EntityPredicateValue, + EntityPredicateExists, + EntityPredicateIn, +} from './predicate'; export { ENTITY_STATUS_CATALOG_PROCESSING_TYPE } from './status'; diff --git a/packages/catalog-client/src/types/predicate.ts b/packages/catalog-client/src/types/predicate.ts new file mode 100644 index 0000000000..11b081d650 --- /dev/null +++ b/packages/catalog-client/src/types/predicate.ts @@ -0,0 +1,122 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * A predicate-based filter supporting logical operators. + * + * @remarks + * + * This provides a more expressive filter syntax compared to the traditional + * EntityFilterQuery. It supports: + * - Logical operators: $all (AND), $any (OR), $not (negation) + * - Value operators: $exists, $in + * - Direct field matching with primitive values + * + * @example + * ```typescript + * // Match all service components + * { + * $all: [ + * { kind: 'component' }, + * { 'spec.type': 'service' } + * ] + * } + * + * // Match components owned by specific teams + * { + * $all: [ + * { kind: 'component' }, + * { 'spec.owner': { $in: ['backend-team', 'platform-team'] } } + * ] + * } + * + * // Match non-production services + * { + * $all: [ + * { kind: 'component' }, + * { 'spec.type': 'service' }, + * { $not: { 'spec.lifecycle': 'production' } } + * ] + * } + * ``` + * + * @public + */ +export type EntityPredicate = + | EntityPredicateAll + | EntityPredicateAny + | EntityPredicateNot + | boolean + | number + | string + | { [key: string]: EntityPredicateValue }; + +/** + * All conditions must match (AND logic). + * + * @public + */ +export interface EntityPredicateAll { + $all: Array; +} + +/** + * At least one condition must match (OR logic). + * + * @public + */ +export interface EntityPredicateAny { + $any: Array; +} + +/** + * Negates the condition. + * + * @public + */ +export interface EntityPredicateNot { + $not: EntityPredicate; +} + +/** + * Value for a field predicate. + * + * @public + */ +export type EntityPredicateValue = + | EntityPredicateExists + | EntityPredicateIn + | boolean + | number + | string; + +/** + * Check if field exists. + * + * @public + */ +export interface EntityPredicateExists { + $exists: boolean; +} + +/** + * Match any value in array. + * + * @public + */ +export interface EntityPredicateIn { + $in: Array; +} diff --git a/plugins/catalog-backend/src/schema/openapi.yaml b/plugins/catalog-backend/src/schema/openapi.yaml index d4c7560c30..fba22d153a 100644 --- a/plugins/catalog-backend/src/schema/openapi.yaml +++ b/plugins/catalog-backend/src/schema/openapi.yaml @@ -350,59 +350,74 @@ components: - type: string - type: number - type: boolean - - type: object - additionalProperties: false - properties: - $all: - type: array - items: - $ref: '#/components/schemas/EntityPredicate' - required: - - $all - - type: object - additionalProperties: false - properties: - $any: - type: array - items: - $ref: '#/components/schemas/EntityPredicate' - required: - - $any - - type: object - additionalProperties: false - properties: - $not: - $ref: '#/components/schemas/EntityPredicate' - required: - - $not + - $ref: '#/components/schemas/EntityPredicateAll' + - $ref: '#/components/schemas/EntityPredicateAny' + - $ref: '#/components/schemas/EntityPredicateNot' - type: object additionalProperties: $ref: '#/components/schemas/EntityPredicateValue' + EntityPredicateAll: + type: object + description: All conditions must match (AND logic) + additionalProperties: false + properties: + $all: + type: array + items: + $ref: '#/components/schemas/EntityPredicate' + required: + - $all + EntityPredicateAny: + type: object + description: At least one condition must match (OR logic) + additionalProperties: false + properties: + $any: + type: array + items: + $ref: '#/components/schemas/EntityPredicate' + required: + - $any + EntityPredicateNot: + type: object + description: Negates the condition + additionalProperties: false + properties: + $not: + $ref: '#/components/schemas/EntityPredicate' + required: + - $not EntityPredicateValue: description: Value for a field predicate oneOf: - type: string - type: number - type: boolean - - type: object - additionalProperties: false - properties: - $exists: - type: boolean - required: - - $exists - - type: object - additionalProperties: false - properties: - $in: - type: array - items: - oneOf: - - type: string - - type: number - - type: boolean - required: - - $in + - $ref: '#/components/schemas/EntityPredicateExists' + - $ref: '#/components/schemas/EntityPredicateIn' + EntityPredicateExists: + type: object + description: Check if field exists + additionalProperties: false + properties: + $exists: + type: boolean + required: + - $exists + EntityPredicateIn: + type: object + description: Match any value in array + additionalProperties: false + properties: + $in: + type: array + items: + oneOf: + - type: string + - type: number + - type: boolean + required: + - $in MapStringString: type: object properties: {} @@ -1160,20 +1175,19 @@ paths: type: string explode: false style: form - /entities/by-predicates: post: - operationId: GetEntitiesByPredicates + operationId: QueryEntitiesByPredicate tags: - Entity description: | - Query entities using predicate-based filters. This endpoint supports a more - expressive filter syntax with logical operators ($all, $any, $not) and - value operators ($exists, $in). + Query entities using predicate-based filters. This endpoint provides an + alternative filtering method with a more expressive filter syntax supporting + logical operators ($all, $any, $not) and value operators ($exists, $in). - Example filter: + Example query: ```json { - "filter": { + "query": { "$all": [ {"kind": "component"}, {"$any": [ @@ -1217,15 +1231,8 @@ paths: parameters: - $ref: '#/components/parameters/limit' - $ref: '#/components/parameters/offset' + - $ref: '#/components/parameters/orderField' - $ref: '#/components/parameters/after' - - name: order - in: query - allowReserved: true - required: false - schema: - type: array - items: - type: string requestBody: required: true content: @@ -1234,18 +1241,18 @@ paths: type: object additionalProperties: false properties: - filter: + query: $ref: '#/components/schemas/EntityPredicate' examples: Get all service components: value: - filter: + query: $all: - kind: component - spec.type: service Get components owned by specific teams: value: - filter: + query: $all: - kind: component - spec.owner: @@ -1254,7 +1261,7 @@ paths: - platform-team Get non-production services: value: - filter: + query: $all: - kind: component - spec.type: service diff --git a/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts b/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts index a185f9a359..258c777067 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts @@ -25,9 +25,9 @@ import { EntitiesQueryResponse } from '../models/EntitiesQueryResponse.model'; import { Entity } from '../models/Entity.model'; import { EntityAncestryResponse } from '../models/EntityAncestryResponse.model'; import { EntityFacetsResponse } from '../models/EntityFacetsResponse.model'; -import { GetEntitiesByPredicates200Response } from '../models/GetEntitiesByPredicates200Response.model'; -import { GetEntitiesByPredicatesRequest } from '../models/GetEntitiesByPredicatesRequest.model'; import { GetEntitiesByRefsRequest } from '../models/GetEntitiesByRefsRequest.model'; +import { QueryEntitiesByPredicate200Response } from '../models/QueryEntitiesByPredicate200Response.model'; +import { QueryEntitiesByPredicateRequest } from '../models/QueryEntitiesByPredicateRequest.model'; import { RefreshEntityRequest } from '../models/RefreshEntityRequest.model'; import { ValidateEntity400Response } from '../models/ValidateEntity400Response.model'; import { ValidateEntityRequest } from '../models/ValidateEntityRequest.model'; @@ -55,19 +55,6 @@ export type GetEntities = { }; response: Array | Error | Error; }; -/** - * @public - */ -export type GetEntitiesByPredicates = { - body: GetEntitiesByPredicatesRequest; - query: { - limit?: number; - offset?: number; - after?: string; - order?: Array; - }; - response: GetEntitiesByPredicates200Response | Error | Error; -}; /** * @public */ @@ -135,6 +122,19 @@ export type GetEntityFacets = { }; response: EntityFacetsResponse | Error | Error; }; +/** + * @public + */ +export type QueryEntitiesByPredicate = { + body: QueryEntitiesByPredicateRequest; + query: { + limit?: number; + offset?: number; + orderField?: Array; + after?: string; + }; + response: QueryEntitiesByPredicate200Response | Error | Error; +}; /** * @public */ @@ -223,8 +223,6 @@ export type EndpointMap = { '#get|/entities': GetEntities; - '#post|/entities/by-predicates': GetEntitiesByPredicates; - '#get|/entities/by-query': GetEntitiesByQuery; '#post|/entities/by-refs': GetEntitiesByRefs; @@ -237,6 +235,8 @@ export type EndpointMap = { '#get|/entity-facets': GetEntityFacets; + '#post|/entities/by-query': QueryEntitiesByPredicate; + '#post|/refresh': RefreshEntity; '#post|/validate-entity': ValidateEntity; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts index 18b9038228..bdc862ca50 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts @@ -17,9 +17,9 @@ // ****************************************************************** // * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * // ****************************************************************** -import { EntityPredicateOneOf } from '../models/EntityPredicateOneOf.model'; -import { EntityPredicateOneOf1 } from '../models/EntityPredicateOneOf1.model'; -import { EntityPredicateOneOf2 } from '../models/EntityPredicateOneOf2.model'; +import { EntityPredicateAll } from '../models/EntityPredicateAll.model'; +import { EntityPredicateAny } from '../models/EntityPredicateAny.model'; +import { EntityPredicateNot } from '../models/EntityPredicateNot.model'; import { EntityPredicateValue } from '../models/EntityPredicateValue.model'; /** @@ -27,9 +27,9 @@ import { EntityPredicateValue } from '../models/EntityPredicateValue.model'; * @public */ export type EntityPredicate = - | EntityPredicateOneOf - | EntityPredicateOneOf1 - | EntityPredicateOneOf2 + | EntityPredicateAll + | EntityPredicateAny + | EntityPredicateNot | boolean | number | string diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAll.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAll.model.ts new file mode 100644 index 0000000000..080b8307b4 --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAll.model.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { EntityPredicate } from '../models/EntityPredicate.model'; + +/** + * All conditions must match (AND logic) + * @public + */ +export interface EntityPredicateAll { + $all: Array; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAny.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAny.model.ts new file mode 100644 index 0000000000..170ea3ab0c --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAny.model.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { EntityPredicate } from '../models/EntityPredicate.model'; + +/** + * At least one condition must match (OR logic) + * @public + */ +export interface EntityPredicateAny { + $any: Array; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateExists.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateExists.model.ts new file mode 100644 index 0000000000..7800fcd20c --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateExists.model.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * Check if field exists + * @public + */ +export interface EntityPredicateExists { + $exists: boolean; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateIn.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateIn.model.ts new file mode 100644 index 0000000000..574ab4f45b --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateIn.model.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { EntityPredicateInInInner } from '../models/EntityPredicateInInInner.model'; + +/** + * Match any value in array + * @public + */ +export interface EntityPredicateIn { + $in: Array; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts new file mode 100644 index 0000000000..2b4e3d7c2f --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export type EntityPredicateInInInner = boolean | number | string; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateNot.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateNot.model.ts new file mode 100644 index 0000000000..e56279b0fc --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateNot.model.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { EntityPredicate } from '../models/EntityPredicate.model'; + +/** + * Negates the condition + * @public + */ +export interface EntityPredicateNot { + $not: EntityPredicate; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts index 0ea00bef2f..c230c2a984 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts @@ -17,16 +17,16 @@ // ****************************************************************** // * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * // ****************************************************************** -import { EntityPredicateValueOneOf } from '../models/EntityPredicateValueOneOf.model'; -import { EntityPredicateValueOneOf1 } from '../models/EntityPredicateValueOneOf1.model'; +import { EntityPredicateExists } from '../models/EntityPredicateExists.model'; +import { EntityPredicateIn } from '../models/EntityPredicateIn.model'; /** * Value for a field predicate * @public */ export type EntityPredicateValue = - | EntityPredicateValueOneOf - | EntityPredicateValueOneOf1 + | EntityPredicateExists + | EntityPredicateIn | boolean | number | string; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1.model.ts deleted file mode 100644 index 1bea769175..0000000000 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValueOneOf1.model.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright 2026 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// ****************************************************************** -// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * -// ****************************************************************** -import { EntityPredicateValueOneOf1InInner } from '../models/EntityPredicateValueOneOf1InInner.model'; - -/** - * @public - */ -export interface EntityPredicateValueOneOf1 { - $in: Array; -} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts new file mode 100644 index 0000000000..3c4b74cd10 --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** +import { Entity } from '../models/Entity.model'; +import { QueryEntitiesByPredicate200ResponsePageInfo } from '../models/QueryEntitiesByPredicate200ResponsePageInfo.model'; + +/** + * @public + */ +export interface QueryEntitiesByPredicate200Response { + /** + * The list of entities matching the predicate filter. + */ + items: Array; + pageInfo: QueryEntitiesByPredicate200ResponsePageInfo; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts new file mode 100644 index 0000000000..3ef9f73758 --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export interface QueryEntitiesByPredicate200ResponsePageInfo { + /** + * The cursor for the next batch of entities. + */ + nextCursor?: string; +} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicatesRequest.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts similarity index 87% rename from plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicatesRequest.model.ts rename to plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts index 2937e27df5..8b8276c9da 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/GetEntitiesByPredicatesRequest.model.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Backstage Authors + * Copyright 2026 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,6 +22,6 @@ import { EntityPredicate } from '../models/EntityPredicate.model'; /** * @public */ -export interface GetEntitiesByPredicatesRequest { - filter?: EntityPredicate; +export interface QueryEntitiesByPredicateRequest { + query?: EntityPredicate; } diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts index abd35ed409..9d7ee33602 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts @@ -32,20 +32,17 @@ export * from '../models/EntityFacetsResponse.model'; export * from '../models/EntityLink.model'; export * from '../models/EntityMeta.model'; export * from '../models/EntityPredicate.model'; -export * from '../models/EntityPredicateOneOf.model'; -export * from '../models/EntityPredicateOneOf1.model'; -export * from '../models/EntityPredicateOneOf2.model'; +export * from '../models/EntityPredicateAll.model'; +export * from '../models/EntityPredicateAny.model'; +export * from '../models/EntityPredicateExists.model'; +export * from '../models/EntityPredicateIn.model'; +export * from '../models/EntityPredicateInInInner.model'; +export * from '../models/EntityPredicateNot.model'; export * from '../models/EntityPredicateValue.model'; -export * from '../models/EntityPredicateValueOneOf.model'; -export * from '../models/EntityPredicateValueOneOf1.model'; -export * from '../models/EntityPredicateValueOneOf1InInner.model'; export * from '../models/EntityRelation.model'; export * from '../models/ErrorError.model'; export * from '../models/ErrorRequest.model'; export * from '../models/ErrorResponse.model'; -export * from '../models/GetEntitiesByPredicates200Response.model'; -export * from '../models/GetEntitiesByPredicates200ResponsePageInfo.model'; -export * from '../models/GetEntitiesByPredicatesRequest.model'; export * from '../models/GetEntitiesByRefsRequest.model'; export * from '../models/GetLocations200ResponseInner.model'; export * from '../models/GetLocationsByQueryRequest.model'; @@ -56,6 +53,9 @@ export * from '../models/LocationsQueryResponse.model'; export * from '../models/LocationsQueryResponsePageInfo.model'; export * from '../models/ModelError.model'; export * from '../models/NullableEntity.model'; +export * from '../models/QueryEntitiesByPredicate200Response.model'; +export * from '../models/QueryEntitiesByPredicate200ResponsePageInfo.model'; +export * from '../models/QueryEntitiesByPredicateRequest.model'; export * from '../models/RecursivePartialEntity.model'; export * from '../models/RecursivePartialEntityMeta.model'; export * from '../models/RecursivePartialEntityMetaAllOf.model'; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/router.ts b/plugins/catalog-backend/src/schema/openapi/generated/router.ts index 5fe2c642b7..2625bd0125 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/router.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/router.ts @@ -276,40 +276,13 @@ export const spec = { type: 'boolean', }, { - type: 'object', - additionalProperties: false, - properties: { - $all: { - type: 'array', - items: { - $ref: '#/components/schemas/EntityPredicate', - }, - }, - }, - required: ['$all'], + $ref: '#/components/schemas/EntityPredicateAll', }, { - type: 'object', - additionalProperties: false, - properties: { - $any: { - type: 'array', - items: { - $ref: '#/components/schemas/EntityPredicate', - }, - }, - }, - required: ['$any'], + $ref: '#/components/schemas/EntityPredicateAny', }, { - type: 'object', - additionalProperties: false, - properties: { - $not: { - $ref: '#/components/schemas/EntityPredicate', - }, - }, - required: ['$not'], + $ref: '#/components/schemas/EntityPredicateNot', }, { type: 'object', @@ -319,6 +292,45 @@ export const spec = { }, ], }, + EntityPredicateAll: { + type: 'object', + description: 'All conditions must match (AND logic)', + additionalProperties: false, + properties: { + $all: { + type: 'array', + items: { + $ref: '#/components/schemas/EntityPredicate', + }, + }, + }, + required: ['$all'], + }, + EntityPredicateAny: { + type: 'object', + description: 'At least one condition must match (OR logic)', + additionalProperties: false, + properties: { + $any: { + type: 'array', + items: { + $ref: '#/components/schemas/EntityPredicate', + }, + }, + }, + required: ['$any'], + }, + EntityPredicateNot: { + type: 'object', + description: 'Negates the condition', + additionalProperties: false, + properties: { + $not: { + $ref: '#/components/schemas/EntityPredicate', + }, + }, + required: ['$not'], + }, EntityPredicateValue: { description: 'Value for a field predicate', oneOf: [ @@ -332,40 +344,48 @@ export const spec = { type: 'boolean', }, { - type: 'object', - additionalProperties: false, - properties: { - $exists: { - type: 'boolean', - }, - }, - required: ['$exists'], + $ref: '#/components/schemas/EntityPredicateExists', }, { - type: 'object', - additionalProperties: false, - properties: { - $in: { - type: 'array', - items: { - oneOf: [ - { - type: 'string', - }, - { - type: 'number', - }, - { - type: 'boolean', - }, - ], - }, - }, - }, - required: ['$in'], + $ref: '#/components/schemas/EntityPredicateIn', }, ], }, + EntityPredicateExists: { + type: 'object', + description: 'Check if field exists', + additionalProperties: false, + properties: { + $exists: { + type: 'boolean', + }, + }, + required: ['$exists'], + }, + EntityPredicateIn: { + type: 'object', + description: 'Match any value in array', + additionalProperties: false, + properties: { + $in: { + type: 'array', + items: { + oneOf: [ + { + type: 'string', + }, + { + type: 'number', + }, + { + type: 'boolean', + }, + ], + }, + }, + }, + required: ['$in'], + }, MapStringString: { type: 'object', properties: {}, @@ -1332,13 +1352,11 @@ export const spec = { }, ], }, - }, - '/entities/by-predicates': { post: { - operationId: 'GetEntitiesByPredicates', + operationId: 'QueryEntitiesByPredicate', tags: ['Entity'], description: - 'Query entities using predicate-based filters. This endpoint supports a more\nexpressive filter syntax with logical operators ($all, $any, $not) and\nvalue operators ($exists, $in).\n\nExample filter:\n```json\n{\n "filter": {\n "$all": [\n {"kind": "component"},\n {"$any": [\n {"spec.type": "service"},\n {"spec.type": "website"}\n ]},\n {"$not": {"spec.lifecycle": "experimental"}}\n ]\n }\n}\n```\n', + 'Query entities using predicate-based filters. This endpoint provides an\nalternative filtering method with a more expressive filter syntax supporting\nlogical operators ($all, $any, $not) and value operators ($exists, $in).\n\nExample query:\n```json\n{\n "query": {\n "$all": [\n {"kind": "component"},\n {"$any": [\n {"spec.type": "service"},\n {"spec.type": "website"}\n ]},\n {"$not": {"spec.lifecycle": "experimental"}}\n ]\n }\n}\n```\n', responses: { '200': { description: 'Ok', @@ -1392,19 +1410,10 @@ export const spec = { $ref: '#/components/parameters/offset', }, { - $ref: '#/components/parameters/after', + $ref: '#/components/parameters/orderField', }, { - name: 'order', - in: 'query', - allowReserved: true, - required: false, - schema: { - type: 'array', - items: { - type: 'string', - }, - }, + $ref: '#/components/parameters/after', }, ], requestBody: { @@ -1415,7 +1424,7 @@ export const spec = { type: 'object', additionalProperties: false, properties: { - filter: { + query: { $ref: '#/components/schemas/EntityPredicate', }, }, @@ -1423,7 +1432,7 @@ export const spec = { examples: { 'Get all service components': { value: { - filter: { + query: { $all: [ { kind: 'component', @@ -1437,7 +1446,7 @@ export const spec = { }, 'Get components owned by specific teams': { value: { - filter: { + query: { $all: [ { kind: 'component', @@ -1453,7 +1462,7 @@ export const spec = { }, 'Get non-production services': { value: { - filter: { + query: { $all: [ { kind: 'component', diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index e26a0c1233..6af2fc96b0 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -68,6 +68,7 @@ import { encodeLocationQueryCursor, parseLocationQuery, } from './request/parseLocationQuery'; +import { parseEntityOrderFieldParams } from './request/parseEntityOrderFieldParams'; /** * Options used by {@link createRouter}. @@ -261,24 +262,24 @@ export async function createRouter( throw err; } }) - .post('/entities/by-predicates', async (req, res) => { + .post('/entities/by-query', async (req, res) => { const auditorEvent = await auditor.createEvent({ eventId: 'entity-fetch', request: req, meta: { - queryType: 'by-predicates', + queryType: 'by-query-predicate', }, }); try { - const filter = req.body.filter as EntityPredicate | undefined; - const order = parseEntityOrderParams(req.query); + const query = req.body.query as EntityPredicate | undefined; + const order = parseEntityOrderFieldParams(req.query); const pagination = parseEntityPaginationParams(req.query); const credentials = await httpAuth.credentials(req); const { entities, pageInfo } = await entitiesCatalog.queryEntitiesByPredicate({ - filter, + query, order, pagination, credentials, From 4bc4eb06a0030050b5703d74d7f1aac4314b7c93 Mon Sep 17 00:00:00 2001 From: Nilay1999 Date: Thu, 12 Feb 2026 00:24:32 +0530 Subject: [PATCH 05/37] refactor(catalog): use @backstage/filter-predicates package types - Replace EntityPredicate with FilterPredicate - update package.json and yarn.lock - Add InputError handling for invalid predicate values in filter application - Update catalog-client utils.ts to use browser-compatible atob() for base64 decoding Signed-off-by: Nilay1999 --- packages/catalog-client/src/utils.ts | 3 +- plugins/catalog-backend/src/catalog/types.ts | 9 +-- .../src/service/createRouter.ts | 11 ++-- .../applyPredicateEntityFilterToQuery.ts | 42 +++++++----- plugins/catalog-backend/src/service/util.ts | 5 +- plugins/catalog-node/src/api/common.ts | 66 ------------------- plugins/catalog-node/src/api/index.ts | 4 -- 7 files changed, 42 insertions(+), 98 deletions(-) diff --git a/packages/catalog-client/src/utils.ts b/packages/catalog-client/src/utils.ts index e7713623b9..489093f65d 100644 --- a/packages/catalog-client/src/utils.ts +++ b/packages/catalog-client/src/utils.ts @@ -32,7 +32,8 @@ export function isQueryEntitiesInitialRequest( */ export function cursorContainsQuery(cursor: string): boolean { try { - const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString('utf8')); + // Use browser-compatible base64 decoding + const decoded = JSON.parse(atob(cursor)); return !!decoded.query; } catch { return false; diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index aad176e390..04bfb93d4f 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -16,7 +16,8 @@ import { BackstageCredentials } from '@backstage/backend-plugin-api'; import { Entity } from '@backstage/catalog-model'; -import { EntityFilter, EntityPredicate } from '@backstage/plugin-catalog-node'; +import { EntityFilter } from '@backstage/plugin-catalog-node'; +import { FilterPredicate } from '@backstage/filter-predicates'; /** * A pagination rule for entities. @@ -53,7 +54,7 @@ export type EntitiesRequest = { }; export type EntityPredicateRequest = { - query?: EntityPredicate; + query?: FilterPredicate; order?: EntityOrder[]; pagination?: EntityPagination; credentials: BackstageCredentials; @@ -232,7 +233,7 @@ export interface QueryEntitiesInitialRequest { * Predicate-based query for filtering entities. * Mutually exclusive with filter. */ - query?: EntityPredicate; + query?: FilterPredicate; orderFields?: EntityOrder[]; fullTextFilter?: { term: string; @@ -298,7 +299,7 @@ export type Cursor = { * A predicate-based query to be applied to the full list of entities. * Mutually exclusive with filter. */ - query?: EntityPredicate; + query?: FilterPredicate; /** * true if the cursor is a previous cursor. */ diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index 6af2fc96b0..c3608c19a6 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -30,10 +30,8 @@ import { } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { InputError, serializeError } from '@backstage/errors'; -import { - EntityPredicate, - LocationAnalyzer, -} from '@backstage/plugin-catalog-node'; +import { parseFilterPredicate } from '@backstage/filter-predicates'; +import { LocationAnalyzer } from '@backstage/plugin-catalog-node'; import express from 'express'; import yn from 'yn'; import { z } from 'zod'; @@ -272,7 +270,10 @@ export async function createRouter( }); try { - const query = req.body.query as EntityPredicate | undefined; + // Validate the query using the Zod schema from @backstage/filter-predicates + const query = req.body.query + ? parseFilterPredicate(req.body.query) + : undefined; const order = parseEntityOrderFieldParams(req.query); const pagination = parseEntityPaginationParams(req.query); const credentials = await httpAuth.credentials(req); diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index 59e08e7d9e..eff7c12f7e 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -15,32 +15,33 @@ */ import { - EntityPredicate, - EntityPredicatePrimitive, - EntityPredicateValue, -} from '@backstage/plugin-catalog-node'; + FilterPredicate, + FilterPredicatePrimitive, + FilterPredicateValue, +} from '@backstage/filter-predicates'; +import { InputError } from '@backstage/errors'; import { Knex } from 'knex'; import { DbSearchRow } from '../../database/tables'; function isAllPredicate( - filter: EntityPredicate, -): filter is { $all: EntityPredicate[] } { + filter: FilterPredicate, +): filter is { $all: FilterPredicate[] } { return typeof filter === 'object' && filter !== null && '$all' in filter; } function isAnyPredicate( - filter: EntityPredicate, -): filter is { $any: EntityPredicate[] } { + filter: FilterPredicate, +): filter is { $any: FilterPredicate[] } { return typeof filter === 'object' && filter !== null && '$any' in filter; } function isNotPredicate( - filter: EntityPredicate, -): filter is { $not: EntityPredicate } { + filter: FilterPredicate, +): filter is { $not: FilterPredicate } { return typeof filter === 'object' && filter !== null && '$not' in filter; } -function isPrimitive(value: unknown): value is EntityPredicatePrimitive { +function isPrimitive(value: unknown): value is FilterPredicatePrimitive { return ( typeof value === 'string' || typeof value === 'number' || @@ -49,18 +50,18 @@ function isPrimitive(value: unknown): value is EntityPredicatePrimitive { } function isExistsValue( - value: EntityPredicateValue, + value: FilterPredicateValue, ): value is { $exists: boolean } { return typeof value === 'object' && value !== null && '$exists' in value; } function isInValue( - value: EntityPredicateValue, -): value is { $in: EntityPredicatePrimitive[] } { + value: FilterPredicateValue, +): value is { $in: FilterPredicatePrimitive[] } { return typeof value === 'object' && value !== null && '$in' in value; } -function isFieldExpression(filter: EntityPredicate): boolean { +function isFieldExpression(filter: FilterPredicate): boolean { if (typeof filter !== 'object' || filter === null) { return false; } @@ -109,7 +110,7 @@ function isFieldExpression(filter: EntityPredicate): boolean { */ function applyPredicateInStrategy( - filter: EntityPredicate, + filter: FilterPredicate, targetQuery: Knex.QueryBuilder, onEntityIdField: string, knex: Knex, @@ -211,6 +212,13 @@ function applyPredicateInStrategy( value: String(value).toLowerCase(), }); this.andWhere(onEntityIdField, 'in', matchQuery); + } else { + // Reject unsupported/invalid predicate values + throw new InputError( + `Invalid filter predicate value for field "${key}": expected a primitive value, $exists, or $in operator, but got ${JSON.stringify( + value, + )}`, + ); } } }, @@ -221,7 +229,7 @@ function applyPredicateInStrategy( } export function applyPredicateEntityFilterToQuery(options: { - filter: EntityPredicate; + filter: FilterPredicate; targetQuery: Knex.QueryBuilder; onEntityIdField: string; knex: Knex; diff --git a/plugins/catalog-backend/src/service/util.ts b/plugins/catalog-backend/src/service/util.ts index dbdbd374cf..708eec2de3 100644 --- a/plugins/catalog-backend/src/service/util.ts +++ b/plugins/catalog-backend/src/service/util.ts @@ -15,6 +15,7 @@ */ import { InputError, NotAllowedError } from '@backstage/errors'; +import { createZodV3FilterPredicateSchema } from '@backstage/filter-predicates'; import { Request } from 'express'; import lodash from 'lodash'; import { z } from 'zod'; @@ -109,6 +110,8 @@ const entityFilterParser: z.ZodSchema = z.lazy(() => .or(z.object({ allOf: z.array(entityFilterParser) })), ); +const filterPredicateSchema = createZodV3FilterPredicateSchema(z); + export const cursorParser: z.ZodSchema = z.object({ orderFields: z.array( z.object({ field: z.string(), order: z.enum(['asc', 'desc']) }), @@ -122,7 +125,7 @@ export const cursorParser: z.ZodSchema = z.object({ orderFieldValues: z.array(z.string().or(z.null())), filter: entityFilterParser.optional(), isPrevious: z.boolean(), - query: z.any().optional(), + query: filterPredicateSchema.optional(), firstSortFieldValues: z.array(z.string().or(z.null())).optional(), totalItems: z.number().optional(), }); diff --git a/plugins/catalog-node/src/api/common.ts b/plugins/catalog-node/src/api/common.ts index f88857a312..7f9bd3eabf 100644 --- a/plugins/catalog-node/src/api/common.ts +++ b/plugins/catalog-node/src/api/common.ts @@ -85,69 +85,3 @@ export type EntitiesSearchFilter = { */ values?: string[]; }; - -/** - * Defines a predicate used to filter entities using logical and comparison operators. - * - * This type supports: - * - Primitive equality matching - * - Field existence checks - * - Inclusion checks - * - Logical composition using $all, $any, and $not - * - * @public - */ -export type EntityPredicate = - | EntityPredicateExpression - | EntityPredicatePrimitive - | { $all: EntityPredicate[] } - | { $any: EntityPredicate[] } - | { $not: EntityPredicate }; - -/** - * Represents a field-based predicate expression. - * - * Each key is treated as a JSON path into the entity object. - * Keys starting with `$` are reserved for operators and are not allowed. - * - * Example: - * ```ts - * { - * "spec.type": "service", - * "metadata.name": { $in: ["payments", "orders"] } - * } - * ``` - * - * @public - */ -export type EntityPredicateExpression = { - /** JSON path of the entity field */ - [KPath in string]: EntityPredicateValue; -} & { - /** Operator keys are not allowed as field paths */ - [KPath in `$${string}`]: never; -}; - -/** - * Represents a value condition for a predicate field. - * - * Supported forms: - * - Primitive equality match - * - Existence check using `$exists` - * - Inclusion check using `$in` - * - * @public - */ -export type EntityPredicateValue = - | EntityPredicatePrimitive - | { $exists: boolean } - | { $in: EntityPredicatePrimitive[] }; - -/** - * Represents a primitive predicate value. - * - * Used for direct equality comparison. - * - * @public - */ -export type EntityPredicatePrimitive = string | number | boolean; diff --git a/plugins/catalog-node/src/api/index.ts b/plugins/catalog-node/src/api/index.ts index 5b7abbb6b9..292a6cd872 100644 --- a/plugins/catalog-node/src/api/index.ts +++ b/plugins/catalog-node/src/api/index.ts @@ -20,10 +20,6 @@ export type { LocationSpec, EntitiesSearchFilter, EntityFilter, - EntityPredicate, - EntityPredicateExpression, - EntityPredicateValue, - EntityPredicatePrimitive, } from './common'; export type { CatalogProcessor, From 51e23eb73e5480c2b4042d9d7a9eb2ff7c4a3979 Mon Sep 17 00:00:00 2001 From: Nilay1999 Date: Thu, 12 Feb 2026 00:32:09 +0530 Subject: [PATCH 06/37] =?UTF-8?q?chore:=20=F0=9F=93=9D=20add=20changeset?= =?UTF-8?q?=20for=20predicate-based=20entity=20querying?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nilay1999 --- .changeset/rich-ducks-ring.md | 15 ++++++++ packages/catalog-client/report.api.md | 51 +++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 .changeset/rich-ducks-ring.md diff --git a/.changeset/rich-ducks-ring.md b/.changeset/rich-ducks-ring.md new file mode 100644 index 0000000000..0ba4b86961 --- /dev/null +++ b/.changeset/rich-ducks-ring.md @@ -0,0 +1,15 @@ +--- +'@backstage/catalog-client': minor +'@backstage/plugin-catalog-backend': minor +--- + +New POST /entities/by-query endpoint + +- Supports predicate-based entity filtering using advanced query operators ($all, $any, $in, $not, $exists) +- Enables complex nested queries for more powerful entity searches +- Provides cursor-based pagination for efficient result traversal + +Updated Catalog Client + +- Enhanced queryEntities() method to automatically route requests to POST endpoint when query predicate is provided +- Validates mutual exclusivity between filter (legacy) and query (predicate-based) parameters diff --git a/packages/catalog-client/report.api.md b/packages/catalog-client/report.api.md index 58fc4bc8a1..10e5b5d684 100644 --- a/packages/catalog-client/report.api.md +++ b/packages/catalog-client/report.api.md @@ -231,6 +231,56 @@ export type EntityOrderQuery = order: 'asc' | 'desc'; }>; +// @public +export type EntityPredicate = + | EntityPredicateAll + | EntityPredicateAny + | EntityPredicateNot + | boolean + | number + | string + | { + [key: string]: EntityPredicateValue; + }; + +// @public +export interface EntityPredicateAll { + // (undocumented) + $all: Array; +} + +// @public +export interface EntityPredicateAny { + // (undocumented) + $any: Array; +} + +// @public +export interface EntityPredicateExists { + // (undocumented) + $exists: boolean; +} + +// @public +export interface EntityPredicateIn { + // (undocumented) + $in: Array; +} + +// @public +export interface EntityPredicateNot { + // (undocumented) + $not: EntityPredicate; +} + +// @public +export type EntityPredicateValue = + | EntityPredicateExists + | EntityPredicateIn + | boolean + | number + | string; + // @public export interface GetEntitiesByRefsRequest { entityRefs: string[]; @@ -320,6 +370,7 @@ export type QueryEntitiesInitialRequest = { limit?: number; offset?: number; filter?: EntityFilterQuery; + query?: EntityPredicate; orderFields?: EntityOrderQuery; fullTextFilter?: { term: string; From 02b33b0650f4b0707dd6c9d3c54ffa231b585511 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 17 Feb 2026 10:10:53 +0100 Subject: [PATCH 07/37] refactor: integrate predicate filtering into queryEntities flow Remove separate queryEntitiesByPredicate method and integrate predicate-based filtering into the existing queryEntities path. POST /entities/by-query now uses the same queryEntities call with proper permission enforcement via applyEntityFilterToQuery. Aligns with the locations query pattern from PR #32846. Signed-off-by: benjdlambert --- .changeset/rich-ducks-ring.md | 11 +- packages/catalog-client/report.api.md | 54 +--- .../catalog-client/src/CatalogClient.test.ts | 43 +-- packages/catalog-client/src/CatalogClient.ts | 70 ++--- .../openapi/generated/apis/Api.client.ts | 19 +- .../generated/models/EntityPredicate.model.ts | 36 --- .../models/EntityPredicateAll.model.ts | 28 -- .../models/EntityPredicateAny.model.ts | 28 -- .../models/EntityPredicateIn.model.ts | 28 -- .../models/EntityPredicateInInInner.model.ts | 24 -- .../models/EntityPredicateNot.model.ts | 28 -- .../models/EntityPredicateValue.model.ts | 32 --- ...eryEntitiesByPredicate200Response.model.ts | 32 --- ...iesByPredicate200ResponsePageInfo.model.ts | 29 -- .../QueryEntitiesByPredicateRequest.model.ts | 13 +- ...sByPredicateRequestFullTextFilter.model.ts | 6 +- .../schema/openapi/generated/models/index.ts | 11 +- packages/catalog-client/src/types/api.ts | 2 +- packages/catalog-client/src/types/index.ts | 9 - .../catalog-client/src/types/predicate.ts | 122 --------- packages/catalog-client/src/utils.ts | 3 +- plugins/catalog-backend/src/catalog/types.ts | 16 -- .../catalog-backend/src/schema/openapi.yaml | 152 ++--------- .../openapi/generated/apis/Api.server.ts | 9 +- .../generated/models/EntityPredicate.model.ts | 36 --- .../models/EntityPredicateAll.model.ts | 28 -- .../models/EntityPredicateAny.model.ts | 28 -- .../models/EntityPredicateIn.model.ts | 28 -- .../models/EntityPredicateInInInner.model.ts | 24 -- .../models/EntityPredicateNot.model.ts | 28 -- .../models/EntityPredicateValue.model.ts | 32 --- ...eryEntitiesByPredicate200Response.model.ts | 32 --- ...iesByPredicate200ResponsePageInfo.model.ts | 29 -- .../QueryEntitiesByPredicateRequest.model.ts | 13 +- ...sByPredicateRequestFullTextFilter.model.ts | 6 +- .../schema/openapi/generated/models/index.ts | 11 +- .../src/schema/openapi/generated/router.ts | 249 +++--------------- .../service/AuthorizedEntitiesCatalog.test.ts | 1 - .../src/service/AuthorizedEntitiesCatalog.ts | 31 --- .../src/service/DefaultEntitiesCatalog.ts | 159 +---------- .../src/service/createRouter.test.ts | 62 ++++- .../src/service/createRouter.ts | 40 +-- .../request/applyEntityFilterToQuery.test.ts | 3 +- .../request/applyEntityFilterToQuery.ts | 25 +- .../applyPredicateEntityFilterToQuery.ts | 16 +- .../src/service/request/parseEntityQuery.ts | 115 ++++++++ 46 files changed, 364 insertions(+), 1437 deletions(-) delete mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicate.model.ts delete mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAll.model.ts delete mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAny.model.ts delete mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateIn.model.ts delete mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts delete mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateNot.model.ts delete mode 100644 packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateValue.model.ts delete mode 100644 packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts delete mode 100644 packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts rename plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateExists.model.ts => packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestFullTextFilter.model.ts (88%) delete mode 100644 packages/catalog-client/src/types/predicate.ts delete mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts delete mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAll.model.ts delete mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAny.model.ts delete mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateIn.model.ts delete mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts delete mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateNot.model.ts delete mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts delete mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts delete mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts rename packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateExists.model.ts => plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestFullTextFilter.model.ts (88%) create mode 100644 plugins/catalog-backend/src/service/request/parseEntityQuery.ts diff --git a/.changeset/rich-ducks-ring.md b/.changeset/rich-ducks-ring.md index 0ba4b86961..04848da4a7 100644 --- a/.changeset/rich-ducks-ring.md +++ b/.changeset/rich-ducks-ring.md @@ -3,13 +3,8 @@ '@backstage/plugin-catalog-backend': minor --- -New POST /entities/by-query endpoint +Added predicate-based entity filtering via POST /entities/by-query endpoint. -- Supports predicate-based entity filtering using advanced query operators ($all, $any, $in, $not, $exists) -- Enables complex nested queries for more powerful entity searches -- Provides cursor-based pagination for efficient result traversal +Supports `$all`, `$any`, `$not`, `$exists`, and `$in` operators for expressive entity queries. Integrated into the existing `queryEntities` flow with full cursor-based pagination, permission enforcement, and `totalItems` support. -Updated Catalog Client - -- Enhanced queryEntities() method to automatically route requests to POST endpoint when query predicate is provided -- Validates mutual exclusivity between filter (legacy) and query (predicate-based) parameters +The catalog client's `queryEntities()` method automatically routes to the POST endpoint when a `query` predicate is provided. diff --git a/packages/catalog-client/report.api.md b/packages/catalog-client/report.api.md index 10e5b5d684..cc83cce443 100644 --- a/packages/catalog-client/report.api.md +++ b/packages/catalog-client/report.api.md @@ -7,7 +7,7 @@ import type { AnalyzeLocationRequest } from '@backstage/plugin-catalog-common'; import type { AnalyzeLocationResponse } from '@backstage/plugin-catalog-common'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { Entity } from '@backstage/catalog-model'; -import { FilterPredicate } from '@backstage/filter-predicates'; +import type { FilterPredicate } from '@backstage/filter-predicates'; import { SerializedError } from '@backstage/errors'; // @public @@ -231,56 +231,6 @@ export type EntityOrderQuery = order: 'asc' | 'desc'; }>; -// @public -export type EntityPredicate = - | EntityPredicateAll - | EntityPredicateAny - | EntityPredicateNot - | boolean - | number - | string - | { - [key: string]: EntityPredicateValue; - }; - -// @public -export interface EntityPredicateAll { - // (undocumented) - $all: Array; -} - -// @public -export interface EntityPredicateAny { - // (undocumented) - $any: Array; -} - -// @public -export interface EntityPredicateExists { - // (undocumented) - $exists: boolean; -} - -// @public -export interface EntityPredicateIn { - // (undocumented) - $in: Array; -} - -// @public -export interface EntityPredicateNot { - // (undocumented) - $not: EntityPredicate; -} - -// @public -export type EntityPredicateValue = - | EntityPredicateExists - | EntityPredicateIn - | boolean - | number - | string; - // @public export interface GetEntitiesByRefsRequest { entityRefs: string[]; @@ -370,7 +320,7 @@ export type QueryEntitiesInitialRequest = { limit?: number; offset?: number; filter?: EntityFilterQuery; - query?: EntityPredicate; + query?: FilterPredicate; orderFields?: EntityOrderQuery; fullTextFilter?: { term: string; diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index 31c5d62046..4ee93c0cba 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -568,6 +568,7 @@ describe('CatalogClient', () => { }, }, ], + totalItems: 2, pageInfo: {}, }; @@ -575,9 +576,8 @@ describe('CatalogClient', () => { const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { expect(req.method).toBe('POST'); expect(req.body).toMatchObject({ - query: { - kind: 'component', - }, + query: { kind: 'component' }, + limit: 20, }); return res(ctx.json(defaultResponse)); }); @@ -591,7 +591,7 @@ describe('CatalogClient', () => { expect(mockedEndpoint).toHaveBeenCalledTimes(1); expect(response.items).toEqual(defaultResponse.items); - expect(response.totalItems).toBe(defaultResponse.items.length); + expect(response.totalItems).toBe(2); }); it('should throw error when both filter and query are provided', async () => { @@ -753,10 +753,7 @@ describe('CatalogClient', () => { it('should send orderFields with correct format (field,order)', async () => { const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { - const url = new URL(req.url); - expect(url.searchParams.getAll('orderField')).toEqual([ - 'metadata.name,asc', - ]); + expect(req.body.orderField).toEqual(['metadata.name,asc']); return res(ctx.json(defaultResponse)); }); @@ -772,8 +769,7 @@ describe('CatalogClient', () => { it('should send multiple orderFields with correct format', async () => { const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { - const url = new URL(req.url); - expect(url.searchParams.getAll('orderField')).toEqual([ + expect(req.body.orderField).toEqual([ 'metadata.name,asc', 'spec.type,desc', ]); @@ -793,11 +789,9 @@ describe('CatalogClient', () => { expect(mockedEndpoint).toHaveBeenCalledTimes(1); }); - it('should send limit and offset parameters', async () => { + it('should send limit and offset parameters in the body', async () => { const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { - const url = new URL(req.url); - expect(url.searchParams.get('limit')).toBe('50'); - expect(url.searchParams.get('offset')).toBe('10'); + expect(req.body.limit).toBe(50); return res(ctx.json(defaultResponse)); }); @@ -806,32 +800,11 @@ describe('CatalogClient', () => { await client.queryEntities({ query: { kind: 'component' }, limit: 50, - offset: 10, }); expect(mockedEndpoint).toHaveBeenCalledTimes(1); }); - it('should not allow cursor with query (cursor takes precedence)', async () => { - // When cursor is provided, it's not an initial request, so the query - // parameter is ignored and it goes to GET endpoint - const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { - // Should use GET endpoint, not POST - expect(req.method).toBe('GET'); - return res(ctx.json({ items: [], totalItems: 0, pageInfo: {} })); - }); - - server.use(rest.get(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); - - // This will use GET endpoint with cursor, ignoring the query parameter - await client.queryEntities({ - cursor: 'some-cursor', - query: { kind: 'component' }, - } as any); - - expect(mockedEndpoint).toHaveBeenCalledTimes(1); - }); - it('should handle errors from POST endpoint', async () => { const mockedEndpoint = jest .fn() diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 2d7fa7d957..51d57f715e 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -54,6 +54,7 @@ import { import { DefaultApiClient, GetLocationsByQueryRequest, + QueryEntitiesByPredicateRequest, TypedResponse, } from './schema/openapi'; import type { @@ -356,61 +357,48 @@ export class CatalogClient implements CatalogApi { request: QueryEntitiesRequest, options?: CatalogRequestOptions, ): Promise { - const params: { - limit?: number; - offset?: number; - after?: string; - orderField?: string[]; - cursor?: string; - fields?: string[]; - } = {}; - - let query; + const body: Record = {}; if (isQueryEntitiesInitialRequest(request)) { - // Initial request with query predicate - const { query: requestQuery, limit, offset, orderFields } = request; - query = requestQuery; - - if (limit !== undefined) { - params.limit = limit; + const { query, limit, orderFields, fullTextFilter, fields } = request; + if (query && typeof query === 'object') { + body.query = query; } - if (offset !== undefined) { - params.offset = offset; + if (limit !== undefined) { + body.limit = limit; } if (orderFields !== undefined) { - params.orderField = ( + body.orderField = ( Array.isArray(orderFields) ? orderFields : [orderFields] ).map(({ field, order }) => `${field},${order}`); } - } else { - // Cursor-based pagination request - const { cursor, limit } = request; - - params.after = cursor; - if (limit !== undefined) { - params.limit = limit; + if (fullTextFilter) { + body.fullTextFilter = fullTextFilter; + } + if (fields?.length) { + body.fields = fields; + } + } else { + body.cursor = request.cursor; + if (request.limit !== undefined) { + body.limit = request.limit; + } + if (request.fields?.length) { + body.fields = request.fields; } - // Query will be extracted from cursor on the backend - query = undefined; } - const response = await this.apiClient.queryEntitiesByPredicate( - { - body: query ? { query } : {}, - query: params, - }, - options, + const res = await this.requestRequired( + await this.apiClient.queryEntitiesByPredicate( + { body: body as unknown as QueryEntitiesByPredicateRequest }, + options, + ), ); - const result = await this.requestRequired(response); - return { - items: result.items, - totalItems: result.items.length, - pageInfo: { - nextCursor: result.pageInfo?.nextCursor, - }, + items: res.items, + totalItems: res.totalItems, + pageInfo: res.pageInfo, }; } diff --git a/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts b/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts index bc34d1c769..5a5eb2ec5d 100644 --- a/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts +++ b/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts @@ -29,7 +29,6 @@ import { Entity } from '../models/Entity.model'; import { EntityAncestryResponse } from '../models/EntityAncestryResponse.model'; import { EntityFacetsResponse } from '../models/EntityFacetsResponse.model'; import { GetEntitiesByRefsRequest } from '../models/GetEntitiesByRefsRequest.model'; -import { QueryEntitiesByPredicate200Response } from '../models/QueryEntitiesByPredicate200Response.model'; import { QueryEntitiesByPredicateRequest } from '../models/QueryEntitiesByPredicateRequest.model'; import { RefreshEntityRequest } from '../models/RefreshEntityRequest.model'; import { ValidateEntityRequest } from '../models/ValidateEntityRequest.model'; @@ -146,12 +145,6 @@ export type GetEntityFacets = { */ export type QueryEntitiesByPredicate = { body: QueryEntitiesByPredicateRequest; - query: { - limit?: number; - offset?: number; - orderField?: Array; - after?: string; - }; }; /** * @public @@ -466,23 +459,17 @@ export class DefaultApiClient { /** * Query entities using predicate-based filters. This endpoint provides an alternative filtering method with a more expressive filter syntax supporting logical operators ($all, $any, $not) and value operators ($exists, $in). Example query: ```json { \"query\": { \"$all\": [ {\"kind\": \"component\"}, {\"$any\": [ {\"spec.type\": \"service\"}, {\"spec.type\": \"website\"} ]}, {\"$not\": {\"spec.lifecycle\": \"experimental\"}} ] } } ``` * @param queryEntitiesByPredicateRequest - - * @param limit - Number of records to return in the response. - * @param offset - Number of records to skip in the query page. - * @param orderField - By default the entities are returned ordered by their internal uid. You can customize the `orderField` query parameters to affect that ordering. For example, to return entities by their name: `/entities/by-query?orderField=metadata.name,asc` Each parameter can be followed by `asc` for ascending lexicographical order or `desc` for descending (reverse) lexicographical order. - * @param after - Pointer to the previous page of results. */ public async queryEntitiesByPredicate( // @ts-ignore request: QueryEntitiesByPredicate, options?: RequestOptions, - ): Promise> { + ): Promise> { const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); - const uriTemplate = `/entities/by-query{?limit,offset,orderField*,after}`; + const uriTemplate = `/entities/by-query`; - const uri = parser.parse(uriTemplate).expand({ - ...request.query, - }); + const uri = parser.parse(uriTemplate).expand({}); return await this.fetchApi.fetch(`${baseUrl}${uri}`, { headers: { diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicate.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicate.model.ts deleted file mode 100644 index bdc862ca50..0000000000 --- a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicate.model.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2026 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// ****************************************************************** -// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * -// ****************************************************************** -import { EntityPredicateAll } from '../models/EntityPredicateAll.model'; -import { EntityPredicateAny } from '../models/EntityPredicateAny.model'; -import { EntityPredicateNot } from '../models/EntityPredicateNot.model'; -import { EntityPredicateValue } from '../models/EntityPredicateValue.model'; - -/** - * A predicate-based filter supporting logical operators. - $all: All conditions must match (AND) - $any: At least one condition must match (OR) - $not: Negates the condition - $exists: Check if field exists - $in: Match any value in array - * @public - */ -export type EntityPredicate = - | EntityPredicateAll - | EntityPredicateAny - | EntityPredicateNot - | boolean - | number - | string - | { [key: string]: EntityPredicateValue }; diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAll.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAll.model.ts deleted file mode 100644 index 080b8307b4..0000000000 --- a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAll.model.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2026 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// ****************************************************************** -// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * -// ****************************************************************** -import { EntityPredicate } from '../models/EntityPredicate.model'; - -/** - * All conditions must match (AND logic) - * @public - */ -export interface EntityPredicateAll { - $all: Array; -} diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAny.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAny.model.ts deleted file mode 100644 index 170ea3ab0c..0000000000 --- a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateAny.model.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2026 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// ****************************************************************** -// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * -// ****************************************************************** -import { EntityPredicate } from '../models/EntityPredicate.model'; - -/** - * At least one condition must match (OR logic) - * @public - */ -export interface EntityPredicateAny { - $any: Array; -} diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateIn.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateIn.model.ts deleted file mode 100644 index 574ab4f45b..0000000000 --- a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateIn.model.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2026 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// ****************************************************************** -// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * -// ****************************************************************** -import { EntityPredicateInInInner } from '../models/EntityPredicateInInInner.model'; - -/** - * Match any value in array - * @public - */ -export interface EntityPredicateIn { - $in: Array; -} diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts deleted file mode 100644 index 2b4e3d7c2f..0000000000 --- a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright 2026 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// ****************************************************************** -// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * -// ****************************************************************** - -/** - * @public - */ -export type EntityPredicateInInInner = boolean | number | string; diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateNot.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateNot.model.ts deleted file mode 100644 index e56279b0fc..0000000000 --- a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateNot.model.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2026 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// ****************************************************************** -// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * -// ****************************************************************** -import { EntityPredicate } from '../models/EntityPredicate.model'; - -/** - * Negates the condition - * @public - */ -export interface EntityPredicateNot { - $not: EntityPredicate; -} diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateValue.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateValue.model.ts deleted file mode 100644 index c230c2a984..0000000000 --- a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateValue.model.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2026 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// ****************************************************************** -// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * -// ****************************************************************** -import { EntityPredicateExists } from '../models/EntityPredicateExists.model'; -import { EntityPredicateIn } from '../models/EntityPredicateIn.model'; - -/** - * Value for a field predicate - * @public - */ -export type EntityPredicateValue = - | EntityPredicateExists - | EntityPredicateIn - | boolean - | number - | string; diff --git a/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts deleted file mode 100644 index 3c4b74cd10..0000000000 --- a/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2026 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// ****************************************************************** -// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * -// ****************************************************************** -import { Entity } from '../models/Entity.model'; -import { QueryEntitiesByPredicate200ResponsePageInfo } from '../models/QueryEntitiesByPredicate200ResponsePageInfo.model'; - -/** - * @public - */ -export interface QueryEntitiesByPredicate200Response { - /** - * The list of entities matching the predicate filter. - */ - items: Array; - pageInfo: QueryEntitiesByPredicate200ResponsePageInfo; -} diff --git a/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts deleted file mode 100644 index 3ef9f73758..0000000000 --- a/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2026 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// ****************************************************************** -// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * -// ****************************************************************** - -/** - * @public - */ -export interface QueryEntitiesByPredicate200ResponsePageInfo { - /** - * The cursor for the next batch of entities. - */ - nextCursor?: string; -} diff --git a/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts index 8b8276c9da..bdcd854a5c 100644 --- a/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts @@ -17,11 +17,20 @@ // ****************************************************************** // * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * // ****************************************************************** -import { EntityPredicate } from '../models/EntityPredicate.model'; + +import { QueryEntitiesByPredicateRequestFullTextFilter } from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model'; /** * @public */ export interface QueryEntitiesByPredicateRequest { - query?: EntityPredicate; + cursor?: string; + limit?: number; + orderField?: Array; + fullTextFilter?: QueryEntitiesByPredicateRequestFullTextFilter; + fields?: Array; + /** + * A type representing all allowed JSON object values. + */ + query?: { [key: string]: any }; } diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateExists.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestFullTextFilter.model.ts similarity index 88% rename from plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateExists.model.ts rename to packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestFullTextFilter.model.ts index 7800fcd20c..e7e971e1ea 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateExists.model.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestFullTextFilter.model.ts @@ -19,9 +19,9 @@ // ****************************************************************** /** - * Check if field exists * @public */ -export interface EntityPredicateExists { - $exists: boolean; +export interface QueryEntitiesByPredicateRequestFullTextFilter { + term?: string; + fields?: Array; } diff --git a/packages/catalog-client/src/schema/openapi/generated/models/index.ts b/packages/catalog-client/src/schema/openapi/generated/models/index.ts index 9d7ee33602..889b5968fa 100644 --- a/packages/catalog-client/src/schema/openapi/generated/models/index.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/index.ts @@ -31,14 +31,6 @@ export * from '../models/EntityFacet.model'; export * from '../models/EntityFacetsResponse.model'; export * from '../models/EntityLink.model'; export * from '../models/EntityMeta.model'; -export * from '../models/EntityPredicate.model'; -export * from '../models/EntityPredicateAll.model'; -export * from '../models/EntityPredicateAny.model'; -export * from '../models/EntityPredicateExists.model'; -export * from '../models/EntityPredicateIn.model'; -export * from '../models/EntityPredicateInInInner.model'; -export * from '../models/EntityPredicateNot.model'; -export * from '../models/EntityPredicateValue.model'; export * from '../models/EntityRelation.model'; export * from '../models/ErrorError.model'; export * from '../models/ErrorRequest.model'; @@ -53,9 +45,8 @@ export * from '../models/LocationsQueryResponse.model'; export * from '../models/LocationsQueryResponsePageInfo.model'; export * from '../models/ModelError.model'; export * from '../models/NullableEntity.model'; -export * from '../models/QueryEntitiesByPredicate200Response.model'; -export * from '../models/QueryEntitiesByPredicate200ResponsePageInfo.model'; export * from '../models/QueryEntitiesByPredicateRequest.model'; +export * from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model'; export * from '../models/RecursivePartialEntity.model'; export * from '../models/RecursivePartialEntityMeta.model'; export * from '../models/RecursivePartialEntityMetaAllOf.model'; diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index dda003ec90..8bf13271b2 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -20,7 +20,7 @@ import type { AnalyzeLocationRequest, AnalyzeLocationResponse, } from '@backstage/plugin-catalog-common'; -import { FilterPredicate } from '@backstage/filter-predicates'; +import type { FilterPredicate } from '@backstage/filter-predicates'; /** * This symbol can be used in place of a value when passed to filters in e.g. diff --git a/packages/catalog-client/src/types/index.ts b/packages/catalog-client/src/types/index.ts index 20a5f3303d..b280f1874f 100644 --- a/packages/catalog-client/src/types/index.ts +++ b/packages/catalog-client/src/types/index.ts @@ -44,13 +44,4 @@ export type { QueryLocationsInitialRequest, QueryLocationsResponse, } from './api'; -export type { - EntityPredicate, - EntityPredicateAll, - EntityPredicateAny, - EntityPredicateNot, - EntityPredicateValue, - EntityPredicateExists, - EntityPredicateIn, -} from './predicate'; export { ENTITY_STATUS_CATALOG_PROCESSING_TYPE } from './status'; diff --git a/packages/catalog-client/src/types/predicate.ts b/packages/catalog-client/src/types/predicate.ts deleted file mode 100644 index 11b081d650..0000000000 --- a/packages/catalog-client/src/types/predicate.ts +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright 2026 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * A predicate-based filter supporting logical operators. - * - * @remarks - * - * This provides a more expressive filter syntax compared to the traditional - * EntityFilterQuery. It supports: - * - Logical operators: $all (AND), $any (OR), $not (negation) - * - Value operators: $exists, $in - * - Direct field matching with primitive values - * - * @example - * ```typescript - * // Match all service components - * { - * $all: [ - * { kind: 'component' }, - * { 'spec.type': 'service' } - * ] - * } - * - * // Match components owned by specific teams - * { - * $all: [ - * { kind: 'component' }, - * { 'spec.owner': { $in: ['backend-team', 'platform-team'] } } - * ] - * } - * - * // Match non-production services - * { - * $all: [ - * { kind: 'component' }, - * { 'spec.type': 'service' }, - * { $not: { 'spec.lifecycle': 'production' } } - * ] - * } - * ``` - * - * @public - */ -export type EntityPredicate = - | EntityPredicateAll - | EntityPredicateAny - | EntityPredicateNot - | boolean - | number - | string - | { [key: string]: EntityPredicateValue }; - -/** - * All conditions must match (AND logic). - * - * @public - */ -export interface EntityPredicateAll { - $all: Array; -} - -/** - * At least one condition must match (OR logic). - * - * @public - */ -export interface EntityPredicateAny { - $any: Array; -} - -/** - * Negates the condition. - * - * @public - */ -export interface EntityPredicateNot { - $not: EntityPredicate; -} - -/** - * Value for a field predicate. - * - * @public - */ -export type EntityPredicateValue = - | EntityPredicateExists - | EntityPredicateIn - | boolean - | number - | string; - -/** - * Check if field exists. - * - * @public - */ -export interface EntityPredicateExists { - $exists: boolean; -} - -/** - * Match any value in array. - * - * @public - */ -export interface EntityPredicateIn { - $in: Array; -} diff --git a/packages/catalog-client/src/utils.ts b/packages/catalog-client/src/utils.ts index 489093f65d..fc867d8670 100644 --- a/packages/catalog-client/src/utils.ts +++ b/packages/catalog-client/src/utils.ts @@ -32,9 +32,8 @@ export function isQueryEntitiesInitialRequest( */ export function cursorContainsQuery(cursor: string): boolean { try { - // Use browser-compatible base64 decoding const decoded = JSON.parse(atob(cursor)); - return !!decoded.query; + return 'query' in decoded; } catch { return false; } diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index 04bfb93d4f..dda7e9a811 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -53,13 +53,6 @@ export type EntitiesRequest = { credentials: BackstageCredentials; }; -export type EntityPredicateRequest = { - query?: FilterPredicate; - order?: EntityOrder[]; - pagination?: EntityPagination; - credentials: BackstageCredentials; -}; - /** * Encapsulates either a deserialized or serialized entities to be sent in a response. * @internal @@ -173,15 +166,6 @@ export interface EntitiesCatalog { */ queryEntities(request: QueryEntitiesRequest): Promise; - /** - * Fetch entities using predicate-based filters. - * - * @param request - Request options with predicate filter - */ - queryEntitiesByPredicate( - request?: EntityPredicateRequest, - ): Promise; - /** * Removes a single entity. * diff --git a/plugins/catalog-backend/src/schema/openapi.yaml b/plugins/catalog-backend/src/schema/openapi.yaml index fba22d153a..f6b80ca062 100644 --- a/plugins/catalog-backend/src/schema/openapi.yaml +++ b/plugins/catalog-backend/src/schema/openapi.yaml @@ -338,86 +338,6 @@ components: properties: {} description: A type representing all allowed JSON object values. additionalProperties: {} - EntityPredicate: - description: | - A predicate-based filter supporting logical operators. - - $all: All conditions must match (AND) - - $any: At least one condition must match (OR) - - $not: Negates the condition - - $exists: Check if field exists - - $in: Match any value in array - oneOf: - - type: string - - type: number - - type: boolean - - $ref: '#/components/schemas/EntityPredicateAll' - - $ref: '#/components/schemas/EntityPredicateAny' - - $ref: '#/components/schemas/EntityPredicateNot' - - type: object - additionalProperties: - $ref: '#/components/schemas/EntityPredicateValue' - EntityPredicateAll: - type: object - description: All conditions must match (AND logic) - additionalProperties: false - properties: - $all: - type: array - items: - $ref: '#/components/schemas/EntityPredicate' - required: - - $all - EntityPredicateAny: - type: object - description: At least one condition must match (OR logic) - additionalProperties: false - properties: - $any: - type: array - items: - $ref: '#/components/schemas/EntityPredicate' - required: - - $any - EntityPredicateNot: - type: object - description: Negates the condition - additionalProperties: false - properties: - $not: - $ref: '#/components/schemas/EntityPredicate' - required: - - $not - EntityPredicateValue: - description: Value for a field predicate - oneOf: - - type: string - - type: number - - type: boolean - - $ref: '#/components/schemas/EntityPredicateExists' - - $ref: '#/components/schemas/EntityPredicateIn' - EntityPredicateExists: - type: object - description: Check if field exists - additionalProperties: false - properties: - $exists: - type: boolean - required: - - $exists - EntityPredicateIn: - type: object - description: Match any value in array - additionalProperties: false - properties: - $in: - type: array - items: - oneOf: - - type: string - - type: number - - type: boolean - required: - - $in MapStringString: type: object properties: {} @@ -1205,22 +1125,7 @@ paths: content: application/json: schema: - type: object - properties: - items: - type: array - items: - $ref: '#/components/schemas/Entity' - description: The list of entities matching the predicate filter. - pageInfo: - type: object - properties: - nextCursor: - type: string - description: The cursor for the next batch of entities. - required: - - items - - pageInfo + $ref: '#/components/schemas/EntitiesQueryResponse' '400': $ref: '#/components/responses/ErrorResponse' default: @@ -1228,45 +1133,36 @@ paths: security: - {} - JWT: [] - parameters: - - $ref: '#/components/parameters/limit' - - $ref: '#/components/parameters/offset' - - $ref: '#/components/parameters/orderField' - - $ref: '#/components/parameters/after' requestBody: - required: true + required: false content: application/json: schema: type: object - additionalProperties: false properties: + cursor: + type: string + limit: + type: number + orderField: + type: array + items: + type: string + fullTextFilter: + type: object + properties: + term: + type: string + fields: + type: array + items: + type: string + fields: + type: array + items: + type: string query: - $ref: '#/components/schemas/EntityPredicate' - examples: - Get all service components: - value: - query: - $all: - - kind: component - - spec.type: service - Get components owned by specific teams: - value: - query: - $all: - - kind: component - - spec.owner: - $in: - - backend-team - - platform-team - Get non-production services: - value: - query: - $all: - - kind: component - - spec.type: service - - $not: - spec.lifecycle: production + $ref: '#/components/schemas/JsonObject' /entity-facets: get: operationId: GetEntityFacets diff --git a/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts b/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts index 258c777067..dfb111359c 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/apis/Api.server.ts @@ -26,7 +26,6 @@ import { Entity } from '../models/Entity.model'; import { EntityAncestryResponse } from '../models/EntityAncestryResponse.model'; import { EntityFacetsResponse } from '../models/EntityFacetsResponse.model'; import { GetEntitiesByRefsRequest } from '../models/GetEntitiesByRefsRequest.model'; -import { QueryEntitiesByPredicate200Response } from '../models/QueryEntitiesByPredicate200Response.model'; import { QueryEntitiesByPredicateRequest } from '../models/QueryEntitiesByPredicateRequest.model'; import { RefreshEntityRequest } from '../models/RefreshEntityRequest.model'; import { ValidateEntity400Response } from '../models/ValidateEntity400Response.model'; @@ -127,13 +126,7 @@ export type GetEntityFacets = { */ export type QueryEntitiesByPredicate = { body: QueryEntitiesByPredicateRequest; - query: { - limit?: number; - offset?: number; - orderField?: Array; - after?: string; - }; - response: QueryEntitiesByPredicate200Response | Error | Error; + response: EntitiesQueryResponse | Error | Error; }; /** * @public diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts deleted file mode 100644 index bdc862ca50..0000000000 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicate.model.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2026 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// ****************************************************************** -// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * -// ****************************************************************** -import { EntityPredicateAll } from '../models/EntityPredicateAll.model'; -import { EntityPredicateAny } from '../models/EntityPredicateAny.model'; -import { EntityPredicateNot } from '../models/EntityPredicateNot.model'; -import { EntityPredicateValue } from '../models/EntityPredicateValue.model'; - -/** - * A predicate-based filter supporting logical operators. - $all: All conditions must match (AND) - $any: At least one condition must match (OR) - $not: Negates the condition - $exists: Check if field exists - $in: Match any value in array - * @public - */ -export type EntityPredicate = - | EntityPredicateAll - | EntityPredicateAny - | EntityPredicateNot - | boolean - | number - | string - | { [key: string]: EntityPredicateValue }; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAll.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAll.model.ts deleted file mode 100644 index 080b8307b4..0000000000 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAll.model.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2026 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// ****************************************************************** -// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * -// ****************************************************************** -import { EntityPredicate } from '../models/EntityPredicate.model'; - -/** - * All conditions must match (AND logic) - * @public - */ -export interface EntityPredicateAll { - $all: Array; -} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAny.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAny.model.ts deleted file mode 100644 index 170ea3ab0c..0000000000 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateAny.model.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2026 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// ****************************************************************** -// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * -// ****************************************************************** -import { EntityPredicate } from '../models/EntityPredicate.model'; - -/** - * At least one condition must match (OR logic) - * @public - */ -export interface EntityPredicateAny { - $any: Array; -} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateIn.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateIn.model.ts deleted file mode 100644 index 574ab4f45b..0000000000 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateIn.model.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2026 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// ****************************************************************** -// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * -// ****************************************************************** -import { EntityPredicateInInInner } from '../models/EntityPredicateInInInner.model'; - -/** - * Match any value in array - * @public - */ -export interface EntityPredicateIn { - $in: Array; -} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts deleted file mode 100644 index 2b4e3d7c2f..0000000000 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateInInInner.model.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright 2026 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// ****************************************************************** -// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * -// ****************************************************************** - -/** - * @public - */ -export type EntityPredicateInInInner = boolean | number | string; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateNot.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateNot.model.ts deleted file mode 100644 index e56279b0fc..0000000000 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateNot.model.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2026 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// ****************************************************************** -// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * -// ****************************************************************** -import { EntityPredicate } from '../models/EntityPredicate.model'; - -/** - * Negates the condition - * @public - */ -export interface EntityPredicateNot { - $not: EntityPredicate; -} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts deleted file mode 100644 index c230c2a984..0000000000 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/EntityPredicateValue.model.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2026 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// ****************************************************************** -// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * -// ****************************************************************** -import { EntityPredicateExists } from '../models/EntityPredicateExists.model'; -import { EntityPredicateIn } from '../models/EntityPredicateIn.model'; - -/** - * Value for a field predicate - * @public - */ -export type EntityPredicateValue = - | EntityPredicateExists - | EntityPredicateIn - | boolean - | number - | string; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts deleted file mode 100644 index 3c4b74cd10..0000000000 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200Response.model.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2026 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// ****************************************************************** -// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * -// ****************************************************************** -import { Entity } from '../models/Entity.model'; -import { QueryEntitiesByPredicate200ResponsePageInfo } from '../models/QueryEntitiesByPredicate200ResponsePageInfo.model'; - -/** - * @public - */ -export interface QueryEntitiesByPredicate200Response { - /** - * The list of entities matching the predicate filter. - */ - items: Array; - pageInfo: QueryEntitiesByPredicate200ResponsePageInfo; -} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts deleted file mode 100644 index 3ef9f73758..0000000000 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicate200ResponsePageInfo.model.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2026 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// ****************************************************************** -// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * -// ****************************************************************** - -/** - * @public - */ -export interface QueryEntitiesByPredicate200ResponsePageInfo { - /** - * The cursor for the next batch of entities. - */ - nextCursor?: string; -} diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts index 8b8276c9da..bdcd854a5c 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts @@ -17,11 +17,20 @@ // ****************************************************************** // * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * // ****************************************************************** -import { EntityPredicate } from '../models/EntityPredicate.model'; + +import { QueryEntitiesByPredicateRequestFullTextFilter } from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model'; /** * @public */ export interface QueryEntitiesByPredicateRequest { - query?: EntityPredicate; + cursor?: string; + limit?: number; + orderField?: Array; + fullTextFilter?: QueryEntitiesByPredicateRequestFullTextFilter; + fields?: Array; + /** + * A type representing all allowed JSON object values. + */ + query?: { [key: string]: any }; } diff --git a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateExists.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestFullTextFilter.model.ts similarity index 88% rename from packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateExists.model.ts rename to plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestFullTextFilter.model.ts index 7800fcd20c..e7e971e1ea 100644 --- a/packages/catalog-client/src/schema/openapi/generated/models/EntityPredicateExists.model.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestFullTextFilter.model.ts @@ -19,9 +19,9 @@ // ****************************************************************** /** - * Check if field exists * @public */ -export interface EntityPredicateExists { - $exists: boolean; +export interface QueryEntitiesByPredicateRequestFullTextFilter { + term?: string; + fields?: Array; } diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts index 9d7ee33602..889b5968fa 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts @@ -31,14 +31,6 @@ export * from '../models/EntityFacet.model'; export * from '../models/EntityFacetsResponse.model'; export * from '../models/EntityLink.model'; export * from '../models/EntityMeta.model'; -export * from '../models/EntityPredicate.model'; -export * from '../models/EntityPredicateAll.model'; -export * from '../models/EntityPredicateAny.model'; -export * from '../models/EntityPredicateExists.model'; -export * from '../models/EntityPredicateIn.model'; -export * from '../models/EntityPredicateInInInner.model'; -export * from '../models/EntityPredicateNot.model'; -export * from '../models/EntityPredicateValue.model'; export * from '../models/EntityRelation.model'; export * from '../models/ErrorError.model'; export * from '../models/ErrorRequest.model'; @@ -53,9 +45,8 @@ export * from '../models/LocationsQueryResponse.model'; export * from '../models/LocationsQueryResponsePageInfo.model'; export * from '../models/ModelError.model'; export * from '../models/NullableEntity.model'; -export * from '../models/QueryEntitiesByPredicate200Response.model'; -export * from '../models/QueryEntitiesByPredicate200ResponsePageInfo.model'; export * from '../models/QueryEntitiesByPredicateRequest.model'; +export * from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model'; export * from '../models/RecursivePartialEntity.model'; export * from '../models/RecursivePartialEntityMeta.model'; export * from '../models/RecursivePartialEntityMetaAllOf.model'; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/router.ts b/plugins/catalog-backend/src/schema/openapi/generated/router.ts index 2625bd0125..afac4ce5f8 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/router.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/router.ts @@ -262,130 +262,6 @@ export const spec = { description: 'A type representing all allowed JSON object values.', additionalProperties: {}, }, - EntityPredicate: { - description: - 'A predicate-based filter supporting logical operators.\n- $all: All conditions must match (AND)\n- $any: At least one condition must match (OR)\n- $not: Negates the condition\n- $exists: Check if field exists\n- $in: Match any value in array\n', - oneOf: [ - { - type: 'string', - }, - { - type: 'number', - }, - { - type: 'boolean', - }, - { - $ref: '#/components/schemas/EntityPredicateAll', - }, - { - $ref: '#/components/schemas/EntityPredicateAny', - }, - { - $ref: '#/components/schemas/EntityPredicateNot', - }, - { - type: 'object', - additionalProperties: { - $ref: '#/components/schemas/EntityPredicateValue', - }, - }, - ], - }, - EntityPredicateAll: { - type: 'object', - description: 'All conditions must match (AND logic)', - additionalProperties: false, - properties: { - $all: { - type: 'array', - items: { - $ref: '#/components/schemas/EntityPredicate', - }, - }, - }, - required: ['$all'], - }, - EntityPredicateAny: { - type: 'object', - description: 'At least one condition must match (OR logic)', - additionalProperties: false, - properties: { - $any: { - type: 'array', - items: { - $ref: '#/components/schemas/EntityPredicate', - }, - }, - }, - required: ['$any'], - }, - EntityPredicateNot: { - type: 'object', - description: 'Negates the condition', - additionalProperties: false, - properties: { - $not: { - $ref: '#/components/schemas/EntityPredicate', - }, - }, - required: ['$not'], - }, - EntityPredicateValue: { - description: 'Value for a field predicate', - oneOf: [ - { - type: 'string', - }, - { - type: 'number', - }, - { - type: 'boolean', - }, - { - $ref: '#/components/schemas/EntityPredicateExists', - }, - { - $ref: '#/components/schemas/EntityPredicateIn', - }, - ], - }, - EntityPredicateExists: { - type: 'object', - description: 'Check if field exists', - additionalProperties: false, - properties: { - $exists: { - type: 'boolean', - }, - }, - required: ['$exists'], - }, - EntityPredicateIn: { - type: 'object', - description: 'Match any value in array', - additionalProperties: false, - properties: { - $in: { - type: 'array', - items: { - oneOf: [ - { - type: 'string', - }, - { - type: 'number', - }, - { - type: 'boolean', - }, - ], - }, - }, - }, - required: ['$in'], - }, MapStringString: { type: 'object', properties: {}, @@ -1363,28 +1239,7 @@ export const spec = { content: { 'application/json': { schema: { - type: 'object', - properties: { - items: { - type: 'array', - items: { - $ref: '#/components/schemas/Entity', - }, - description: - 'The list of entities matching the predicate filter.', - }, - pageInfo: { - type: 'object', - properties: { - nextCursor: { - type: 'string', - description: - 'The cursor for the next batch of entities.', - }, - }, - }, - }, - required: ['items', 'pageInfo'], + $ref: '#/components/schemas/EntitiesQueryResponse', }, }, }, @@ -1402,81 +1257,47 @@ export const spec = { JWT: [], }, ], - parameters: [ - { - $ref: '#/components/parameters/limit', - }, - { - $ref: '#/components/parameters/offset', - }, - { - $ref: '#/components/parameters/orderField', - }, - { - $ref: '#/components/parameters/after', - }, - ], requestBody: { - required: true, + required: false, content: { 'application/json': { schema: { type: 'object', - additionalProperties: false, properties: { + cursor: { + type: 'string', + }, + limit: { + type: 'number', + }, + orderField: { + type: 'array', + items: { + type: 'string', + }, + }, + fullTextFilter: { + type: 'object', + properties: { + term: { + type: 'string', + }, + fields: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + }, + fields: { + type: 'array', + items: { + type: 'string', + }, + }, query: { - $ref: '#/components/schemas/EntityPredicate', - }, - }, - }, - examples: { - 'Get all service components': { - value: { - query: { - $all: [ - { - kind: 'component', - }, - { - 'spec.type': 'service', - }, - ], - }, - }, - }, - 'Get components owned by specific teams': { - value: { - query: { - $all: [ - { - kind: 'component', - }, - { - 'spec.owner': { - $in: ['backend-team', 'platform-team'], - }, - }, - ], - }, - }, - }, - 'Get non-production services': { - value: { - query: { - $all: [ - { - kind: 'component', - }, - { - 'spec.type': 'service', - }, - { - $not: { - 'spec.lifecycle': 'production', - }, - }, - ], - }, + $ref: '#/components/schemas/JsonObject', }, }, }, diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts index cf262c7778..894949a691 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts @@ -29,7 +29,6 @@ describe('AuthorizedEntitiesCatalog', () => { const fakeCatalog = { entities: jest.fn(), entitiesBatch: jest.fn(), - queryEntitiesByPredicate: jest.fn(), removeEntityByUid: jest.fn(), entityAncestry: jest.fn(), facets: jest.fn(), diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts index 831a3bb76b..d3d0dba5b3 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts @@ -32,7 +32,6 @@ import { EntityAncestryResponse, EntityFacetsRequest, EntityFacetsResponse, - EntityPredicateRequest, QueryEntitiesRequest, QueryEntitiesResponse, } from '../catalog/types'; @@ -197,36 +196,6 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { return this.entitiesCatalog.queryEntities(request); } - async queryEntitiesByPredicate( - request?: EntityPredicateRequest, - ): Promise { - if (!request) { - return { - entities: { type: 'object', entities: [] }, - pageInfo: { hasNextPage: false }, - }; - } - - const authorizeDecision = ( - await this.permissionApi.authorizeConditional( - [{ permission: catalogEntityReadPermission }], - { credentials: request.credentials }, - ) - )[0]; - - if (authorizeDecision.result === AuthorizeResult.DENY) { - return { - entities: { type: 'object', entities: [] }, - pageInfo: { hasNextPage: false }, - }; - } - - // Note: For CONDITIONAL results, we pass through to the underlying catalog - // since EntityPredicate filters are separate from EntityFilter permission conditions. - // The permission filter would need to be applied separately if needed. - return this.entitiesCatalog.queryEntitiesByPredicate(request); - } - async removeEntityByUid( uid: string, options: { credentials: BackstageCredentials }, diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts index 151e345f5b..11bce7bd86 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts @@ -31,7 +31,6 @@ import { EntityFacetsResponse, EntityOrder, EntityPagination, - EntityPredicateRequest, QueryEntitiesRequest, QueryEntitiesResponse, } from '../catalog/types'; @@ -46,8 +45,6 @@ import { import { Stitcher } from '../stitching/types'; import { - decodeCursor, - encodeCursor, expandLegacyCompoundRelationsInEntity, isQueryEntitiesCursorRequest, isQueryEntitiesInitialRequest, @@ -55,7 +52,6 @@ import { import { EntityFilter } from '@backstage/plugin-catalog-node'; import { LoggerService } from '@backstage/backend-plugin-api'; import { applyEntityFilterToQuery } from './request/applyEntityFilterToQuery'; -import { applyPredicateEntityFilterToQuery } from './request/applyPredicateEntityFilterToQuery'; import { processRawEntitiesResult } from './response'; const DEFAULT_LIMIT = 200; @@ -217,156 +213,6 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { }; } - async queryEntitiesByPredicate( - request?: EntityPredicateRequest, - ): Promise { - const db = this.database; - const { limit, offset } = parsePagination(request?.pagination); - - const cursor = request?.pagination?.after - ? decodeCursor(request.pagination.after) - : { - query: request?.query, - orderFields: request?.order || [], - isPrevious: false, - orderFieldValues: undefined, - firstSortFieldValues: undefined, - }; - - // Use query from cursor if not provided in request (pagination case) - const effectiveQuery = request?.query ?? cursor.query; - const sortField = cursor.orderFields?.[0]; - - let entitiesQuery = db('final_entities'); - - // Join with search table if we have a sort field - if (sortField) { - entitiesQuery = entitiesQuery - .distinct() - .leftOuterJoin({ order_0: 'search' }, function search(inner) { - inner - .on('order_0.entity_id', 'final_entities.entity_id') - .andOn('order_0.key', db.raw('?', [sortField.field])); - }) - .select({ - entity_id: 'final_entities.entity_id', - final_entity: 'final_entities.final_entity', - value: 'order_0.value', - }); - } else { - entitiesQuery = entitiesQuery.select({ - entity_id: 'final_entities.entity_id', - final_entity: 'final_entities.final_entity', - }); - } - - entitiesQuery = entitiesQuery.whereNotNull('final_entities.final_entity'); - - // Apply predicate filter from cursor - if (effectiveQuery) { - entitiesQuery = applyPredicateEntityFilterToQuery({ - filter: effectiveQuery, - targetQuery: entitiesQuery, - onEntityIdField: 'final_entities.entity_id', - knex: db, - }); - } - - // Apply cursor-based pagination (keyset pagination) - if (cursor.orderFieldValues) { - if (cursor.orderFieldValues.length === 2) { - const [sortValue, entityId] = cursor.orderFieldValues; - const isOrderingDescending = sortField?.order === 'desc'; - entitiesQuery = entitiesQuery.andWhere(function nested() { - this.where( - 'order_0.value', - isOrderingDescending ? '<' : '>', - sortValue, - ) - .orWhere('order_0.value', '=', sortValue) - .andWhere('final_entities.entity_id', '>', entityId); - }); - } else if (cursor.orderFieldValues.length === 1) { - const [entityId] = cursor.orderFieldValues; - entitiesQuery = entitiesQuery.andWhere( - 'final_entities.entity_id', - '>', - entityId, - ); - } - } - - if (sortField) { - if (db.client.config.client === 'pg') { - entitiesQuery = entitiesQuery.orderBy([ - { column: 'order_0.value', order: sortField.order, nulls: 'last' }, - { column: 'final_entities.entity_id', order: 'asc' }, - ]); - } else { - entitiesQuery = entitiesQuery.orderBy([ - { column: 'order_0.value', order: undefined, nulls: 'last' }, - { column: 'order_0.value', order: sortField.order }, - { column: 'final_entities.entity_id', order: 'asc' }, - ]); - } - } else { - entitiesQuery = entitiesQuery.orderBy('final_entities.entity_id', 'asc'); - } - - // Apply a manually set initial offset (only when not using cursor pagination) - if (!request?.pagination?.after && offset !== undefined) { - entitiesQuery = entitiesQuery.offset(offset); - } - const effectiveLimit = limit ?? DEFAULT_LIMIT; - entitiesQuery = entitiesQuery.limit(effectiveLimit + 1); - - let rows = await entitiesQuery; - let pageInfo: DbPageInfo; - - if (rows.length <= effectiveLimit) { - pageInfo = { hasNextPage: false }; - } else { - // Remove the extra row - rows = rows.slice(0, -1); - - const lastRow = rows[rows.length - 1]; - const firstRow = rows[0]; - - // Create proper cursor with query field - const nextCursor: Cursor = { - query: effectiveQuery, - orderFields: cursor.orderFields || [], - orderFieldValues: sortField - ? [(lastRow as any).value, lastRow.entity_id] - : [lastRow.entity_id], - isPrevious: false, - firstSortFieldValues: - cursor.firstSortFieldValues || - (sortField - ? [(firstRow as any).value, firstRow.entity_id] - : [firstRow.entity_id]), - }; - - pageInfo = { - hasNextPage: true, - endCursor: encodeCursor(nextCursor), - }; - } - - return { - entities: processRawEntitiesResult( - rows.map(r => r.final_entity!), - this.enableRelationsCompatibility - ? e => { - expandLegacyCompoundRelationsInEntity(e); - return e; - } - : undefined, - ), - pageInfo, - }; - } - async entitiesBatch( request: EntitiesBatchRequest, ): Promise { @@ -457,10 +303,11 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { }); } - // Add regular filters, if given - if (cursor.filter) { + // Add regular filters and/or predicate query, if given + if (cursor.filter || cursor.query) { applyEntityFilterToQuery({ filter: cursor.filter, + query: cursor.query, targetQuery: inner, onEntityIdField: 'final_entities.entity_id', knex: this.database, diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index 2a792f1de1..4571d3bc68 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -72,7 +72,7 @@ describe('createRouter readonly disabled', () => { beforeEach(async () => { entitiesCatalog = { entities: jest.fn(), - queryEntitiesByPredicate: jest.fn(), + entitiesBatch: jest.fn(), removeEntityByUid: jest.fn(), entityAncestry: jest.fn(), @@ -480,6 +480,62 @@ describe('createRouter readonly disabled', () => { }); }); + describe('POST /entities/by-query', () => { + it('queries entities with a predicate filter', async () => { + const items: Entity[] = [ + { apiVersion: 'a', kind: 'b', metadata: { name: 'n' } }, + ]; + entitiesCatalog.queryEntities.mockResolvedValue({ + items: { type: 'object', entities: items }, + pageInfo: {}, + totalItems: 1, + }); + + const response = await request(app) + .post('/entities/by-query') + .send({ query: { kind: 'b' }, limit: 10 }); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + items, + totalItems: 1, + pageInfo: {}, + }); + expect(entitiesCatalog.queryEntities).toHaveBeenCalledWith( + expect.objectContaining({ + query: { kind: 'b' }, + limit: 10, + credentials: mockCredentials.user(), + }), + ); + }); + + it('paginates with a cursor in the body', async () => { + const items: Entity[] = [ + { apiVersion: 'a', kind: 'b', metadata: { name: 'n' } }, + ]; + const cursor = mockCursor({ totalItems: 100, isPrevious: false }); + + entitiesCatalog.queryEntities.mockResolvedValue({ + items: { type: 'object', entities: items }, + pageInfo: { nextCursor: mockCursor() }, + totalItems: 100, + }); + + const response = await request(app) + .post('/entities/by-query') + .send({ cursor: encodeCursor(cursor) }); + + expect(response.status).toEqual(200); + expect(entitiesCatalog.queryEntities).toHaveBeenCalledWith( + expect.objectContaining({ + cursor, + credentials: mockCredentials.user(), + }), + ); + }); + }); + describe('GET /entities/by-uid/:uid', () => { it('can fetch entity by uid', async () => { const entity: Entity = { @@ -1121,7 +1177,7 @@ describe('createRouter readonly and raw json enabled', () => { beforeAll(async () => { entitiesCatalog = { entities: jest.fn(), - queryEntitiesByPredicate: jest.fn(), + entitiesBatch: jest.fn(), removeEntityByUid: jest.fn(), entityAncestry: jest.fn(), @@ -1338,7 +1394,7 @@ describe('NextRouter permissioning', () => { beforeAll(async () => { entitiesCatalog = { entities: jest.fn(), - queryEntitiesByPredicate: jest.fn(), + entitiesBatch: jest.fn(), removeEntityByUid: jest.fn(), entityAncestry: jest.fn(), diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index c3608c19a6..48bf7961b8 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -30,7 +30,6 @@ import { } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { InputError, serializeError } from '@backstage/errors'; -import { parseFilterPredicate } from '@backstage/filter-predicates'; import { LocationAnalyzer } from '@backstage/plugin-catalog-node'; import express from 'express'; import yn from 'yn'; @@ -66,7 +65,7 @@ import { encodeLocationQueryCursor, parseLocationQuery, } from './request/parseLocationQuery'; -import { parseEntityOrderFieldParams } from './request/parseEntityOrderFieldParams'; +import { parseEntityQuery } from './request/parseEntityQuery'; /** * Options used by {@link createRouter}. @@ -265,31 +264,34 @@ export async function createRouter( eventId: 'entity-fetch', request: req, meta: { - queryType: 'by-query-predicate', + queryType: 'by-query', }, }); try { - // Validate the query using the Zod schema from @backstage/filter-predicates - const query = req.body.query - ? parseFilterPredicate(req.body.query) - : undefined; - const order = parseEntityOrderFieldParams(req.query); - const pagination = parseEntityPaginationParams(req.query); const credentials = await httpAuth.credentials(req); + const { fields: rawFields, ...parsed } = parseEntityQuery( + req.body ?? {}, + ); + const fields = rawFields?.length + ? parseEntityTransformParams({ fields: rawFields }) + : undefined; - const { entities, pageInfo } = - await entitiesCatalog.queryEntitiesByPredicate({ - query, - order, - pagination, + const { items, pageInfo, totalItems } = + await entitiesCatalog.queryEntities({ credentials, + fields, + ...parsed, }); const meta = { + totalItems, pageInfo: { - ...(pageInfo.hasNextPage && { - nextCursor: pageInfo.endCursor, + ...(pageInfo.nextCursor && { + nextCursor: encodeCursor(pageInfo.nextCursor), + }), + ...(pageInfo.prevCursor && { + prevCursor: encodeCursor(pageInfo.prevCursor), }), }, }; @@ -298,10 +300,10 @@ export async function createRouter( await writeEntitiesResponse({ res, - items: entities, + items, alwaysUseObjectMode: enableRelationsCompatibility, - responseWrapper: items => ({ - items, + responseWrapper: entities => ({ + items: entities, ...meta, }), }); diff --git a/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.test.ts b/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.test.ts index ea64f5b400..8d3db17934 100644 --- a/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.test.ts +++ b/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.test.ts @@ -108,7 +108,7 @@ describe.each(databases.eachSupportedId())( } // #endregion - describe.each(strategies)('with strategy %p', strategy => { + describe.each(strategies)('with strategy %p', _strategy => { async function query(filter: EntityFilter): Promise { const q = knex('final_entities').whereNotNull( @@ -119,7 +119,6 @@ describe.each(databases.eachSupportedId())( targetQuery: q, onEntityIdField: 'final_entities.entity_id', knex, - strategy, }); return await q.then(rows => rows diff --git a/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.ts index b2a7fb6708..7cbe4f5f58 100644 --- a/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.ts @@ -18,8 +18,10 @@ import { EntitiesSearchFilter, EntityFilter, } from '@backstage/plugin-catalog-node'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { Knex } from 'knex'; import { DbSearchRow } from '../../database/tables'; +import { applyPredicateEntityFilterToQuery } from './applyPredicateEntityFilterToQuery'; function isEntitiesSearchFilter( filter: EntitiesSearchFilter | EntityFilter, @@ -118,13 +120,28 @@ function applyInStrategy( // The actual exported function export function applyEntityFilterToQuery(options: { - filter: EntityFilter; + filter?: EntityFilter; + query?: FilterPredicate; targetQuery: Knex.QueryBuilder; onEntityIdField: string; knex: Knex; - strategy?: 'in' | 'join'; }): Knex.QueryBuilder { - const { filter, targetQuery, onEntityIdField, knex } = options; + const { filter, query, targetQuery, onEntityIdField, knex } = options; - return applyInStrategy(filter, targetQuery, onEntityIdField, knex, false); + let result = targetQuery; + + if (filter) { + result = applyInStrategy(filter, result, onEntityIdField, knex, false); + } + + if (query) { + result = applyPredicateEntityFilterToQuery({ + filter: query, + targetQuery: result, + onEntityIdField, + knex, + }); + } + + return result; } diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index eff7c12f7e..db39a2195a 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -165,15 +165,14 @@ function applyPredicateInStrategy( ); } - // Handle primitive value at top level (e.g., "component" shorthand) + // Reject primitives at the top level. Matching by value without specifying + // a field key is ambiguous and should not be allowed. if (isPrimitive(filter)) { - const matchQuery = knex('search') - .select('search.entity_id') - .where({ value: String(filter).toLowerCase() }); - return targetQuery.andWhere( - onEntityIdField, - negate ? 'not in' : 'in', - matchQuery, + throw new InputError( + `Invalid filter predicate: top-level primitive values are not supported. ` + + `Wrap the value in a field expression, e.g. { "kind": ${JSON.stringify( + filter, + )} }`, ); } @@ -233,7 +232,6 @@ export function applyPredicateEntityFilterToQuery(options: { targetQuery: Knex.QueryBuilder; onEntityIdField: string; knex: Knex; - strategy?: 'in' | 'join'; }): Knex.QueryBuilder { const { filter, targetQuery, onEntityIdField, knex } = options; diff --git a/plugins/catalog-backend/src/service/request/parseEntityQuery.ts b/plugins/catalog-backend/src/service/request/parseEntityQuery.ts new file mode 100644 index 0000000000..c22d849391 --- /dev/null +++ b/plugins/catalog-backend/src/service/request/parseEntityQuery.ts @@ -0,0 +1,115 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InputError } from '@backstage/errors'; +import { + createZodV3FilterPredicateSchema, + FilterPredicate, +} from '@backstage/filter-predicates'; +import { z } from 'zod/v3'; +import { fromZodError } from 'zod-validation-error'; +import { QueryEntitiesByPredicateRequest } from '../../schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model'; +import { EntityOrder } from '../../catalog/types'; +import { Cursor } from '../../catalog/types'; +import { decodeCursor } from '../util'; + +const filterPredicateSchema = createZodV3FilterPredicateSchema(z); + +function isSupportedFilterPredicateRoot( + value: FilterPredicate | undefined, +): boolean { + if (value === undefined) { + return true; + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + return true; +} + +function parseOrderFields( + orderField: string[] | undefined, +): EntityOrder[] | undefined { + if (!orderField?.length) { + return undefined; + } + return orderField.map(entry => { + const [field, order] = entry.split(','); + if (order !== undefined && order !== 'asc' && order !== 'desc') { + throw new InputError('Invalid order field order, must be asc or desc'); + } + return { field, order: order as 'asc' | 'desc' }; + }); +} + +export type ParsedEntityQuery = + | { + cursor: Cursor; + fields?: string[]; + limit?: number; + } + | { + query?: FilterPredicate; + orderFields?: EntityOrder[]; + fullTextFilter?: { term: string; fields?: string[] }; + fields?: string[]; + limit?: number; + offset?: number; + }; + +export function parseEntityQuery( + request: Readonly, +): ParsedEntityQuery { + if (request.cursor !== undefined) { + if (!request.cursor) { + throw new InputError('Cursor cannot be empty'); + } + + const cursor = decodeCursor(request.cursor); + return { + cursor, + fields: request.fields, + limit: request.limit, + }; + } + + let query: FilterPredicate | undefined; + if (request.query !== undefined) { + const result = filterPredicateSchema.safeParse(request.query); + if (!result.success) { + throw new InputError(`Invalid query: ${fromZodError(result.error)}`); + } + if (!isSupportedFilterPredicateRoot(result.data)) { + throw new InputError('Query must be an object'); + } + query = result.data; + } + + const orderFields = parseOrderFields(request.orderField); + + return { + query, + orderFields, + fullTextFilter: request.fullTextFilter + ? { + term: request.fullTextFilter.term ?? '', + fields: request.fullTextFilter.fields, + } + : undefined, + fields: request.fields, + limit: request.limit, + }; +} From b2b4f06f617748230661db65f0f5d4cb21800359 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 17 Feb 2026 10:35:23 +0100 Subject: [PATCH 08/37] chore: small fix Signed-off-by: benjdlambert --- plugins/catalog-backend/src/service/request/parseEntityQuery.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/catalog-backend/src/service/request/parseEntityQuery.ts b/plugins/catalog-backend/src/service/request/parseEntityQuery.ts index c22d849391..5ad505b8a1 100644 --- a/plugins/catalog-backend/src/service/request/parseEntityQuery.ts +++ b/plugins/catalog-backend/src/service/request/parseEntityQuery.ts @@ -20,7 +20,7 @@ import { FilterPredicate, } from '@backstage/filter-predicates'; import { z } from 'zod/v3'; -import { fromZodError } from 'zod-validation-error'; +import { fromZodError } from 'zod-validation-error/v3'; import { QueryEntitiesByPredicateRequest } from '../../schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model'; import { EntityOrder } from '../../catalog/types'; import { Cursor } from '../../catalog/types'; From 58edede71653ad9d19c284653444d80ac9cac6d8 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 17 Feb 2026 10:45:55 +0100 Subject: [PATCH 09/37] chore: refactor a little bit Signed-off-by: benjdlambert --- .../openapi/generated/apis/Api.client.ts | 2 +- .../catalog-backend/src/schema/openapi.yaml | 21 +------------------ .../src/schema/openapi/generated/router.ts | 3 +-- .../request/applyEntityFilterToQuery.test.ts | 3 ++- .../request/applyEntityFilterToQuery.ts | 1 + 5 files changed, 6 insertions(+), 24 deletions(-) diff --git a/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts b/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts index 5a5eb2ec5d..086f3695d4 100644 --- a/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts +++ b/packages/catalog-client/src/schema/openapi/generated/apis/Api.client.ts @@ -457,7 +457,7 @@ export class DefaultApiClient { } /** - * Query entities using predicate-based filters. This endpoint provides an alternative filtering method with a more expressive filter syntax supporting logical operators ($all, $any, $not) and value operators ($exists, $in). Example query: ```json { \"query\": { \"$all\": [ {\"kind\": \"component\"}, {\"$any\": [ {\"spec.type\": \"service\"}, {\"spec.type\": \"website\"} ]}, {\"$not\": {\"spec.lifecycle\": \"experimental\"}} ] } } ``` + * Query entities using predicate-based filters. * @param queryEntitiesByPredicateRequest - */ public async queryEntitiesByPredicate( diff --git a/plugins/catalog-backend/src/schema/openapi.yaml b/plugins/catalog-backend/src/schema/openapi.yaml index f6b80ca062..f44ac026e4 100644 --- a/plugins/catalog-backend/src/schema/openapi.yaml +++ b/plugins/catalog-backend/src/schema/openapi.yaml @@ -1099,26 +1099,7 @@ paths: operationId: QueryEntitiesByPredicate tags: - Entity - description: | - Query entities using predicate-based filters. This endpoint provides an - alternative filtering method with a more expressive filter syntax supporting - logical operators ($all, $any, $not) and value operators ($exists, $in). - - Example query: - ```json - { - "query": { - "$all": [ - {"kind": "component"}, - {"$any": [ - {"spec.type": "service"}, - {"spec.type": "website"} - ]}, - {"$not": {"spec.lifecycle": "experimental"}} - ] - } - } - ``` + description: Query entities using predicate-based filters. responses: '200': description: Ok diff --git a/plugins/catalog-backend/src/schema/openapi/generated/router.ts b/plugins/catalog-backend/src/schema/openapi/generated/router.ts index afac4ce5f8..86ddeddf61 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/router.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/router.ts @@ -1231,8 +1231,7 @@ export const spec = { post: { operationId: 'QueryEntitiesByPredicate', tags: ['Entity'], - description: - 'Query entities using predicate-based filters. This endpoint provides an\nalternative filtering method with a more expressive filter syntax supporting\nlogical operators ($all, $any, $not) and value operators ($exists, $in).\n\nExample query:\n```json\n{\n "query": {\n "$all": [\n {"kind": "component"},\n {"$any": [\n {"spec.type": "service"},\n {"spec.type": "website"}\n ]},\n {"$not": {"spec.lifecycle": "experimental"}}\n ]\n }\n}\n```\n', + description: 'Query entities using predicate-based filters.', responses: { '200': { description: 'Ok', diff --git a/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.test.ts b/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.test.ts index 8d3db17934..ea64f5b400 100644 --- a/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.test.ts +++ b/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.test.ts @@ -108,7 +108,7 @@ describe.each(databases.eachSupportedId())( } // #endregion - describe.each(strategies)('with strategy %p', _strategy => { + describe.each(strategies)('with strategy %p', strategy => { async function query(filter: EntityFilter): Promise { const q = knex('final_entities').whereNotNull( @@ -119,6 +119,7 @@ describe.each(databases.eachSupportedId())( targetQuery: q, onEntityIdField: 'final_entities.entity_id', knex, + strategy, }); return await q.then(rows => rows diff --git a/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.ts index 7cbe4f5f58..49356cd0ff 100644 --- a/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyEntityFilterToQuery.ts @@ -125,6 +125,7 @@ export function applyEntityFilterToQuery(options: { targetQuery: Knex.QueryBuilder; onEntityIdField: string; knex: Knex; + strategy?: 'in' | 'join'; }): Knex.QueryBuilder { const { filter, query, targetQuery, onEntityIdField, knex } = options; From 723b94aa16b28dcaecfa8a5b9fb74bf4d341ab05 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 17 Feb 2026 10:57:37 +0100 Subject: [PATCH 10/37] chore: added some tests Signed-off-by: benjdlambert --- .../applyPredicateEntityFilterToQuery.test.ts | 220 ++++++++++++++++++ .../service/request/parseEntityQuery.test.ts | 199 ++++++++++++++++ 2 files changed, 419 insertions(+) create mode 100644 plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts create mode 100644 plugins/catalog-backend/src/service/request/parseEntityQuery.test.ts diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts new file mode 100644 index 0000000000..63b0bb9b24 --- /dev/null +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts @@ -0,0 +1,220 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestDatabases } from '@backstage/backend-test-utils'; +import { applyEntityFilterToQuery } from './applyEntityFilterToQuery'; +import { + DbFinalEntitiesRow, + DbRefreshStateRow, + DbSearchRow, +} from '../../database/tables'; +import { Knex } from 'knex'; +import { applyDatabaseMigrations } from '../../database/migrations'; +import { FilterPredicate } from '@backstage/filter-predicates'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; +import { v4 as uuid } from 'uuid'; +import { buildEntitySearch } from '../../database/operations/stitcher/buildEntitySearch'; + +jest.setTimeout(60_000); + +const databases = TestDatabases.create(); + +describe.each(databases.eachSupportedId())( + 'applyEntityFilterToQuery with predicate queries, %p', + databaseId => { + let knex: Knex; + + beforeAll(async () => { + knex = await databases.init(databaseId); + await applyDatabaseMigrations(knex); + await addEntity({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'service-a', namespace: 'default' }, + spec: { type: 'service', lifecycle: 'production', owner: 'team-a' }, + }); + await addEntity({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'service-b', namespace: 'default' }, + spec: { type: 'service', lifecycle: 'experimental', owner: 'team-b' }, + }); + await addEntity({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'website-c', namespace: 'default' }, + spec: { type: 'website', lifecycle: 'production', owner: 'team-a' }, + }); + await addEntity({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'API', + metadata: { name: 'api-d', namespace: 'default' }, + spec: { type: 'openapi', lifecycle: 'production' }, + }); + await addEntity({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'bare-e', namespace: 'default' }, + spec: {}, + }); + }); + + afterAll(async () => { + knex.destroy(); + }); + + async function addEntity(entity: Entity) { + const id = uuid(); + const entityRef = stringifyEntityRef(entity); + const entityJson = JSON.stringify(entity); + + await knex('refresh_state').insert({ + entity_id: id, + entity_ref: entityRef, + unprocessed_entity: entityJson, + errors: '[]', + next_update_at: '2031-01-01 23:00:00', + last_discovery_at: '2021-04-01 13:37:00', + }); + + await knex('final_entities').insert({ + entity_id: id, + entity_ref: entityRef, + final_entity: entityJson, + hash: 'h', + stitch_ticket: '', + }); + + const search = await buildEntitySearch(id, entity); + await knex('search').insert(search); + } + + async function query(predicate: FilterPredicate): Promise { + const q = + knex('final_entities').whereNotNull('final_entity'); + applyEntityFilterToQuery({ + query: predicate, + targetQuery: q, + onEntityIdField: 'final_entities.entity_id', + knex, + }); + return await q.then(rows => + rows.map(row => JSON.parse(row.final_entity!).metadata.name).toSorted(), + ); + } + + it('filters by direct field value', async () => { + await expect(query({ kind: 'component' })).resolves.toEqual([ + 'bare-e', + 'service-a', + 'service-b', + 'website-c', + ]); + }); + + it('filters by exact spec field value', async () => { + await expect(query({ 'spec.type': 'service' })).resolves.toEqual([ + 'service-a', + 'service-b', + ]); + }); + + it('filters with $all', async () => { + await expect( + query({ + $all: [{ kind: 'component' }, { 'spec.type': 'service' }], + }), + ).resolves.toEqual(['service-a', 'service-b']); + }); + + it('filters with $any', async () => { + await expect( + query({ + $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], + }), + ).resolves.toEqual(['service-a', 'service-b', 'website-c']); + }); + + it('filters with $not', async () => { + await expect( + query({ + $all: [ + { kind: 'component' }, + { $not: { 'spec.lifecycle': 'experimental' } }, + ], + }), + ).resolves.toEqual(['bare-e', 'service-a', 'website-c']); + }); + + it('filters with $in', async () => { + await expect( + query({ 'spec.type': { $in: ['service', 'openapi'] } }), + ).resolves.toEqual(['api-d', 'service-a', 'service-b']); + }); + + it('filters with $exists true', async () => { + await expect( + query({ + $all: [{ kind: 'component' }, { 'spec.owner': { $exists: true } }], + }), + ).resolves.toEqual(['service-a', 'service-b', 'website-c']); + }); + + it('filters with $exists false', async () => { + await expect( + query({ + $all: [{ kind: 'component' }, { 'spec.owner': { $exists: false } }], + }), + ).resolves.toEqual(['bare-e']); + }); + + it('handles nested logical operators', async () => { + await expect( + query({ + $all: [ + { kind: 'component' }, + { + $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], + }, + { $not: { 'spec.lifecycle': 'experimental' } }, + ], + }), + ).resolves.toEqual(['service-a', 'website-c']); + }); + + it('throws on top-level primitive', async () => { + await expect(query('bad-value' as any)).rejects.toThrow( + /top-level primitive values are not supported/, + ); + }); + + it('combines filter and query independently', async () => { + const q = + knex('final_entities').whereNotNull('final_entity'); + applyEntityFilterToQuery({ + filter: { key: 'kind', values: ['component'] }, + query: { 'spec.type': 'service' }, + targetQuery: q, + onEntityIdField: 'final_entities.entity_id', + knex, + }); + const result = await q.then(rows => + rows.map(row => JSON.parse(row.final_entity!).metadata.name).toSorted(), + ); + expect(result).toEqual(['service-a', 'service-b']); + }); + }, +); diff --git a/plugins/catalog-backend/src/service/request/parseEntityQuery.test.ts b/plugins/catalog-backend/src/service/request/parseEntityQuery.test.ts new file mode 100644 index 0000000000..8b6d1c21b7 --- /dev/null +++ b/plugins/catalog-backend/src/service/request/parseEntityQuery.test.ts @@ -0,0 +1,199 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { parseEntityQuery } from './parseEntityQuery'; +import { encodeCursor } from '../util'; +import { Cursor } from '../../catalog/types'; + +describe('parseEntityQuery', () => { + describe('initial request', () => { + it('returns empty result for empty request', () => { + const result = parseEntityQuery({}); + expect(result).toEqual({ + query: undefined, + orderFields: undefined, + fullTextFilter: undefined, + fields: undefined, + limit: undefined, + }); + }); + + it('parses a simple query predicate', () => { + const query = { kind: 'component' }; + const result = parseEntityQuery({ query }); + expect(result).toEqual( + expect.objectContaining({ query: { kind: 'component' } }), + ); + }); + + it('parses a complex query with $all, $any, $not', () => { + const query = { + $all: [ + { kind: 'component' }, + { $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }] }, + { $not: { 'spec.lifecycle': 'experimental' } }, + ], + }; + const result = parseEntityQuery({ query }); + expect(result).toEqual(expect.objectContaining({ query })); + }); + + it('parses query with $exists operator', () => { + const query = { 'metadata.labels.team': { $exists: true } }; + const result = parseEntityQuery({ query }); + expect(result).toEqual(expect.objectContaining({ query })); + }); + + it('parses query with $in operator', () => { + const query = { kind: { $in: ['component', 'api'] } }; + const result = parseEntityQuery({ query }); + expect(result).toEqual(expect.objectContaining({ query })); + }); + + it('passes through limit', () => { + const result = parseEntityQuery({ limit: 50 }); + expect(result).toEqual(expect.objectContaining({ limit: 50 })); + }); + + it('passes through fields', () => { + const result = parseEntityQuery({ + fields: ['metadata.name', 'kind'], + }); + expect(result).toEqual( + expect.objectContaining({ fields: ['metadata.name', 'kind'] }), + ); + }); + + it('parses orderField into orderFields', () => { + const result = parseEntityQuery({ + orderField: ['metadata.name,asc', 'metadata.namespace,desc'], + }); + expect(result).toEqual( + expect.objectContaining({ + orderFields: [ + { field: 'metadata.name', order: 'asc' }, + { field: 'metadata.namespace', order: 'desc' }, + ], + }), + ); + }); + + it('parses fullTextFilter', () => { + const result = parseEntityQuery({ + fullTextFilter: { term: 'search term', fields: ['metadata.name'] }, + }); + expect(result).toEqual( + expect.objectContaining({ + fullTextFilter: { + term: 'search term', + fields: ['metadata.name'], + }, + }), + ); + }); + + it('defaults fullTextFilter term to empty string when missing', () => { + const result = parseEntityQuery({ + fullTextFilter: {} as any, + }); + expect(result).toEqual( + expect.objectContaining({ + fullTextFilter: { term: '', fields: undefined }, + }), + ); + }); + + it('throws on invalid query predicate', () => { + expect(() => + parseEntityQuery({ query: { $invalid: true } as any }), + ).toThrow(/Invalid query/); + }); + + it('throws when query root is not an object', () => { + expect(() => parseEntityQuery({ query: 'bad' as any })).toThrow( + /Query must be an object/, + ); + }); + + it('throws on invalid orderField order value', () => { + expect(() => + parseEntityQuery({ orderField: ['metadata.name,sideways'] }), + ).toThrow(/Invalid order field order/); + }); + }); + + describe('cursor request', () => { + function makeCursor(partial: Partial): string { + const full: Cursor = { + orderFields: [], + orderFieldValues: [], + isPrevious: false, + ...partial, + }; + return encodeCursor(full); + } + + it('decodes a valid cursor', () => { + const cursor = makeCursor({ + orderFields: [{ field: 'metadata.name', order: 'asc' }], + orderFieldValues: ['test'], + isPrevious: false, + }); + const result = parseEntityQuery({ cursor }); + expect(result).toEqual( + expect.objectContaining({ + cursor: expect.objectContaining({ + orderFields: [{ field: 'metadata.name', order: 'asc' }], + orderFieldValues: ['test'], + isPrevious: false, + }), + }), + ); + }); + + it('passes through limit and fields with cursor', () => { + const cursor = makeCursor({}); + const result = parseEntityQuery({ + cursor, + limit: 25, + fields: ['metadata.name'], + }); + expect(result).toEqual( + expect.objectContaining({ + limit: 25, + fields: ['metadata.name'], + }), + ); + }); + + it('throws on empty cursor string', () => { + expect(() => parseEntityQuery({ cursor: '' })).toThrow( + /Cursor cannot be empty/, + ); + }); + + it('throws on invalid base64 cursor', () => { + expect(() => parseEntityQuery({ cursor: '!!not-valid!!' })).toThrow( + /Malformed cursor/, + ); + }); + + it('throws on invalid JSON in cursor', () => { + const cursor = Buffer.from('not json', 'utf8').toString('base64'); + expect(() => parseEntityQuery({ cursor })).toThrow(/Malformed cursor/); + }); + }); +}); From e172faf7be85179aace9922216f279c8e45cc79c Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 17 Feb 2026 11:02:14 +0100 Subject: [PATCH 11/37] feat: add support for the querying in the catalog client Signed-off-by: benjdlambert --- .../testUtils/InMemoryCatalogClient.test.ts | 77 +++++++++++++++++++ .../src/testUtils/InMemoryCatalogClient.ts | 13 ++++ 2 files changed, 90 insertions(+) diff --git a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.test.ts b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.test.ts index d867ab1d28..aa3b624050 100644 --- a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.test.ts +++ b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.test.ts @@ -683,6 +683,83 @@ describe('InMemoryCatalogClient', () => { ]); }); + it('filters by predicate query', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + query: { kind: 'CustomKind' }, + }); + expect(result.items).toEqual([entity1, entity3]); + expect(result.totalItems).toBe(2); + }); + + it('filters by predicate query with $all', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + query: { + $all: [{ kind: 'CustomKind' }, { 'spec.type': 'service' }], + }, + }); + expect(result.items).toEqual([entity1, entity3]); + }); + + it('filters by predicate query with $any', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + query: { + $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], + }, + }); + expect(result.items).toEqual([entity1, entity3, entity4]); + }); + + it('filters by predicate query with $not', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + query: { + $all: [ + { kind: 'CustomKind' }, + { $not: { 'spec.lifecycle': 'production' } }, + ], + }, + }); + expect(result.items).toEqual([]); + }); + + it('filters by predicate query with $in', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + query: { 'spec.type': { $in: ['service', 'library'] } }, + }); + expect(result.items).toEqual([entity1, entity2, entity3]); + }); + + it('filters by predicate query with $exists', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + query: { 'spec.lifecycle': { $exists: false } }, + }); + expect(result.items).toEqual([entity4]); + }); + + it('preserves query predicate through cursor pagination', async () => { + const client = new InMemoryCatalogClient({ entities }); + const page1 = await client.queryEntities({ + query: { kind: 'CustomKind' }, + orderFields: { field: 'metadata.name', order: 'asc' }, + limit: 1, + }); + expect(page1.items.map(e => e.metadata.name)).toEqual(['e1']); + expect(page1.totalItems).toBe(2); + expect(page1.pageInfo.nextCursor).toBeDefined(); + + const page2 = await client.queryEntities({ + cursor: page1.pageInfo.nextCursor!, + limit: 1, + }); + expect(page2.items.map(e => e.metadata.name)).toEqual(['e3']); + expect(page2.pageInfo.nextCursor).toBeUndefined(); + }); + it('throws InputError for invalid cursor', async () => { const client = new InMemoryCatalogClient({ entities }); await expect( diff --git a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts index e64706ff8d..5ffaafa16f 100644 --- a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts +++ b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts @@ -51,6 +51,7 @@ import { NotFoundError, NotImplementedError, } from '@backstage/errors'; +import { filterPredicateToFilterFunction } from '@backstage/filter-predicates'; import lodash from 'lodash'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { traverse } from '../../../../plugins/catalog-backend/src/database/operations/stitcher/buildEntitySearch'; @@ -373,6 +374,7 @@ export class InMemoryCatalogClient implements CatalogApi { ): Promise { // Decode query parameters from cursor or from the request directly let filter: EntityFilterQuery | undefined; + let query: Record | undefined; let orderFields: EntityOrderQuery | undefined; let fullTextFilter: { term: string; fields?: string[] } | undefined; let offset: number; @@ -386,12 +388,17 @@ export class InMemoryCatalogClient implements CatalogApi { throw new InputError('Invalid cursor'); } filter = deserializeFilter(c.filter as any[]); + query = c.query as Record | undefined; orderFields = c.orderFields as EntityOrderQuery | undefined; fullTextFilter = c.fullTextFilter as typeof fullTextFilter; offset = c.offset as number; limit = request.limit; } else { filter = request?.filter; + query = + request?.query && typeof request.query === 'object' + ? (request.query as Record) + : undefined; orderFields = request?.orderFields; fullTextFilter = request?.fullTextFilter; offset = request?.offset ?? 0; @@ -401,6 +408,11 @@ export class InMemoryCatalogClient implements CatalogApi { // Apply filter let items = this.#entities.filter(createFilter(filter)); + // Apply predicate-based query filter + if (query) { + items = items.filter(filterPredicateToFilterFunction(query)); + } + // Apply full-text filter, defaulting to the sort field or metadata.uid if (fullTextFilter) { const orderFieldsList = orderFields ? [orderFields].flat() : []; @@ -432,6 +444,7 @@ export class InMemoryCatalogClient implements CatalogApi { const cursorBase = { filter: serializeFilter(filter), + query, orderFields, fullTextFilter, totalItems, From c01f24006972acf5cdc0908692b6f870d460518e Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 17 Feb 2026 12:00:58 +0100 Subject: [PATCH 12/37] chore: fix typescript Signed-off-by: benjdlambert --- .../src/testUtils/InMemoryCatalogClient.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts index 5ffaafa16f..0a4dc57386 100644 --- a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts +++ b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts @@ -51,7 +51,10 @@ import { NotFoundError, NotImplementedError, } from '@backstage/errors'; -import { filterPredicateToFilterFunction } from '@backstage/filter-predicates'; +import { + FilterPredicate, + filterPredicateToFilterFunction, +} from '@backstage/filter-predicates'; import lodash from 'lodash'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { traverse } from '../../../../plugins/catalog-backend/src/database/operations/stitcher/buildEntitySearch'; @@ -374,7 +377,7 @@ export class InMemoryCatalogClient implements CatalogApi { ): Promise { // Decode query parameters from cursor or from the request directly let filter: EntityFilterQuery | undefined; - let query: Record | undefined; + let query: FilterPredicate | undefined; let orderFields: EntityOrderQuery | undefined; let fullTextFilter: { term: string; fields?: string[] } | undefined; let offset: number; @@ -388,17 +391,14 @@ export class InMemoryCatalogClient implements CatalogApi { throw new InputError('Invalid cursor'); } filter = deserializeFilter(c.filter as any[]); - query = c.query as Record | undefined; + query = c.query as FilterPredicate | undefined; orderFields = c.orderFields as EntityOrderQuery | undefined; fullTextFilter = c.fullTextFilter as typeof fullTextFilter; offset = c.offset as number; limit = request.limit; } else { filter = request?.filter; - query = - request?.query && typeof request.query === 'object' - ? (request.query as Record) - : undefined; + query = request?.query; orderFields = request?.orderFields; fullTextFilter = request?.fullTextFilter; offset = request?.offset ?? 0; From f0876da49f6a98baa378b28a9e67218f15528d4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 10:35:15 +0100 Subject: [PATCH 13/37] use a structured orderBy for ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../catalog-client/src/CatalogClient.test.ts | 12 ++++--- packages/catalog-client/src/CatalogClient.ts | 4 +-- .../QueryEntitiesByPredicateRequest.model.ts | 3 +- ...iesByPredicateRequestOrderByInner.model.ts | 34 +++++++++++++++++++ .../schema/openapi/generated/models/index.ts | 1 + .../catalog-backend/src/schema/openapi.yaml | 15 ++++++-- .../QueryEntitiesByPredicateRequest.model.ts | 3 +- ...iesByPredicateRequestOrderByInner.model.ts | 34 +++++++++++++++++++ .../schema/openapi/generated/models/index.ts | 1 + .../src/schema/openapi/generated/router.ts | 14 ++++++-- .../service/request/parseEntityQuery.test.ts | 14 +++++--- .../src/service/request/parseEntityQuery.ts | 11 +++--- 12 files changed, 122 insertions(+), 24 deletions(-) create mode 100644 packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestOrderByInner.model.ts create mode 100644 plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestOrderByInner.model.ts diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index 4ee93c0cba..aee10cd3f2 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -751,9 +751,11 @@ describe('CatalogClient', () => { expect(mockedEndpoint).toHaveBeenCalledTimes(1); }); - it('should send orderFields with correct format (field,order)', async () => { + it('should send orderFields with correct format', async () => { const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { - expect(req.body.orderField).toEqual(['metadata.name,asc']); + expect(req.body.orderBy).toEqual([ + { field: 'metadata.name', order: 'asc' }, + ]); return res(ctx.json(defaultResponse)); }); @@ -769,9 +771,9 @@ describe('CatalogClient', () => { it('should send multiple orderFields with correct format', async () => { const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { - expect(req.body.orderField).toEqual([ - 'metadata.name,asc', - 'spec.type,desc', + expect(req.body.orderBy).toEqual([ + { field: 'metadata.name', order: 'asc' }, + { field: 'spec.type', order: 'desc' }, ]); return res(ctx.json(defaultResponse)); }); diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 51d57f715e..a6f47933f2 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -368,9 +368,7 @@ export class CatalogClient implements CatalogApi { body.limit = limit; } if (orderFields !== undefined) { - body.orderField = ( - Array.isArray(orderFields) ? orderFields : [orderFields] - ).map(({ field, order }) => `${field},${order}`); + body.orderBy = [orderFields].flat(); } if (fullTextFilter) { body.fullTextFilter = fullTextFilter; diff --git a/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts index bdcd854a5c..818a780b4a 100644 --- a/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts @@ -19,6 +19,7 @@ // ****************************************************************** import { QueryEntitiesByPredicateRequestFullTextFilter } from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model'; +import { QueryEntitiesByPredicateRequestOrderByInner } from '../models/QueryEntitiesByPredicateRequestOrderByInner.model'; /** * @public @@ -26,7 +27,7 @@ import { QueryEntitiesByPredicateRequestFullTextFilter } from '../models/QueryEn export interface QueryEntitiesByPredicateRequest { cursor?: string; limit?: number; - orderField?: Array; + orderBy?: Array; fullTextFilter?: QueryEntitiesByPredicateRequestFullTextFilter; fields?: Array; /** diff --git a/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestOrderByInner.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestOrderByInner.model.ts new file mode 100644 index 0000000000..373ca9715f --- /dev/null +++ b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestOrderByInner.model.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export interface QueryEntitiesByPredicateRequestOrderByInner { + field: string; + order: QueryEntitiesByPredicateRequestOrderByInnerOrderEnum; +} + +/** + * @public + */ +export type QueryEntitiesByPredicateRequestOrderByInnerOrderEnum = + | 'asc' + | 'desc'; diff --git a/packages/catalog-client/src/schema/openapi/generated/models/index.ts b/packages/catalog-client/src/schema/openapi/generated/models/index.ts index 889b5968fa..9461ac8d28 100644 --- a/packages/catalog-client/src/schema/openapi/generated/models/index.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/index.ts @@ -47,6 +47,7 @@ export * from '../models/ModelError.model'; export * from '../models/NullableEntity.model'; export * from '../models/QueryEntitiesByPredicateRequest.model'; export * from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model'; +export * from '../models/QueryEntitiesByPredicateRequestOrderByInner.model'; export * from '../models/RecursivePartialEntity.model'; export * from '../models/RecursivePartialEntityMeta.model'; export * from '../models/RecursivePartialEntityMetaAllOf.model'; diff --git a/plugins/catalog-backend/src/schema/openapi.yaml b/plugins/catalog-backend/src/schema/openapi.yaml index f44ac026e4..41738c1148 100644 --- a/plugins/catalog-backend/src/schema/openapi.yaml +++ b/plugins/catalog-backend/src/schema/openapi.yaml @@ -1125,10 +1125,21 @@ paths: type: string limit: type: number - orderField: + orderBy: type: array items: - type: string + type: object + required: + - field + - order + properties: + field: + type: string + order: + type: string + enum: + - asc + - desc fullTextFilter: type: object properties: diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts index bdcd854a5c..818a780b4a 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts @@ -19,6 +19,7 @@ // ****************************************************************** import { QueryEntitiesByPredicateRequestFullTextFilter } from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model'; +import { QueryEntitiesByPredicateRequestOrderByInner } from '../models/QueryEntitiesByPredicateRequestOrderByInner.model'; /** * @public @@ -26,7 +27,7 @@ import { QueryEntitiesByPredicateRequestFullTextFilter } from '../models/QueryEn export interface QueryEntitiesByPredicateRequest { cursor?: string; limit?: number; - orderField?: Array; + orderBy?: Array; fullTextFilter?: QueryEntitiesByPredicateRequestFullTextFilter; fields?: Array; /** diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestOrderByInner.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestOrderByInner.model.ts new file mode 100644 index 0000000000..373ca9715f --- /dev/null +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequestOrderByInner.model.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// ****************************************************************** +// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. * +// ****************************************************************** + +/** + * @public + */ +export interface QueryEntitiesByPredicateRequestOrderByInner { + field: string; + order: QueryEntitiesByPredicateRequestOrderByInnerOrderEnum; +} + +/** + * @public + */ +export type QueryEntitiesByPredicateRequestOrderByInnerOrderEnum = + | 'asc' + | 'desc'; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts index 889b5968fa..9461ac8d28 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/index.ts @@ -47,6 +47,7 @@ export * from '../models/ModelError.model'; export * from '../models/NullableEntity.model'; export * from '../models/QueryEntitiesByPredicateRequest.model'; export * from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model'; +export * from '../models/QueryEntitiesByPredicateRequestOrderByInner.model'; export * from '../models/RecursivePartialEntity.model'; export * from '../models/RecursivePartialEntityMeta.model'; export * from '../models/RecursivePartialEntityMetaAllOf.model'; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/router.ts b/plugins/catalog-backend/src/schema/openapi/generated/router.ts index 86ddeddf61..ed9536d6b9 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/router.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/router.ts @@ -1269,10 +1269,20 @@ export const spec = { limit: { type: 'number', }, - orderField: { + orderBy: { type: 'array', items: { - type: 'string', + type: 'object', + required: ['field', 'order'], + properties: { + field: { + type: 'string', + }, + order: { + type: 'string', + enum: ['asc', 'desc'], + }, + }, }, }, fullTextFilter: { diff --git a/plugins/catalog-backend/src/service/request/parseEntityQuery.test.ts b/plugins/catalog-backend/src/service/request/parseEntityQuery.test.ts index 8b6d1c21b7..b95c4c6c0e 100644 --- a/plugins/catalog-backend/src/service/request/parseEntityQuery.test.ts +++ b/plugins/catalog-backend/src/service/request/parseEntityQuery.test.ts @@ -77,9 +77,12 @@ describe('parseEntityQuery', () => { ); }); - it('parses orderField into orderFields', () => { + it('parses orderBy into orderFields', () => { const result = parseEntityQuery({ - orderField: ['metadata.name,asc', 'metadata.namespace,desc'], + orderBy: [ + { field: 'metadata.name', order: 'asc' }, + { field: 'metadata.namespace', order: 'desc' }, + ], }); expect(result).toEqual( expect.objectContaining({ @@ -128,9 +131,12 @@ describe('parseEntityQuery', () => { ); }); - it('throws on invalid orderField order value', () => { + it('throws on invalid orderBy order value', () => { expect(() => - parseEntityQuery({ orderField: ['metadata.name,sideways'] }), + parseEntityQuery({ + // @ts-expect-error - invalid order value + orderBy: [{ field: 'metadata.name', order: 'sideways' }], + }), ).toThrow(/Invalid order field order/); }); }); diff --git a/plugins/catalog-backend/src/service/request/parseEntityQuery.ts b/plugins/catalog-backend/src/service/request/parseEntityQuery.ts index 5ad505b8a1..c33492e740 100644 --- a/plugins/catalog-backend/src/service/request/parseEntityQuery.ts +++ b/plugins/catalog-backend/src/service/request/parseEntityQuery.ts @@ -41,17 +41,16 @@ function isSupportedFilterPredicateRoot( } function parseOrderFields( - orderField: string[] | undefined, + orderField: Array<{ field: string; order: string }> | undefined, ): EntityOrder[] | undefined { if (!orderField?.length) { return undefined; } - return orderField.map(entry => { - const [field, order] = entry.split(','); - if (order !== undefined && order !== 'asc' && order !== 'desc') { + return orderField.map(({ field, order }) => { + if (order !== 'asc' && order !== 'desc') { throw new InputError('Invalid order field order, must be asc or desc'); } - return { field, order: order as 'asc' | 'desc' }; + return { field, order }; }); } @@ -98,7 +97,7 @@ export function parseEntityQuery( query = result.data; } - const orderFields = parseOrderFields(request.orderField); + const orderFields = parseOrderFields(request.orderBy); return { query, From f076495eea5bdf960a250251ccc7a4acd90db327 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 10:48:44 +0100 Subject: [PATCH 14/37] add todo about cursor introspection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- packages/catalog-client/src/CatalogClient.ts | 34 +++++++++----------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index a6f47933f2..fa8a88c1ca 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -53,6 +53,7 @@ import { } from './utils'; import { DefaultApiClient, + GetEntitiesByQuery, GetLocationsByQueryRequest, QueryEntitiesByPredicateRequest, TypedResponse, @@ -271,35 +272,33 @@ export class CatalogClient implements CatalogApi { request: QueryEntitiesRequest = {}, options?: CatalogRequestOptions, ): Promise { + const isInitialRequest = isQueryEntitiesInitialRequest(request); + // Validate that filter and query are mutually exclusive - if ( - isQueryEntitiesInitialRequest(request) && - request.filter && - request.query - ) { + if (isInitialRequest && request.filter && request.query) { throw new Error( 'Cannot specify both "filter" and "query" in the same request. Use "filter" for traditional key-value filtering or "query" for predicate-based filtering.', ); } // Route to POST endpoint if query predicate is provided (initial request) - if (isQueryEntitiesInitialRequest(request) && request.query) { + if (isInitialRequest && request.query) { return this.queryEntitiesByPredicate(request, options); } // Route to POST endpoint if cursor contains a query predicate (pagination) - if ( - !isQueryEntitiesInitialRequest(request) && - cursorContainsQuery(request.cursor) - ) { + // TODO(freben): It's costly and non-opaque to have to introspect the cursor + // like this. It should be refactored in the future to not need this. + // Suggestion: make the GET and POST endpoints understand the same cursor + // format, and pick which one to call ONLY based on whether the cursor size + // risks hitting url length limits + if (!isInitialRequest && cursorContainsQuery(request.cursor)) { return this.queryEntitiesByPredicate(request, options); } - const params: Partial< - Parameters[0]['query'] - > = {}; + const params: Partial = {}; - if (isQueryEntitiesInitialRequest(request)) { + if (isInitialRequest) { const { fields = [], filter, @@ -357,7 +356,7 @@ export class CatalogClient implements CatalogApi { request: QueryEntitiesRequest, options?: CatalogRequestOptions, ): Promise { - const body: Record = {}; + const body: QueryEntitiesByPredicateRequest = {}; if (isQueryEntitiesInitialRequest(request)) { const { query, limit, orderFields, fullTextFilter, fields } = request; @@ -387,10 +386,7 @@ export class CatalogClient implements CatalogApi { } const res = await this.requestRequired( - await this.apiClient.queryEntitiesByPredicate( - { body: body as unknown as QueryEntitiesByPredicateRequest }, - options, - ), + await this.apiClient.queryEntitiesByPredicate({ body }, options), ); return { From f3b3b1dafb8eddc77f0506b2bf949a9c9568bab0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 10:55:24 +0100 Subject: [PATCH 15/37] implement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../applyPredicateEntityFilterToQuery.test.ts | 12 +++++++++++ .../applyPredicateEntityFilterToQuery.ts | 21 ++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts index 63b0bb9b24..2f216fd219 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts @@ -181,6 +181,18 @@ describe.each(databases.eachSupportedId())( ).resolves.toEqual(['bare-e']); }); + it('filters with $hasPrefix', async () => { + await expect( + query({ 'metadata.name': { $hasPrefix: 'service' } }), + ).resolves.toEqual(['service-a', 'service-b']); + }); + + it('filters with $hasPrefix case-insensitively', async () => { + await expect( + query({ 'metadata.name': { $hasPrefix: 'Service' } }), + ).resolves.toEqual(['service-a', 'service-b']); + }); + it('handles nested logical operators', async () => { await expect( query({ diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index db39a2195a..d077bd1460 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -61,6 +61,12 @@ function isInValue( return typeof value === 'object' && value !== null && '$in' in value; } +function isHasPrefixValue( + value: FilterPredicateValue, +): value is { $hasPrefix: string } { + return typeof value === 'object' && value !== null && '$hasPrefix' in value; +} + function isFieldExpression(filter: FilterPredicate): boolean { if (typeof filter !== 'object' || filter === null) { return false; @@ -202,6 +208,19 @@ function applyPredicateInStrategy( .where({ key: normalizedKey }) .whereIn('value', values); this.andWhere(onEntityIdField, 'in', matchQuery); + } else if (isHasPrefixValue(value)) { + // Handle $hasPrefix + const prefix = value.$hasPrefix.toLowerCase(); + const escaped = prefix.replace(/[%_\\]/g, c => `\\${c}`); + const matchQuery = knex('search') + .select('search.entity_id') + .where({ key: normalizedKey }) + .andWhereRaw('?? like ? escape ?', [ + 'value', + `${escaped}%`, + '\\', + ]); + this.andWhere(onEntityIdField, 'in', matchQuery); } else if (isPrimitive(value)) { // Handle direct value match const matchQuery = knex('search') @@ -214,7 +233,7 @@ function applyPredicateInStrategy( } else { // Reject unsupported/invalid predicate values throw new InputError( - `Invalid filter predicate value for field "${key}": expected a primitive value, $exists, or $in operator, but got ${JSON.stringify( + `Invalid filter predicate value for field "${key}": expected a primitive value, $exists, $in, or $hasPrefix operator, but got ${JSON.stringify( value, )}`, ); From 02443480b3f22752d2e4d11f7dbe642514f1bdfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 10:56:31 +0100 Subject: [PATCH 16/37] locale lowercase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../request/applyPredicateEntityFilterToQuery.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index d077bd1460..91b29b6ce1 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -187,7 +187,7 @@ function applyPredicateInStrategy( return targetQuery[negate ? 'andWhereNot' : 'andWhere']( function fieldFilter() { for (const [key, value] of Object.entries(filter)) { - const normalizedKey = key.toLowerCase(); + const normalizedKey = key.toLocaleLowerCase('en-US'); if (isExistsValue(value)) { // Handle $exists @@ -202,7 +202,9 @@ function applyPredicateInStrategy( } } else if (isInValue(value)) { // Handle $in - const values = value.$in.map(v => String(v).toLowerCase()); + const values = value.$in.map(v => + String(v).toLocaleLowerCase('en-US'), + ); const matchQuery = knex('search') .select('search.entity_id') .where({ key: normalizedKey }) @@ -210,7 +212,7 @@ function applyPredicateInStrategy( this.andWhere(onEntityIdField, 'in', matchQuery); } else if (isHasPrefixValue(value)) { // Handle $hasPrefix - const prefix = value.$hasPrefix.toLowerCase(); + const prefix = value.$hasPrefix.toLocaleLowerCase('en-US'); const escaped = prefix.replace(/[%_\\]/g, c => `\\${c}`); const matchQuery = knex('search') .select('search.entity_id') @@ -227,7 +229,7 @@ function applyPredicateInStrategy( .select('search.entity_id') .where({ key: normalizedKey, - value: String(value).toLowerCase(), + value: String(value).toLocaleLowerCase('en-US'), }); this.andWhere(onEntityIdField, 'in', matchQuery); } else { From fa4e24d75265bc3267815f82cc87e887f08dd741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 11:04:28 +0100 Subject: [PATCH 17/37] remove the unnecessary inversion boolean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../applyPredicateEntityFilterToQuery.ts | 200 ++++++++---------- 1 file changed, 86 insertions(+), 114 deletions(-) diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index 91b29b6ce1..eb6e65604a 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -115,60 +115,55 @@ function isFieldExpression(filter: FilterPredicate): boolean { * ``` */ -function applyPredicateInStrategy( - filter: FilterPredicate, - targetQuery: Knex.QueryBuilder, - onEntityIdField: string, - knex: Knex, - negate: boolean, -): Knex.QueryBuilder { +export function applyPredicateEntityFilterToQuery(options: { + filter: FilterPredicate; + targetQuery: Knex.QueryBuilder; + onEntityIdField: string; + knex: Knex; +}): Knex.QueryBuilder { + const { filter, targetQuery, onEntityIdField, knex } = options; // Handle $not if (isNotPredicate(filter)) { - return applyPredicateInStrategy( - filter.$not, - targetQuery, - onEntityIdField, - knex, - !negate, + return targetQuery.andWhereNot(subQuery => + applyPredicateEntityFilterToQuery({ + filter: filter.$not, + targetQuery: subQuery, + onEntityIdField, + knex, + }), ); } // Handle $all (AND) if (isAllPredicate(filter)) { - return targetQuery[negate ? 'andWhereNot' : 'andWhere']( - function allFilter() { - for (const subFilter of filter.$all) { - this.andWhere(subQuery => - applyPredicateInStrategy( - subFilter, - subQuery, - onEntityIdField, - knex, - false, - ), - ); - } - }, - ); + return targetQuery.andWhere(function allFilter() { + for (const subFilter of filter.$all) { + this.andWhere(subQuery => + applyPredicateEntityFilterToQuery({ + filter: subFilter, + targetQuery: subQuery, + onEntityIdField, + knex, + }), + ); + } + }); } // Handle $any (OR) if (isAnyPredicate(filter)) { - return targetQuery[negate ? 'andWhereNot' : 'andWhere']( - function anyFilter() { - for (const subFilter of filter.$any) { - this.orWhere(subQuery => - applyPredicateInStrategy( - subFilter, - subQuery, - onEntityIdField, - knex, - false, - ), - ); - } - }, - ); + return targetQuery.andWhere(function anyFilter() { + for (const subFilter of filter.$any) { + this.orWhere(subQuery => + applyPredicateEntityFilterToQuery({ + filter: subFilter, + targetQuery: subQuery, + onEntityIdField, + knex, + }), + ); + } + }); } // Reject primitives at the top level. Matching by value without specifying @@ -184,83 +179,60 @@ function applyPredicateInStrategy( // Handle field expressions like { "kind": "component" } or { "spec.type": { "$in": ["service", "website"] } } if (isFieldExpression(filter)) { - return targetQuery[negate ? 'andWhereNot' : 'andWhere']( - function fieldFilter() { - for (const [key, value] of Object.entries(filter)) { - const normalizedKey = key.toLocaleLowerCase('en-US'); + return targetQuery.andWhere(function fieldFilter() { + for (const [key, value] of Object.entries(filter)) { + const normalizedKey = key.toLocaleLowerCase('en-US'); - if (isExistsValue(value)) { - // Handle $exists - const existsQuery = knex('search') - .select('search.entity_id') - .where({ key: normalizedKey }); + if (isExistsValue(value)) { + // Handle $exists + const existsQuery = knex('search') + .select('search.entity_id') + .where({ key: normalizedKey }); - if (value.$exists) { - this.andWhere(onEntityIdField, 'in', existsQuery); - } else { - this.andWhere(onEntityIdField, 'not in', existsQuery); - } - } else if (isInValue(value)) { - // Handle $in - const values = value.$in.map(v => - String(v).toLocaleLowerCase('en-US'), - ); - const matchQuery = knex('search') - .select('search.entity_id') - .where({ key: normalizedKey }) - .whereIn('value', values); - this.andWhere(onEntityIdField, 'in', matchQuery); - } else if (isHasPrefixValue(value)) { - // Handle $hasPrefix - const prefix = value.$hasPrefix.toLocaleLowerCase('en-US'); - const escaped = prefix.replace(/[%_\\]/g, c => `\\${c}`); - const matchQuery = knex('search') - .select('search.entity_id') - .where({ key: normalizedKey }) - .andWhereRaw('?? like ? escape ?', [ - 'value', - `${escaped}%`, - '\\', - ]); - this.andWhere(onEntityIdField, 'in', matchQuery); - } else if (isPrimitive(value)) { - // Handle direct value match - const matchQuery = knex('search') - .select('search.entity_id') - .where({ - key: normalizedKey, - value: String(value).toLocaleLowerCase('en-US'), - }); - this.andWhere(onEntityIdField, 'in', matchQuery); + if (value.$exists) { + this.andWhere(onEntityIdField, 'in', existsQuery); } else { - // Reject unsupported/invalid predicate values - throw new InputError( - `Invalid filter predicate value for field "${key}": expected a primitive value, $exists, $in, or $hasPrefix operator, but got ${JSON.stringify( - value, - )}`, - ); + this.andWhere(onEntityIdField, 'not in', existsQuery); } + } else if (isInValue(value)) { + // Handle $in + const values = value.$in.map(v => + String(v).toLocaleLowerCase('en-US'), + ); + const matchQuery = knex('search') + .select('search.entity_id') + .where({ key: normalizedKey }) + .whereIn('value', values); + this.andWhere(onEntityIdField, 'in', matchQuery); + } else if (isHasPrefixValue(value)) { + // Handle $hasPrefix + const prefix = value.$hasPrefix.toLocaleLowerCase('en-US'); + const escaped = prefix.replace(/[%_\\]/g, c => `\\${c}`); + const matchQuery = knex('search') + .select('search.entity_id') + .where({ key: normalizedKey }) + .andWhereRaw('?? like ? escape ?', ['value', `${escaped}%`, '\\']); + this.andWhere(onEntityIdField, 'in', matchQuery); + } else if (isPrimitive(value)) { + // Handle direct value match + const matchQuery = knex('search') + .select('search.entity_id') + .where({ + key: normalizedKey, + value: String(value).toLocaleLowerCase('en-US'), + }); + this.andWhere(onEntityIdField, 'in', matchQuery); + } else { + // Reject unsupported/invalid predicate values + throw new InputError( + `Invalid filter predicate value for field "${key}": expected a primitive value, $exists, $in, or $hasPrefix operator, but got ${JSON.stringify( + value, + )}`, + ); } - }, - ); + } + }); } return targetQuery; } - -export function applyPredicateEntityFilterToQuery(options: { - filter: FilterPredicate; - targetQuery: Knex.QueryBuilder; - onEntityIdField: string; - knex: Knex; -}): Knex.QueryBuilder { - const { filter, targetQuery, onEntityIdField, knex } = options; - - return applyPredicateInStrategy( - filter, - targetQuery, - onEntityIdField, - knex, - false, - ); -} From b218fb07df3c5475e0dc50d4db2078a756c446c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 11:30:45 +0100 Subject: [PATCH 18/37] arrow functions and simplify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../applyPredicateEntityFilterToQuery.test.ts | 18 ++ .../applyPredicateEntityFilterToQuery.ts | 265 ++++++++---------- 2 files changed, 141 insertions(+), 142 deletions(-) diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts index 2f216fd219..21e5d2c37a 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts @@ -116,6 +116,16 @@ describe.each(databases.eachSupportedId())( ); } + it('matches everything for empty field expression', async () => { + await expect(query({})).resolves.toEqual([ + 'api-d', + 'bare-e', + 'service-a', + 'service-b', + 'website-c', + ]); + }); + it('filters by direct field value', async () => { await expect(query({ kind: 'component' })).resolves.toEqual([ 'bare-e', @@ -140,6 +150,10 @@ describe.each(databases.eachSupportedId())( ).resolves.toEqual(['service-a', 'service-b']); }); + it('returns nothing for empty $all', async () => { + await expect(query({ $all: [] })).resolves.toEqual([]); + }); + it('filters with $any', async () => { await expect( query({ @@ -148,6 +162,10 @@ describe.each(databases.eachSupportedId())( ).resolves.toEqual(['service-a', 'service-b', 'website-c']); }); + it('returns nothing for empty $any', async () => { + await expect(query({ $any: [] })).resolves.toEqual([]); + }); + it('filters with $not', async () => { await expect( query({ diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index eb6e65604a..88b064866d 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -23,24 +23,6 @@ import { InputError } from '@backstage/errors'; import { Knex } from 'knex'; import { DbSearchRow } from '../../database/tables'; -function isAllPredicate( - filter: FilterPredicate, -): filter is { $all: FilterPredicate[] } { - return typeof filter === 'object' && filter !== null && '$all' in filter; -} - -function isAnyPredicate( - filter: FilterPredicate, -): filter is { $any: FilterPredicate[] } { - return typeof filter === 'object' && filter !== null && '$any' in filter; -} - -function isNotPredicate( - filter: FilterPredicate, -): filter is { $not: FilterPredicate } { - return typeof filter === 'object' && filter !== null && '$not' in filter; -} - function isPrimitive(value: unknown): value is FilterPredicatePrimitive { return ( typeof value === 'string' || @@ -49,30 +31,8 @@ function isPrimitive(value: unknown): value is FilterPredicatePrimitive { ); } -function isExistsValue( - value: FilterPredicateValue, -): value is { $exists: boolean } { - return typeof value === 'object' && value !== null && '$exists' in value; -} - -function isInValue( - value: FilterPredicateValue, -): value is { $in: FilterPredicatePrimitive[] } { - return typeof value === 'object' && value !== null && '$in' in value; -} - -function isHasPrefixValue( - value: FilterPredicateValue, -): value is { $hasPrefix: string } { - return typeof value === 'object' && value !== null && '$hasPrefix' in value; -} - -function isFieldExpression(filter: FilterPredicate): boolean { - if (typeof filter !== 'object' || filter === null) { - return false; - } - // Not a logical operator - return !('$all' in filter || '$any' in filter || '$not' in filter); +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); } /** @@ -114,7 +74,6 @@ function isFieldExpression(filter: FilterPredicate): boolean { * ORDER BY final_entities.entity_ref ASC; * ``` */ - export function applyPredicateEntityFilterToQuery(options: { filter: FilterPredicate; targetQuery: Knex.QueryBuilder; @@ -122,53 +81,9 @@ export function applyPredicateEntityFilterToQuery(options: { knex: Knex; }): Knex.QueryBuilder { const { filter, targetQuery, onEntityIdField, knex } = options; - // Handle $not - if (isNotPredicate(filter)) { - return targetQuery.andWhereNot(subQuery => - applyPredicateEntityFilterToQuery({ - filter: filter.$not, - targetQuery: subQuery, - onEntityIdField, - knex, - }), - ); - } - // Handle $all (AND) - if (isAllPredicate(filter)) { - return targetQuery.andWhere(function allFilter() { - for (const subFilter of filter.$all) { - this.andWhere(subQuery => - applyPredicateEntityFilterToQuery({ - filter: subFilter, - targetQuery: subQuery, - onEntityIdField, - knex, - }), - ); - } - }); - } - - // Handle $any (OR) - if (isAnyPredicate(filter)) { - return targetQuery.andWhere(function anyFilter() { - for (const subFilter of filter.$any) { - this.orWhere(subQuery => - applyPredicateEntityFilterToQuery({ - filter: subFilter, - targetQuery: subQuery, - onEntityIdField, - knex, - }), - ); - } - }); - } - - // Reject primitives at the top level. Matching by value without specifying - // a field key is ambiguous and should not be allowed. - if (isPrimitive(filter)) { + // We do not support top-level primitives; all matching happens through objects + if (!filter || typeof filter !== 'object' || Array.isArray(filter)) { throw new InputError( `Invalid filter predicate: top-level primitive values are not supported. ` + `Wrap the value in a field expression, e.g. { "kind": ${JSON.stringify( @@ -177,62 +92,128 @@ export function applyPredicateEntityFilterToQuery(options: { ); } - // Handle field expressions like { "kind": "component" } or { "spec.type": { "$in": ["service", "website"] } } - if (isFieldExpression(filter)) { - return targetQuery.andWhere(function fieldFilter() { - for (const [key, value] of Object.entries(filter)) { - const normalizedKey = key.toLocaleLowerCase('en-US'); + if ('$not' in filter) { + return targetQuery.andWhereNot(inner => + applyPredicateEntityFilterToQuery({ + filter: filter.$not, + targetQuery: inner, + onEntityIdField, + knex, + }), + ); + } - if (isExistsValue(value)) { - // Handle $exists - const existsQuery = knex('search') - .select('search.entity_id') - .where({ key: normalizedKey }); - - if (value.$exists) { - this.andWhere(onEntityIdField, 'in', existsQuery); - } else { - this.andWhere(onEntityIdField, 'not in', existsQuery); - } - } else if (isInValue(value)) { - // Handle $in - const values = value.$in.map(v => - String(v).toLocaleLowerCase('en-US'), - ); - const matchQuery = knex('search') - .select('search.entity_id') - .where({ key: normalizedKey }) - .whereIn('value', values); - this.andWhere(onEntityIdField, 'in', matchQuery); - } else if (isHasPrefixValue(value)) { - // Handle $hasPrefix - const prefix = value.$hasPrefix.toLocaleLowerCase('en-US'); - const escaped = prefix.replace(/[%_\\]/g, c => `\\${c}`); - const matchQuery = knex('search') - .select('search.entity_id') - .where({ key: normalizedKey }) - .andWhereRaw('?? like ? escape ?', ['value', `${escaped}%`, '\\']); - this.andWhere(onEntityIdField, 'in', matchQuery); - } else if (isPrimitive(value)) { - // Handle direct value match - const matchQuery = knex('search') - .select('search.entity_id') - .where({ - key: normalizedKey, - value: String(value).toLocaleLowerCase('en-US'), - }); - this.andWhere(onEntityIdField, 'in', matchQuery); - } else { - // Reject unsupported/invalid predicate values - throw new InputError( - `Invalid filter predicate value for field "${key}": expected a primitive value, $exists, $in, or $hasPrefix operator, but got ${JSON.stringify( - value, - )}`, - ); - } + if ('$all' in filter) { + if (filter.$all.length === 0) { + return targetQuery.andWhereRaw('1 = 0'); + } + return targetQuery.andWhere(outer => { + for (const subFilter of filter.$all) { + outer.andWhere(inner => + applyPredicateEntityFilterToQuery({ + filter: subFilter, + targetQuery: inner, + onEntityIdField, + knex, + }), + ); } }); } - return targetQuery; + if ('$any' in filter) { + if (filter.$any.length === 0) { + return targetQuery.andWhereRaw('1 = 0'); + } + return targetQuery.andWhere(outer => { + for (const subFilter of filter.$any) { + outer.orWhere(inner => + applyPredicateEntityFilterToQuery({ + filter: subFilter, + targetQuery: inner, + onEntityIdField, + knex, + }), + ); + } + }); + } + + // Treat the filter as a field expression like { "kind": "component" } or { "spec.type": { "$in": ["service", "website"] } } + if (Object.keys(filter).length === 0) { + return targetQuery; + } + return targetQuery.andWhere(inner => { + for (const [keyAnyCase, value] of Object.entries(filter)) { + applyFieldCondition({ + key: keyAnyCase.toLocaleLowerCase('en-US'), + value, + targetQuery: inner, + onEntityIdField, + knex, + }); + } + }); +} + +/** + * Applies a single { key: value } filter to the target query. + */ +function applyFieldCondition(options: { + key: string; + value: FilterPredicateValue; + targetQuery: Knex.QueryBuilder; + onEntityIdField: string; + knex: Knex; +}): Knex.QueryBuilder { + const { key, value, targetQuery, onEntityIdField, knex } = options; + + if (isPrimitive(value)) { + const matchQuery = knex('search') + .select('search.entity_id') + .where({ + key, + value: String(value).toLocaleLowerCase('en-US'), + }); + return targetQuery.andWhere(onEntityIdField, 'in', matchQuery); + } + + if (isObject(value)) { + if ('$exists' in value) { + const existsQuery = knex('search') + .select('search.entity_id') + .where({ key }); + if (value.$exists) { + return targetQuery.andWhere(onEntityIdField, 'in', existsQuery); + } + return targetQuery.andWhere(onEntityIdField, 'not in', existsQuery); + } + + if ('$in' in value) { + const values = (value.$in as FilterPredicatePrimitive[]).map(v => + String(v).toLocaleLowerCase('en-US'), + ); + const matchQuery = knex('search') + .select('search.entity_id') + .where({ key }) + .whereIn('value', values); + return targetQuery.andWhere(onEntityIdField, 'in', matchQuery); + } + + if ('$hasPrefix' in value) { + const prefix = (value.$hasPrefix as string).toLocaleLowerCase('en-US'); + const escaped = prefix.replace(/[%_\\]/g, c => `\\${c}`); + const matchQuery = knex('search') + .select('search.entity_id') + .where({ key }) + .andWhereRaw('?? like ? escape ?', ['value', `${escaped}%`, '\\']); + return targetQuery.andWhere(onEntityIdField, 'in', matchQuery); + } + } + + throw new InputError( + `Invalid filter predicate value for field "${key}": expected a primitive value, $exists, $in, or $hasPrefix operator, but got ${JSON.stringify( + value, + )}`, + ); } From 59d458202033be0d15c742a6559be75155c6a441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 11:33:08 +0100 Subject: [PATCH 19/37] remove extra spaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- plugins/catalog-backend/src/service/createRouter.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index 4571d3bc68..ebfadc66c7 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -72,7 +72,6 @@ describe('createRouter readonly disabled', () => { beforeEach(async () => { entitiesCatalog = { entities: jest.fn(), - entitiesBatch: jest.fn(), removeEntityByUid: jest.fn(), entityAncestry: jest.fn(), @@ -1177,7 +1176,6 @@ describe('createRouter readonly and raw json enabled', () => { beforeAll(async () => { entitiesCatalog = { entities: jest.fn(), - entitiesBatch: jest.fn(), removeEntityByUid: jest.fn(), entityAncestry: jest.fn(), @@ -1394,7 +1392,6 @@ describe('NextRouter permissioning', () => { beforeAll(async () => { entitiesCatalog = { entities: jest.fn(), - entitiesBatch: jest.fn(), removeEntityByUid: jest.fn(), entityAncestry: jest.fn(), From 42d89b5025b4372c2deeba39a0268f826be32e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 11:47:51 +0100 Subject: [PATCH 20/37] small fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/rich-ducks-ring.md | 2 +- .../src/service/request/applyPredicateEntityFilterToQuery.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/rich-ducks-ring.md b/.changeset/rich-ducks-ring.md index 04848da4a7..ea8a3e8016 100644 --- a/.changeset/rich-ducks-ring.md +++ b/.changeset/rich-ducks-ring.md @@ -5,6 +5,6 @@ Added predicate-based entity filtering via POST /entities/by-query endpoint. -Supports `$all`, `$any`, `$not`, `$exists`, and `$in` operators for expressive entity queries. Integrated into the existing `queryEntities` flow with full cursor-based pagination, permission enforcement, and `totalItems` support. +Supports `$all`, `$any`, `$not`, `$exists`, `$in`, and `$hasPrefix` operators for expressive entity queries. Integrated into the existing `queryEntities` flow with full cursor-based pagination, permission enforcement, and `totalItems` support. The catalog client's `queryEntities()` method automatically routes to the POST endpoint when a `query` predicate is provided. diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index 88b064866d..5deb97019c 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -1,5 +1,5 @@ /* - * Copyright 2024 The Backstage Authors + * Copyright 2026 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -83,7 +83,7 @@ export function applyPredicateEntityFilterToQuery(options: { const { filter, targetQuery, onEntityIdField, knex } = options; // We do not support top-level primitives; all matching happens through objects - if (!filter || typeof filter !== 'object' || Array.isArray(filter)) { + if (!isObject(filter)) { throw new InputError( `Invalid filter predicate: top-level primitive values are not supported. ` + `Wrap the value in a field expression, e.g. { "kind": ${JSON.stringify( From 06ac10a0c27a34a53c1e1ed17937e7dd350a21cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 11:53:33 +0100 Subject: [PATCH 21/37] make sure to pass through offset too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- packages/catalog-client/src/CatalogClient.ts | 6 ++++- .../QueryEntitiesByPredicateRequest.model.ts | 1 + .../catalog-backend/src/schema/openapi.yaml | 2 ++ .../QueryEntitiesByPredicateRequest.model.ts | 1 + .../src/schema/openapi/generated/router.ts | 3 +++ .../src/service/createRouter.test.ts | 25 +++++++++++++++++++ .../service/request/parseEntityQuery.test.ts | 13 ++++++++++ .../src/service/request/parseEntityQuery.ts | 1 + 8 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index fa8a88c1ca..62111abf9e 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -359,13 +359,17 @@ export class CatalogClient implements CatalogApi { const body: QueryEntitiesByPredicateRequest = {}; if (isQueryEntitiesInitialRequest(request)) { - const { query, limit, orderFields, fullTextFilter, fields } = request; + const { query, limit, offset, orderFields, fullTextFilter, fields } = + request; if (query && typeof query === 'object') { body.query = query; } if (limit !== undefined) { body.limit = limit; } + if (offset !== undefined) { + body.offset = offset; + } if (orderFields !== undefined) { body.orderBy = [orderFields].flat(); } diff --git a/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts index 818a780b4a..17a9c9962f 100644 --- a/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts +++ b/packages/catalog-client/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts @@ -27,6 +27,7 @@ import { QueryEntitiesByPredicateRequestOrderByInner } from '../models/QueryEnti export interface QueryEntitiesByPredicateRequest { cursor?: string; limit?: number; + offset?: number; orderBy?: Array; fullTextFilter?: QueryEntitiesByPredicateRequestFullTextFilter; fields?: Array; diff --git a/plugins/catalog-backend/src/schema/openapi.yaml b/plugins/catalog-backend/src/schema/openapi.yaml index 41738c1148..0531a9a1d3 100644 --- a/plugins/catalog-backend/src/schema/openapi.yaml +++ b/plugins/catalog-backend/src/schema/openapi.yaml @@ -1125,6 +1125,8 @@ paths: type: string limit: type: number + offset: + type: number orderBy: type: array items: diff --git a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts index 818a780b4a..17a9c9962f 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model.ts @@ -27,6 +27,7 @@ import { QueryEntitiesByPredicateRequestOrderByInner } from '../models/QueryEnti export interface QueryEntitiesByPredicateRequest { cursor?: string; limit?: number; + offset?: number; orderBy?: Array; fullTextFilter?: QueryEntitiesByPredicateRequestFullTextFilter; fields?: Array; diff --git a/plugins/catalog-backend/src/schema/openapi/generated/router.ts b/plugins/catalog-backend/src/schema/openapi/generated/router.ts index ed9536d6b9..6c739eb2ee 100644 --- a/plugins/catalog-backend/src/schema/openapi/generated/router.ts +++ b/plugins/catalog-backend/src/schema/openapi/generated/router.ts @@ -1269,6 +1269,9 @@ export const spec = { limit: { type: 'number', }, + offset: { + type: 'number', + }, orderBy: { type: 'array', items: { diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index ebfadc66c7..acfac3b8d1 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -509,6 +509,31 @@ describe('createRouter readonly disabled', () => { ); }); + it('queries entities with an offset', async () => { + const items: Entity[] = [ + { apiVersion: 'a', kind: 'b', metadata: { name: 'n' } }, + ]; + entitiesCatalog.queryEntities.mockResolvedValue({ + items: { type: 'object', entities: items }, + pageInfo: {}, + totalItems: 5, + }); + + const response = await request(app) + .post('/entities/by-query') + .send({ query: { kind: 'b' }, limit: 2, offset: 3 }); + + expect(response.status).toEqual(200); + expect(entitiesCatalog.queryEntities).toHaveBeenCalledWith( + expect.objectContaining({ + query: { kind: 'b' }, + limit: 2, + offset: 3, + credentials: mockCredentials.user(), + }), + ); + }); + it('paginates with a cursor in the body', async () => { const items: Entity[] = [ { apiVersion: 'a', kind: 'b', metadata: { name: 'n' } }, diff --git a/plugins/catalog-backend/src/service/request/parseEntityQuery.test.ts b/plugins/catalog-backend/src/service/request/parseEntityQuery.test.ts index b95c4c6c0e..7380259188 100644 --- a/plugins/catalog-backend/src/service/request/parseEntityQuery.test.ts +++ b/plugins/catalog-backend/src/service/request/parseEntityQuery.test.ts @@ -28,6 +28,7 @@ describe('parseEntityQuery', () => { fullTextFilter: undefined, fields: undefined, limit: undefined, + offset: undefined, }); }); @@ -68,6 +69,18 @@ describe('parseEntityQuery', () => { expect(result).toEqual(expect.objectContaining({ limit: 50 })); }); + it('passes through offset', () => { + const result = parseEntityQuery({ offset: 100 }); + expect(result).toEqual(expect.objectContaining({ offset: 100 })); + }); + + it('passes through limit and offset together', () => { + const result = parseEntityQuery({ limit: 50, offset: 100 }); + expect(result).toEqual( + expect.objectContaining({ limit: 50, offset: 100 }), + ); + }); + it('passes through fields', () => { const result = parseEntityQuery({ fields: ['metadata.name', 'kind'], diff --git a/plugins/catalog-backend/src/service/request/parseEntityQuery.ts b/plugins/catalog-backend/src/service/request/parseEntityQuery.ts index c33492e740..27992c273a 100644 --- a/plugins/catalog-backend/src/service/request/parseEntityQuery.ts +++ b/plugins/catalog-backend/src/service/request/parseEntityQuery.ts @@ -110,5 +110,6 @@ export function parseEntityQuery( : undefined, fields: request.fields, limit: request.limit, + offset: request.offset, }; } From b8355e72089ecfac9202913a7d5b2ffb079247cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 12:09:38 +0100 Subject: [PATCH 22/37] empty should match everything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../request/applyPredicateEntityFilterToQuery.test.ts | 10 ++++++++-- .../request/applyPredicateEntityFilterToQuery.ts | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts index 21e5d2c37a..70d5461c3a 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts @@ -150,8 +150,14 @@ describe.each(databases.eachSupportedId())( ).resolves.toEqual(['service-a', 'service-b']); }); - it('returns nothing for empty $all', async () => { - await expect(query({ $all: [] })).resolves.toEqual([]); + it('matches everything for empty $all', async () => { + await expect(query({ $all: [] })).resolves.toEqual([ + 'api-d', + 'bare-e', + 'service-a', + 'service-b', + 'website-c', + ]); }); it('filters with $any', async () => { diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index 5deb97019c..2a404b88bf 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -105,7 +105,7 @@ export function applyPredicateEntityFilterToQuery(options: { if ('$all' in filter) { if (filter.$all.length === 0) { - return targetQuery.andWhereRaw('1 = 0'); + return targetQuery; } return targetQuery.andWhere(outer => { for (const subFilter of filter.$all) { From 430b8978402961929506df10fbff31a5469eff72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 12:14:08 +0100 Subject: [PATCH 23/37] add explicit clear rejection of MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../request/applyPredicateEntityFilterToQuery.test.ts | 6 ++++++ .../service/request/applyPredicateEntityFilterToQuery.ts | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts index 70d5461c3a..b90e0ca409 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts @@ -231,6 +231,12 @@ describe.each(databases.eachSupportedId())( ).resolves.toEqual(['service-a', 'website-c']); }); + it('throws on $contains operator', async () => { + await expect( + query({ 'metadata.name': { $contains: 'service' } as any }), + ).rejects.toThrow(/\$contains operator is not supported/); + }); + it('throws on top-level primitive', async () => { await expect(query('bad-value' as any)).rejects.toThrow( /top-level primitive values are not supported/, diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index 2a404b88bf..56d85a9da1 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -209,6 +209,11 @@ function applyFieldCondition(options: { .andWhereRaw('?? like ? escape ?', ['value', `${escaped}%`, '\\']); return targetQuery.andWhere(onEntityIdField, 'in', matchQuery); } + + if ('$contains' in value) { + // TODO(freben): Implement this, AT LEAST for some special cases such as tags and relations. + throw new InputError('The $contains operator is not supported'); + } } throw new InputError( From 9dd2f112f0a920531987e31f9be67773f9143737 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 12:17:57 +0100 Subject: [PATCH 24/37] add more pagination tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../catalog-client/src/CatalogClient.test.ts | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index aee10cd3f2..7ce9e94396 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -807,6 +807,81 @@ describe('CatalogClient', () => { expect(mockedEndpoint).toHaveBeenCalledTimes(1); }); + it('should paginate using POST when cursor contains a query', async () => { + // Simulate a cursor that contains a query predicate (as the server would encode it) + const cursorPayload = Buffer.from( + JSON.stringify({ + orderFields: [], + orderFieldValues: [], + isPrevious: false, + query: { kind: 'component' }, + totalItems: 100, + }), + ).toString('base64'); + + const page2Response = { + items: [ + { + apiVersion: '1', + kind: 'Component', + metadata: { name: 'service-3', namespace: 'default' }, + }, + ], + totalItems: 100, + pageInfo: {}, + }; + + const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { + expect(req.method).toBe('POST'); + expect(req.body).toMatchObject({ cursor: cursorPayload }); + return res(ctx.json(page2Response)); + }); + + server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint)); + + const response = await client.queryEntities({ + cursor: cursorPayload, + }); + + expect(mockedEndpoint).toHaveBeenCalledTimes(1); + expect(response.items).toEqual(page2Response.items); + expect(response.totalItems).toBe(100); + }); + + it('should use GET endpoint for cursor without query', async () => { + // A cursor that does NOT contain a query field should go to GET + const cursorPayload = Buffer.from( + JSON.stringify({ + orderFields: [], + orderFieldValues: [], + isPrevious: false, + totalItems: 50, + }), + ).toString('base64'); + + const mockedGetEndpoint = jest.fn().mockImplementation((_req, res, ctx) => + res( + ctx.json({ + items: [], + totalItems: 50, + pageInfo: {}, + }), + ), + ); + + const mockedPostEndpoint = jest.fn(); + + server.use( + rest.get(`${mockBaseUrl}/entities/by-query`, mockedGetEndpoint), + rest.post(`${mockBaseUrl}/entities/by-query`, mockedPostEndpoint), + ); + + await client.queryEntities({ cursor: cursorPayload }); + + expect(mockedGetEndpoint).toHaveBeenCalledTimes(1); + expect(mockedPostEndpoint).not.toHaveBeenCalled(); + }); + it('should handle errors from POST endpoint', async () => { const mockedEndpoint = jest .fn() From 1ed51c2d8a7641b052529d7311299ec70cabae57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 13:34:06 +0100 Subject: [PATCH 25/37] make sure that permissions apply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../service/AuthorizedEntitiesCatalog.test.ts | 172 ++++++++++++++++++ .../src/service/AuthorizedEntitiesCatalog.ts | 64 ++++++- .../applyPredicateEntityFilterToQuery.ts | 2 +- 3 files changed, 231 insertions(+), 7 deletions(-) diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts index 894949a691..f074f25397 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts @@ -23,6 +23,7 @@ import { AuthorizedEntitiesCatalog } from './AuthorizedEntitiesCatalog'; import { Cursor, QueryEntitiesResponse } from '../catalog/types'; import { Entity } from '@backstage/catalog-model'; import { EntityFilter } from '@backstage/plugin-catalog-node'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { mockCredentials } from '@backstage/backend-test-utils'; describe('AuthorizedEntitiesCatalog', () => { @@ -306,6 +307,177 @@ describe('AuthorizedEntitiesCatalog', () => { }, }); }); + + it('combines permission filter into query field using $all on CONDITIONAL with initial request', async () => { + fakePermissionApi.authorizeConditional.mockResolvedValue([ + { + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_KIND', + params: { kinds: ['b'] }, + }, + }, + ]); + + const userQuery: FilterPredicate = { 'metadata.name': 'my-entity' }; + + const entities = [ + { + kind: 'component', + namespace: 'default', + name: 'a', + } as unknown as Entity, + ]; + + fakeCatalog.queryEntities.mockResolvedValue({ + items: { type: 'object', entities }, + pageInfo: { + nextCursor: { + isPrevious: false, + orderFieldValues: ['xxx', null], + query: { $all: [{ kind: 'b' }, userQuery] }, + orderFields: [{ field: 'name', order: 'asc' }], + }, + }, + totalItems: 1, + } as QueryEntitiesResponse); + + const catalog = createCatalog(isEntityKind); + + const response = await catalog.queryEntities({ + credentials: mockCredentials.none(), + query: userQuery, + }); + + expect(fakeCatalog.queryEntities).toHaveBeenCalledWith({ + credentials: mockCredentials.none(), + query: { $all: [{ kind: 'b' }, userQuery] }, + filter: undefined, + }); + + expect(response.pageInfo.nextCursor).toEqual({ + isPrevious: false, + orderFieldValues: ['xxx', null], + query: userQuery, + filter: undefined, + orderFields: [{ field: 'name', order: 'asc' }], + }); + }); + + it('combines permission filter into cursor query field using $all on CONDITIONAL with cursor request', async () => { + fakePermissionApi.authorizeConditional.mockResolvedValue([ + { + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_KIND', + params: { kinds: ['b'] }, + }, + }, + ]); + + const userQuery: FilterPredicate = { 'metadata.name': 'my-entity' }; + + const entities = [ + { + kind: 'component', + namespace: 'default', + name: 'a', + } as unknown as Entity, + ]; + + fakeCatalog.queryEntities.mockResolvedValue({ + items: { type: 'object', entities }, + pageInfo: { + nextCursor: { + isPrevious: false, + orderFieldValues: ['yyy', null], + query: { $all: [{ kind: 'b' }, userQuery] }, + orderFields: [{ field: 'name', order: 'asc' }], + }, + prevCursor: { + isPrevious: true, + orderFieldValues: ['aaa', null], + query: { $all: [{ kind: 'b' }, userQuery] }, + orderFields: [{ field: 'name', order: 'asc' }], + }, + }, + totalItems: 3, + } as QueryEntitiesResponse); + + const catalog = createCatalog(isEntityKind); + + const cursor: Cursor = { + query: userQuery, + orderFields: [{ field: 'name', order: 'asc' }], + isPrevious: false, + orderFieldValues: ['xxx', null], + }; + + const response = await catalog.queryEntities({ + credentials: mockCredentials.none(), + cursor, + }); + + expect(fakeCatalog.queryEntities).toHaveBeenCalledWith({ + credentials: mockCredentials.none(), + cursor: { + ...cursor, + query: { $all: [{ kind: 'b' }, userQuery] }, + filter: undefined, + }, + }); + + expect(response.pageInfo.nextCursor).toEqual({ + isPrevious: false, + orderFieldValues: ['yyy', null], + query: userQuery, + filter: undefined, + orderFields: [{ field: 'name', order: 'asc' }], + }); + + expect(response.pageInfo.prevCursor).toEqual({ + isPrevious: true, + orderFieldValues: ['aaa', null], + query: userQuery, + filter: undefined, + orderFields: [{ field: 'name', order: 'asc' }], + }); + }); + + it('converts multi-value permission filter with $in when converting to predicate', async () => { + fakePermissionApi.authorizeConditional.mockResolvedValue([ + { + result: AuthorizeResult.CONDITIONAL, + conditions: { + rule: 'IS_ENTITY_KIND', + params: { kinds: ['component', 'api'] }, + }, + }, + ]); + + const userQuery: FilterPredicate = { 'metadata.name': 'my-entity' }; + + fakeCatalog.queryEntities.mockResolvedValue({ + items: { type: 'object', entities: [] }, + pageInfo: {}, + totalItems: 0, + } as QueryEntitiesResponse); + + const catalog = createCatalog(isEntityKind); + + await catalog.queryEntities({ + credentials: mockCredentials.none(), + query: userQuery, + }); + + expect(fakeCatalog.queryEntities).toHaveBeenCalledWith({ + credentials: mockCredentials.none(), + query: { + $all: [{ kind: { $in: ['component', 'api'] } }, userQuery], + }, + filter: undefined, + }); + }); }); describe('removeEntityByUid', () => { diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts index d3d0dba5b3..bb81144f25 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts @@ -35,6 +35,7 @@ import { QueryEntitiesRequest, QueryEntitiesResponse, } from '../catalog/types'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { basicEntityFilter } from './request'; import { isQueryEntitiesCursorRequest } from './util'; import { EntityFilter } from '@backstage/plugin-catalog-node'; @@ -43,6 +44,30 @@ import { PermissionsService, } from '@backstage/backend-plugin-api'; +function entityFilterToFilterPredicate(filter: EntityFilter): FilterPredicate { + if ('allOf' in filter) { + return { $all: filter.allOf.map(entityFilterToFilterPredicate) }; + } + + if ('anyOf' in filter) { + return { $any: filter.anyOf.map(entityFilterToFilterPredicate) }; + } + + if ('not' in filter) { + return { $not: entityFilterToFilterPredicate(filter.not) }; + } + + if (!filter.values) { + return { [filter.key]: { $exists: true } } as FilterPredicate; + } + + if (filter.values.length === 1) { + return { [filter.key]: filter.values[0] } as FilterPredicate; + } + + return { [filter.key]: { $in: filter.values } } as FilterPredicate; +} + export class AuthorizedEntitiesCatalog implements EntitiesCatalog { private readonly entitiesCatalog: EntitiesCatalog; private readonly permissionApi: PermissionsService; @@ -147,18 +172,42 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { let permissionedRequest: QueryEntitiesRequest; let requestFilter: EntityFilter | undefined; + let requestQuery: FilterPredicate | undefined; if (isQueryEntitiesCursorRequest(request)) { requestFilter = request.cursor.filter; + requestQuery = request.cursor.query; + if (request.cursor.query) { + const permissionPredicate = + entityFilterToFilterPredicate(permissionFilter); + permissionedRequest = { + ...request, + cursor: { + ...request.cursor, + query: { $all: [permissionPredicate, request.cursor.query] }, + filter: undefined, + }, + }; + } else { + permissionedRequest = { + ...request, + cursor: { + ...request.cursor, + filter: request.cursor.filter + ? { allOf: [permissionFilter, request.cursor.filter] } + : permissionFilter, + }, + }; + } + } else if (request.query) { + const permissionPredicate = + entityFilterToFilterPredicate(permissionFilter); + requestQuery = request.query; permissionedRequest = { ...request, - cursor: { - ...request.cursor, - filter: request.cursor.filter - ? { allOf: [permissionFilter, request.cursor.filter] } - : permissionFilter, - }, + query: { $all: [permissionPredicate, request.query] }, + filter: undefined, }; } else { permissionedRequest = { @@ -177,11 +226,13 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { const prevCursor: Cursor | undefined = response.pageInfo.prevCursor && { ...response.pageInfo.prevCursor, filter: requestFilter, + query: requestQuery, }; const nextCursor: Cursor | undefined = response.pageInfo.nextCursor && { ...response.pageInfo.nextCursor, filter: requestFilter, + query: requestQuery, }; return { @@ -193,6 +244,7 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { }; } + // The ALLOW case return this.entitiesCatalog.queryEntities(request); } diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index 56d85a9da1..b42252369d 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -105,7 +105,7 @@ export function applyPredicateEntityFilterToQuery(options: { if ('$all' in filter) { if (filter.$all.length === 0) { - return targetQuery; + return targetQuery.andWhereRaw('1 = 1'); } return targetQuery.andWhere(outer => { for (const subFilter of filter.$all) { From 345a36aa1b3f549546122ad2e86f51312af05f11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 13:57:11 +0100 Subject: [PATCH 26/37] remove the exclusivity constraint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../catalog-client/src/CatalogClient.test.ts | 11 ---- packages/catalog-client/src/CatalogClient.ts | 7 --- packages/catalog-client/src/types/api.ts | 11 +++- plugins/catalog-backend/src/catalog/types.ts | 2 - .../service/AuthorizedEntitiesCatalog.test.ts | 55 ++++------------ .../src/service/AuthorizedEntitiesCatalog.ts | 62 +++---------------- .../service/DefaultEntitiesCatalog.test.ts | 32 ++++++++++ 7 files changed, 60 insertions(+), 120 deletions(-) diff --git a/packages/catalog-client/src/CatalogClient.test.ts b/packages/catalog-client/src/CatalogClient.test.ts index 7ce9e94396..819fa70381 100644 --- a/packages/catalog-client/src/CatalogClient.test.ts +++ b/packages/catalog-client/src/CatalogClient.test.ts @@ -594,17 +594,6 @@ describe('CatalogClient', () => { expect(response.totalItems).toBe(2); }); - it('should throw error when both filter and query are provided', async () => { - await expect( - client.queryEntities({ - filter: { kind: 'component' }, - query: { kind: 'component' }, - } as any), - ).rejects.toThrow( - 'Cannot specify both "filter" and "query" in the same request', - ); - }); - it('should support $all operator', async () => { const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => { expect(req.body).toMatchObject({ diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 62111abf9e..bd557cfdd2 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -274,13 +274,6 @@ export class CatalogClient implements CatalogApi { ): Promise { const isInitialRequest = isQueryEntitiesInitialRequest(request); - // Validate that filter and query are mutually exclusive - if (isInitialRequest && request.filter && request.query) { - throw new Error( - 'Cannot specify both "filter" and "query" in the same request. Use "filter" for traditional key-value filtering or "query" for predicate-based filtering.', - ); - } - // Route to POST endpoint if query predicate is provided (initial request) if (isInitialRequest && request.query) { return this.queryEntitiesByPredicate(request, options); diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index 8bf13271b2..2dffb35570 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -593,6 +593,7 @@ export interface CatalogApi { * const response = await catalogClient.queryEntities({ * filter: [{ kind: 'group' }], * limit: 20, + * fields: ['metadata', 'kind'], * fullTextFilter: { * term: 'A', * }, @@ -609,11 +610,15 @@ export interface CatalogApi { * * ``` * const secondBatchResponse = await catalogClient - * .queryEntities({ cursor: response.nextCursor }); + * .queryEntities({ + * cursor: response.nextCursor, + * limit: 20, + * fields: ['metadata', 'kind'], + * }); * ``` * - * secondBatchResponse will contain the next batch of (maximum) 20 entities, - * together with a prevCursor property, useful to fetch the previous batch. + * `secondBatchResponse` will contain the next batch of (maximum) 20 entities, + * together with a `prevCursor` property, useful to fetch the previous batch. * * @public * diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index dda7e9a811..5d733ad14c 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -215,7 +215,6 @@ export interface QueryEntitiesInitialRequest { filter?: EntityFilter; /** * Predicate-based query for filtering entities. - * Mutually exclusive with filter. */ query?: FilterPredicate; orderFields?: EntityOrder[]; @@ -281,7 +280,6 @@ export type Cursor = { filter?: EntityFilter; /** * A predicate-based query to be applied to the full list of entities. - * Mutually exclusive with filter. */ query?: FilterPredicate; /** diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts index f074f25397..85fbd16eeb 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts @@ -308,7 +308,7 @@ describe('AuthorizedEntitiesCatalog', () => { }); }); - it('combines permission filter into query field using $all on CONDITIONAL with initial request', async () => { + it('passes through query alongside permission filter on CONDITIONAL with initial request', async () => { fakePermissionApi.authorizeConditional.mockResolvedValue([ { result: AuthorizeResult.CONDITIONAL, @@ -335,7 +335,8 @@ describe('AuthorizedEntitiesCatalog', () => { nextCursor: { isPrevious: false, orderFieldValues: ['xxx', null], - query: { $all: [{ kind: 'b' }, userQuery] }, + query: userQuery, + filter: { key: 'kind', values: ['b'] }, orderFields: [{ field: 'name', order: 'asc' }], }, }, @@ -351,8 +352,8 @@ describe('AuthorizedEntitiesCatalog', () => { expect(fakeCatalog.queryEntities).toHaveBeenCalledWith({ credentials: mockCredentials.none(), - query: { $all: [{ kind: 'b' }, userQuery] }, - filter: undefined, + query: userQuery, + filter: { key: 'kind', values: ['b'] }, }); expect(response.pageInfo.nextCursor).toEqual({ @@ -364,7 +365,7 @@ describe('AuthorizedEntitiesCatalog', () => { }); }); - it('combines permission filter into cursor query field using $all on CONDITIONAL with cursor request', async () => { + it('passes through cursor query alongside permission filter on CONDITIONAL with cursor request', async () => { fakePermissionApi.authorizeConditional.mockResolvedValue([ { result: AuthorizeResult.CONDITIONAL, @@ -391,13 +392,15 @@ describe('AuthorizedEntitiesCatalog', () => { nextCursor: { isPrevious: false, orderFieldValues: ['yyy', null], - query: { $all: [{ kind: 'b' }, userQuery] }, + query: userQuery, + filter: { key: 'kind', values: ['b'] }, orderFields: [{ field: 'name', order: 'asc' }], }, prevCursor: { isPrevious: true, orderFieldValues: ['aaa', null], - query: { $all: [{ kind: 'b' }, userQuery] }, + query: userQuery, + filter: { key: 'kind', values: ['b'] }, orderFields: [{ field: 'name', order: 'asc' }], }, }, @@ -422,8 +425,7 @@ describe('AuthorizedEntitiesCatalog', () => { credentials: mockCredentials.none(), cursor: { ...cursor, - query: { $all: [{ kind: 'b' }, userQuery] }, - filter: undefined, + filter: { key: 'kind', values: ['b'] }, }, }); @@ -443,41 +445,6 @@ describe('AuthorizedEntitiesCatalog', () => { orderFields: [{ field: 'name', order: 'asc' }], }); }); - - it('converts multi-value permission filter with $in when converting to predicate', async () => { - fakePermissionApi.authorizeConditional.mockResolvedValue([ - { - result: AuthorizeResult.CONDITIONAL, - conditions: { - rule: 'IS_ENTITY_KIND', - params: { kinds: ['component', 'api'] }, - }, - }, - ]); - - const userQuery: FilterPredicate = { 'metadata.name': 'my-entity' }; - - fakeCatalog.queryEntities.mockResolvedValue({ - items: { type: 'object', entities: [] }, - pageInfo: {}, - totalItems: 0, - } as QueryEntitiesResponse); - - const catalog = createCatalog(isEntityKind); - - await catalog.queryEntities({ - credentials: mockCredentials.none(), - query: userQuery, - }); - - expect(fakeCatalog.queryEntities).toHaveBeenCalledWith({ - credentials: mockCredentials.none(), - query: { - $all: [{ kind: { $in: ['component', 'api'] } }, userQuery], - }, - filter: undefined, - }); - }); }); describe('removeEntityByUid', () => { diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts index bb81144f25..910188b036 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts @@ -44,30 +44,6 @@ import { PermissionsService, } from '@backstage/backend-plugin-api'; -function entityFilterToFilterPredicate(filter: EntityFilter): FilterPredicate { - if ('allOf' in filter) { - return { $all: filter.allOf.map(entityFilterToFilterPredicate) }; - } - - if ('anyOf' in filter) { - return { $any: filter.anyOf.map(entityFilterToFilterPredicate) }; - } - - if ('not' in filter) { - return { $not: entityFilterToFilterPredicate(filter.not) }; - } - - if (!filter.values) { - return { [filter.key]: { $exists: true } } as FilterPredicate; - } - - if (filter.values.length === 1) { - return { [filter.key]: filter.values[0] } as FilterPredicate; - } - - return { [filter.key]: { $in: filter.values } } as FilterPredicate; -} - export class AuthorizedEntitiesCatalog implements EntitiesCatalog { private readonly entitiesCatalog: EntitiesCatalog; private readonly permissionApi: PermissionsService; @@ -178,45 +154,25 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog { requestFilter = request.cursor.filter; requestQuery = request.cursor.query; - if (request.cursor.query) { - const permissionPredicate = - entityFilterToFilterPredicate(permissionFilter); - permissionedRequest = { - ...request, - cursor: { - ...request.cursor, - query: { $all: [permissionPredicate, request.cursor.query] }, - filter: undefined, - }, - }; - } else { - permissionedRequest = { - ...request, - cursor: { - ...request.cursor, - filter: request.cursor.filter - ? { allOf: [permissionFilter, request.cursor.filter] } - : permissionFilter, - }, - }; - } - } else if (request.query) { - const permissionPredicate = - entityFilterToFilterPredicate(permissionFilter); - requestQuery = request.query; permissionedRequest = { ...request, - query: { $all: [permissionPredicate, request.query] }, - filter: undefined, + cursor: { + ...request.cursor, + filter: request.cursor.filter + ? { allOf: [permissionFilter, request.cursor.filter] } + : permissionFilter, + }, }; } else { + requestFilter = request.filter; + requestQuery = request.query; + permissionedRequest = { ...request, filter: request.filter ? { allOf: [permissionFilter, request.filter] } : permissionFilter, }; - requestFilter = request.filter; } const response = await this.entitiesCatalog.queryEntities( diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts index 7012c2e505..f9e41b679c 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts @@ -2054,6 +2054,38 @@ describe('DefaultEntitiesCatalog', () => { ]); }, ); + + it.each(databases.eachSupportedId())( + 'should apply both filter and query when both are given, %p', + async databaseId => { + await createDatabase(databaseId); + + // Add entities with different kinds and names + await addEntityToSearch(entityFrom('A', { kind: 'component' })); + await addEntityToSearch(entityFrom('B', { kind: 'component' })); + await addEntityToSearch(entityFrom('C', { kind: 'api' })); + await addEntityToSearch(entityFrom('D', { kind: 'api' })); + + const catalog = new DefaultEntitiesCatalog({ + database: knex, + logger: mockServices.logger.mock(), + stitcher, + }); + + // Use filter to restrict to kind=component, and query to restrict to name=A + const response = await catalog.queryEntities({ + filter: { key: 'kind', values: ['component'] }, + query: { 'metadata.name': 'a' }, + orderFields: [{ field: 'metadata.name', order: 'asc' }], + credentials: mockCredentials.none(), + }); + + const resultEntities = entitiesResponseToObjects(response.items); + expect(resultEntities).toEqual([ + entityFrom('A', { kind: 'component' }), + ]); + }, + ); }); describe('removeEntityByUid', () => { From 7f0f4139300049969ad727bd629dee1cc1479ec6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 14:34:22 +0100 Subject: [PATCH 27/37] Update packages/catalog-client/src/types/api.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Fredrik Adelöw --- packages/catalog-client/src/types/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index 2dffb35570..a99f36573e 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -438,7 +438,7 @@ export type QueryEntitiesInitialRequest = { */ filter?: EntityFilterQuery; /** - * Predicate-based filter with logical operators ($all, $any, $not, $exists, $in). + * Predicate-based filter with logical operators ($all, $any, $not, $exists, $in, $hasPrefix). * Mutually exclusive with `filter`. * * @example From 7969c48889e4207e21e9a60219fe69b03e5fc663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 14:42:17 +0100 Subject: [PATCH 28/37] remove dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- packages/catalog-client/src/types/api.ts | 5 ++-- .../src/service/DefaultEntitiesCatalog.ts | 25 ------------------- 2 files changed, 2 insertions(+), 28 deletions(-) diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index a99f36573e..64d01c0972 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -423,7 +423,7 @@ export type QueryEntitiesRequest = * * @remarks * - * Either `filter` or `query` can be provided, but not both: + * Either `filter` or `query` can be provided, or even both: * - `filter`: Uses the traditional key-value filter syntax (GET endpoint) * - `query`: Uses the predicate-based filter syntax with logical operators (POST endpoint) * @@ -434,12 +434,11 @@ export type QueryEntitiesInitialRequest = { limit?: number; offset?: number; /** - * Traditional key-value based filter. Mutually exclusive with `query`. + * Traditional key-value based filter. */ filter?: EntityFilterQuery; /** * Predicate-based filter with logical operators ($all, $any, $not, $exists, $in, $hasPrefix). - * Mutually exclusive with `filter`. * * @example * ```typescript diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts index 11bce7bd86..dab605d176 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts @@ -18,7 +18,6 @@ import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { InputError, NotFoundError } from '@backstage/errors'; import { Knex } from 'knex'; import { chunk as lodashChunk, isEqual } from 'lodash'; -import { z } from 'zod'; import { Cursor, EntitiesBatchRequest, @@ -49,7 +48,6 @@ import { isQueryEntitiesCursorRequest, isQueryEntitiesInitialRequest, } from './util'; -import { EntityFilter } from '@backstage/plugin-catalog-node'; import { LoggerService } from '@backstage/backend-plugin-api'; import { applyEntityFilterToQuery } from './request/applyEntityFilterToQuery'; import { processRawEntitiesResult } from './response'; @@ -714,29 +712,6 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { } } -const entityFilterParser: z.ZodSchema = z.lazy(() => - z - .object({ - key: z.string(), - values: z.array(z.string()).optional(), - }) - .or(z.object({ not: entityFilterParser })) - .or(z.object({ anyOf: z.array(entityFilterParser) })) - .or(z.object({ allOf: z.array(entityFilterParser) })), -); - -export const cursorParser: z.ZodSchema = z.object({ - orderFields: z.array( - z.object({ field: z.string(), order: z.enum(['asc', 'desc']) }), - ), - orderFieldValues: z.array(z.string().or(z.null())), - filter: entityFilterParser.optional(), - isPrevious: z.boolean(), - query: z.string().optional(), - firstSortFieldValues: z.array(z.string().or(z.null())).optional(), - totalItems: z.number().optional(), -}); - function parseCursorFromRequest( request?: QueryEntitiesRequest, ): Partial & { skipTotalItems: boolean } { From a3e4add213840810696dca100a60dc61020b9082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 15:01:38 +0100 Subject: [PATCH 29/37] remove some unnecessary noise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../applyPredicateEntityFilterToQuery.ts | 45 +------------------ 1 file changed, 2 insertions(+), 43 deletions(-) diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index b42252369d..d376b132ff 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -35,45 +35,6 @@ function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -/** - * example generated query - * Selects all non-null final entities that: - * - * - Are of kind "component" - * - Have spec.type = "service" - * - Are owned by either: - * - backend-team - * - platform-team - * - Are NOT in experimental lifecycle - * - * Results are ordered by entity_ref in ascending order. - * - * SQL Reference: - * ``` - * SELECT final_entities.* - * FROM final_entities - * WHERE final_entities.final_entity IS NOT NULL - * AND final_entities.entity_id IN ( - * SELECT entity_id FROM search WHERE key = 'kind' AND value = 'component' - * ) - * AND final_entities.entity_id IN ( - * SELECT entity_id FROM search WHERE key = 'spec.type' AND value = 'service' - * ) - * AND ( - * final_entities.entity_id IN ( - * SELECT entity_id FROM search WHERE key = 'spec.owner' AND value = 'backend-team' - * ) - * OR - * final_entities.entity_id IN ( - * SELECT entity_id FROM search WHERE key = 'spec.owner' AND value = 'platform-team' - * ) - * ) - * AND final_entities.entity_id NOT IN ( - * SELECT entity_id FROM search WHERE key = 'spec.lifecycle' AND value = 'experimental' - * ) - * ORDER BY final_entities.entity_ref ASC; - * ``` - */ export function applyPredicateEntityFilterToQuery(options: { filter: FilterPredicate; targetQuery: Knex.QueryBuilder; @@ -190,9 +151,7 @@ function applyFieldCondition(options: { } if ('$in' in value) { - const values = (value.$in as FilterPredicatePrimitive[]).map(v => - String(v).toLocaleLowerCase('en-US'), - ); + const values = value.$in.map(v => String(v).toLocaleLowerCase('en-US')); const matchQuery = knex('search') .select('search.entity_id') .where({ key }) @@ -201,7 +160,7 @@ function applyFieldCondition(options: { } if ('$hasPrefix' in value) { - const prefix = (value.$hasPrefix as string).toLocaleLowerCase('en-US'); + const prefix = value.$hasPrefix.toLocaleLowerCase('en-US'); const escaped = prefix.replace(/[%_\\]/g, c => `\\${c}`); const matchQuery = knex('search') .select('search.entity_id') From c7c0dd5ed2688c2e19567054f5f31ecd4f48f905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 15:11:59 +0100 Subject: [PATCH 30/37] add test for empty matcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../request/applyPredicateEntityFilterToQuery.test.ts | 4 ++++ .../request/applyPredicateEntityFilterToQuery.ts | 11 ++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts index b90e0ca409..e48be8169a 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts @@ -126,6 +126,10 @@ describe.each(databases.eachSupportedId())( ]); }); + it('matches nothing for {$not: {}}', async () => { + await expect(query({ $not: {} })).resolves.toEqual([]); + }); + it('filters by direct field value', async () => { await expect(query({ kind: 'component' })).resolves.toEqual([ 'bare-e', diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index d376b132ff..e7cf152184 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -102,7 +102,7 @@ export function applyPredicateEntityFilterToQuery(options: { // Treat the filter as a field expression like { "kind": "component" } or { "spec.type": { "$in": ["service", "website"] } } if (Object.keys(filter).length === 0) { - return targetQuery; + return targetQuery.andWhereRaw('1 = 1'); } return targetQuery.andWhere(inner => { for (const [keyAnyCase, value] of Object.entries(filter)) { @@ -144,10 +144,11 @@ function applyFieldCondition(options: { const existsQuery = knex('search') .select('search.entity_id') .where({ key }); - if (value.$exists) { - return targetQuery.andWhere(onEntityIdField, 'in', existsQuery); - } - return targetQuery.andWhere(onEntityIdField, 'not in', existsQuery); + return targetQuery.andWhere( + onEntityIdField, + value.$exists ? 'in' : 'not in', + existsQuery, + ); } if ('$in' in value) { From c25c589f9cd8733c771efe5c56e9d0732a513833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 15:29:39 +0100 Subject: [PATCH 31/37] add primitive $contains support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../applyPredicateEntityFilterToQuery.test.ts | 328 ++++++++++-------- .../applyPredicateEntityFilterToQuery.ts | 41 ++- 2 files changed, 215 insertions(+), 154 deletions(-) diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts index e48be8169a..7f07cad11e 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts @@ -67,7 +67,11 @@ describe.each(databases.eachSupportedId())( await addEntity({ apiVersion: 'backstage.io/v1alpha1', kind: 'Component', - metadata: { name: 'bare-e', namespace: 'default' }, + metadata: { + name: 'bare-e', + namespace: 'default', + tags: ['java', 'backend'], + }, spec: {}, }); }); @@ -116,151 +120,185 @@ describe.each(databases.eachSupportedId())( ); } - it('matches everything for empty field expression', async () => { - await expect(query({})).resolves.toEqual([ - 'api-d', - 'bare-e', - 'service-a', - 'service-b', - 'website-c', - ]); - }); - - it('matches nothing for {$not: {}}', async () => { - await expect(query({ $not: {} })).resolves.toEqual([]); - }); - - it('filters by direct field value', async () => { - await expect(query({ kind: 'component' })).resolves.toEqual([ - 'bare-e', - 'service-a', - 'service-b', - 'website-c', - ]); - }); - - it('filters by exact spec field value', async () => { - await expect(query({ 'spec.type': 'service' })).resolves.toEqual([ - 'service-a', - 'service-b', - ]); - }); - - it('filters with $all', async () => { - await expect( - query({ - $all: [{ kind: 'component' }, { 'spec.type': 'service' }], - }), - ).resolves.toEqual(['service-a', 'service-b']); - }); - - it('matches everything for empty $all', async () => { - await expect(query({ $all: [] })).resolves.toEqual([ - 'api-d', - 'bare-e', - 'service-a', - 'service-b', - 'website-c', - ]); - }); - - it('filters with $any', async () => { - await expect( - query({ - $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], - }), - ).resolves.toEqual(['service-a', 'service-b', 'website-c']); - }); - - it('returns nothing for empty $any', async () => { - await expect(query({ $any: [] })).resolves.toEqual([]); - }); - - it('filters with $not', async () => { - await expect( - query({ - $all: [ - { kind: 'component' }, - { $not: { 'spec.lifecycle': 'experimental' } }, - ], - }), - ).resolves.toEqual(['bare-e', 'service-a', 'website-c']); - }); - - it('filters with $in', async () => { - await expect( - query({ 'spec.type': { $in: ['service', 'openapi'] } }), - ).resolves.toEqual(['api-d', 'service-a', 'service-b']); - }); - - it('filters with $exists true', async () => { - await expect( - query({ - $all: [{ kind: 'component' }, { 'spec.owner': { $exists: true } }], - }), - ).resolves.toEqual(['service-a', 'service-b', 'website-c']); - }); - - it('filters with $exists false', async () => { - await expect( - query({ - $all: [{ kind: 'component' }, { 'spec.owner': { $exists: false } }], - }), - ).resolves.toEqual(['bare-e']); - }); - - it('filters with $hasPrefix', async () => { - await expect( - query({ 'metadata.name': { $hasPrefix: 'service' } }), - ).resolves.toEqual(['service-a', 'service-b']); - }); - - it('filters with $hasPrefix case-insensitively', async () => { - await expect( - query({ 'metadata.name': { $hasPrefix: 'Service' } }), - ).resolves.toEqual(['service-a', 'service-b']); - }); - - it('handles nested logical operators', async () => { - await expect( - query({ - $all: [ - { kind: 'component' }, - { - $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], - }, - { $not: { 'spec.lifecycle': 'experimental' } }, - ], - }), - ).resolves.toEqual(['service-a', 'website-c']); - }); - - it('throws on $contains operator', async () => { - await expect( - query({ 'metadata.name': { $contains: 'service' } as any }), - ).rejects.toThrow(/\$contains operator is not supported/); - }); - - it('throws on top-level primitive', async () => { - await expect(query('bad-value' as any)).rejects.toThrow( - /top-level primitive values are not supported/, - ); - }); - - it('combines filter and query independently', async () => { - const q = - knex('final_entities').whereNotNull('final_entity'); - applyEntityFilterToQuery({ - filter: { key: 'kind', values: ['component'] }, - query: { 'spec.type': 'service' }, - targetQuery: q, - onEntityIdField: 'final_entities.entity_id', - knex, + describe('field expressions', () => { + it('matches everything for empty field expression', async () => { + await expect(query({})).resolves.toEqual([ + 'api-d', + 'bare-e', + 'service-a', + 'service-b', + 'website-c', + ]); + }); + + it('filters by direct field value', async () => { + await expect(query({ kind: 'component' })).resolves.toEqual([ + 'bare-e', + 'service-a', + 'service-b', + 'website-c', + ]); + }); + + it('filters by exact spec field value', async () => { + await expect(query({ 'spec.type': 'service' })).resolves.toEqual([ + 'service-a', + 'service-b', + ]); + }); + + it('throws on top-level primitive', async () => { + await expect(query('bad-value' as any)).rejects.toThrow( + /top-level primitive values are not supported/, + ); + }); + }); + + describe('$all', () => { + it('filters with $all', async () => { + await expect( + query({ + $all: [{ kind: 'component' }, { 'spec.type': 'service' }], + }), + ).resolves.toEqual(['service-a', 'service-b']); + }); + + it('matches everything for empty $all', async () => { + await expect(query({ $all: [] })).resolves.toEqual([ + 'api-d', + 'bare-e', + 'service-a', + 'service-b', + 'website-c', + ]); + }); + }); + + describe('$any', () => { + it('filters with $any', async () => { + await expect( + query({ + $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], + }), + ).resolves.toEqual(['service-a', 'service-b', 'website-c']); + }); + + it('returns nothing for empty $any', async () => { + await expect(query({ $any: [] })).resolves.toEqual([]); + }); + }); + + describe('$not', () => { + it('filters with $not', async () => { + await expect( + query({ + $all: [ + { kind: 'component' }, + { $not: { 'spec.lifecycle': 'experimental' } }, + ], + }), + ).resolves.toEqual(['bare-e', 'service-a', 'website-c']); + }); + + it('matches nothing for {$not: {}}', async () => { + await expect(query({ $not: {} })).resolves.toEqual([]); + }); + }); + + describe('$in', () => { + it('filters with $in', async () => { + await expect( + query({ 'spec.type': { $in: ['service', 'openapi'] } }), + ).resolves.toEqual(['api-d', 'service-a', 'service-b']); + }); + }); + + describe('$exists', () => { + it('filters with $exists true', async () => { + await expect( + query({ + $all: [{ kind: 'component' }, { 'spec.owner': { $exists: true } }], + }), + ).resolves.toEqual(['service-a', 'service-b', 'website-c']); + }); + + it('filters with $exists false', async () => { + await expect( + query({ + $all: [{ kind: 'component' }, { 'spec.owner': { $exists: false } }], + }), + ).resolves.toEqual(['bare-e']); + }); + }); + + describe('$hasPrefix', () => { + it('filters with $hasPrefix', async () => { + await expect( + query({ 'metadata.name': { $hasPrefix: 'service' } }), + ).resolves.toEqual(['service-a', 'service-b']); + }); + + it('filters with $hasPrefix case-insensitively', async () => { + await expect( + query({ 'metadata.name': { $hasPrefix: 'Service' } }), + ).resolves.toEqual(['service-a', 'service-b']); + }); + }); + + describe('$contains', () => { + it('filters with primitive $contains on array fields', async () => { + await expect( + query({ 'metadata.tags': { $contains: 'java' } }), + ).resolves.toEqual(['bare-e']); + }); + + it('throws on non-primitive $contains', async () => { + await expect( + query({ + 'metadata.tags': { $contains: { nested: 'object' } } as any, + }), + ).rejects.toThrow( + /Non primitive forms of the \$contains operator is not supported/, + ); + }); + }); + + describe('nested operators', () => { + it('handles nested logical operators', async () => { + await expect( + query({ + $all: [ + { kind: 'component' }, + { + $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], + }, + { $not: { 'spec.lifecycle': 'experimental' } }, + ], + }), + ).resolves.toEqual(['service-a', 'website-c']); + }); + }); + + describe('combined filter and query', () => { + it('combines filter and query independently', async () => { + const q = + knex('final_entities').whereNotNull( + 'final_entity', + ); + applyEntityFilterToQuery({ + filter: { key: 'kind', values: ['component'] }, + query: { 'spec.type': 'service' }, + targetQuery: q, + onEntityIdField: 'final_entities.entity_id', + knex, + }); + const result = await q.then(rows => + rows + .map(row => JSON.parse(row.final_entity!).metadata.name) + .toSorted(), + ); + expect(result).toEqual(['service-a', 'service-b']); }); - const result = await q.then(rows => - rows.map(row => JSON.parse(row.final_entity!).metadata.name).toSorted(), - ); - expect(result).toEqual(['service-a', 'service-b']); }); }, ); diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index e7cf152184..27701a90ef 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -45,11 +45,9 @@ export function applyPredicateEntityFilterToQuery(options: { // We do not support top-level primitives; all matching happens through objects if (!isObject(filter)) { + const actual = JSON.stringify(filter); throw new InputError( - `Invalid filter predicate: top-level primitive values are not supported. ` + - `Wrap the value in a field expression, e.g. { "kind": ${JSON.stringify( - filter, - )} }`, + `Invalid filter predicate: top-level primitive values are not supported. Wrap the value in a field expression, e.g. { "kind": ${actual} }`, ); } @@ -171,14 +169,39 @@ function applyFieldCondition(options: { } if ('$contains' in value) { - // TODO(freben): Implement this, AT LEAST for some special cases such as tags and relations. - throw new InputError('The $contains operator is not supported'); + const target = value.$contains; + + // If the target is a primitive, match on the special array syntax. + // + // FROM: `{ "a": { "$contains": "b" } }` + // + // TO: `{ "a.b": "true" }` + // + // The search table does not actually show us that "a" was an array to + // begin with, so this can mistakenly also match on an object that had a + // "b" key with a true value. We'll consider that an acceptable tradeoff + // though. + if (isPrimitive(target)) { + const matchQuery = knex('search') + .select('search.entity_id') + .where({ + key: `${key}.${String(target).toLocaleLowerCase('en-US')}`, + value: 'true', + }); + return targetQuery.andWhere(onEntityIdField, 'in', matchQuery); + } + + // TODO(freben): Implement this for more use cases - for example simple + // objects (resulting in a $and and appending key strings) and maybe + // a subset of relations? + throw new InputError( + 'Non primitive forms of the $contains operator is not supported', + ); } } + const actual = JSON.stringify(value); throw new InputError( - `Invalid filter predicate value for field "${key}": expected a primitive value, $exists, $in, or $hasPrefix operator, but got ${JSON.stringify( - value, - )}`, + `Invalid filter predicate value for field "${key}": expected a primitive value, $exists, $in, or $hasPrefix operator, but got ${actual}`, ); } From bec9fe21d7bd2d070ba07b870600e94c7d1a024a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Feb 2026 17:04:59 +0100 Subject: [PATCH 32/37] implement relations contains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../applyPredicateEntityFilterToQuery.test.ts | 132 +++++++++++++++++- .../applyPredicateEntityFilterToQuery.ts | 132 +++++++++++++++++- 2 files changed, 256 insertions(+), 8 deletions(-) diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts index 7f07cad11e..cea9d961f7 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts @@ -45,18 +45,24 @@ describe.each(databases.eachSupportedId())( kind: 'Component', metadata: { name: 'service-a', namespace: 'default' }, spec: { type: 'service', lifecycle: 'production', owner: 'team-a' }, + relations: [ + { type: 'ownedBy', targetRef: 'group:default/team-a' }, + { type: 'consumesApi', targetRef: 'api:default/api-d' }, + ], }); await addEntity({ apiVersion: 'backstage.io/v1alpha1', kind: 'Component', metadata: { name: 'service-b', namespace: 'default' }, spec: { type: 'service', lifecycle: 'experimental', owner: 'team-b' }, + relations: [{ type: 'ownedBy', targetRef: 'group:default/team-b' }], }); await addEntity({ apiVersion: 'backstage.io/v1alpha1', kind: 'Component', metadata: { name: 'website-c', namespace: 'default' }, spec: { type: 'website', lifecycle: 'production', owner: 'team-a' }, + relations: [{ type: 'ownedBy', targetRef: 'group:default/team-a' }], }); await addEntity({ apiVersion: 'backstage.io/v1alpha1', @@ -147,6 +153,12 @@ describe.each(databases.eachSupportedId())( ]); }); + it('throws on unsupported field operator', async () => { + await expect(query({ kind: { $bad: 'value' } as any })).rejects.toThrow( + /\$contains operator, but got/, + ); + }); + it('throws on top-level primitive', async () => { await expect(query('bad-value' as any)).rejects.toThrow( /top-level primitive values are not supported/, @@ -252,13 +264,127 @@ describe.each(databases.eachSupportedId())( ).resolves.toEqual(['bare-e']); }); - it('throws on non-primitive $contains', async () => { + it('matches relations by type only (existence)', async () => { + await expect( + query({ relations: { $contains: { type: 'consumesApi' } } }), + ).resolves.toEqual(['service-a']); + }); + + it('matches relations by type and exact targetRef', async () => { await expect( query({ - 'metadata.tags': { $contains: { nested: 'object' } } as any, + relations: { + $contains: { + type: 'ownedBy', + targetRef: 'group:default/team-a', + }, + }, + }), + ).resolves.toEqual(['service-a', 'website-c']); + }); + + it('matches relations by type and targetRef case-insensitively', async () => { + await expect( + query({ + relations: { + $contains: { + type: 'OwnedBy', + targetRef: 'Group:Default/Team-A', + }, + }, + }), + ).resolves.toEqual(['service-a', 'website-c']); + }); + + it('handles mixed-case keys in $contains object', async () => { + await expect( + query({ + relations: { + $contains: { + TyPe: 'ownedBy', + TargetRef: 'group:default/team-a', + }, + }, + } as any), + ).resolves.toEqual(['service-a', 'website-c']); + }); + + it('matches relations by type and targetRef with $in', async () => { + await expect( + query({ + relations: { + $contains: { + type: 'ownedBy', + targetRef: { + $in: ['group:default/team-a', 'group:default/team-b'], + }, + }, + }, + }), + ).resolves.toEqual(['service-a', 'service-b', 'website-c']); + }); + + it('throws on unsupported keys in $contains object', async () => { + await expect( + query({ + relations: { + $contains: { type: 'ownedBy', badKey: 'value' }, + } as any, + }), + ).rejects.toThrow(/Unsupported key "badKey" in \$contains/); + }); + + it('throws on duplicate keys in $contains object', async () => { + await expect( + query({ + relations: { + // JSON.parse allows duplicate keys, last one wins, but we + // simulate via an object that has both casings + $contains: JSON.parse('{"type": "ownedBy", "Type": "ownedBy"}'), + }, + }), + ).rejects.toThrow(/Duplicate key "Type" in \$contains/); + }); + + it('throws on $contains object without type', async () => { + await expect( + query({ + relations: { + $contains: { targetRef: 'group:default/team-a' }, + } as any, + }), + ).rejects.toThrow(/requires a "type" string property/); + }); + + it('throws on empty $in array in targetRef', async () => { + await expect( + query({ + relations: { + $contains: { type: 'ownedBy', targetRef: { $in: [] } }, + }, + }), + ).rejects.toThrow(/Empty "\$in" array for \$contains on "relations"/); + }); + + it('throws on unsupported targetRef value', async () => { + await expect( + query({ + relations: { + $contains: { type: 'ownedBy', targetRef: ['bad'] }, + } as any, + }), + ).rejects.toThrow(/Unsupported value in \$contains for "relations"/); + }); + + it('throws on object $contains for non-relations fields', async () => { + await expect( + query({ + 'metadata.tags': { + $contains: { type: 'something' }, + } as any, }), ).rejects.toThrow( - /Non primitive forms of the \$contains operator is not supported/, + /Object form of \$contains is not supported for field/, ); }); }); diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index 27701a90ef..a44f878abd 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -191,17 +191,139 @@ function applyFieldCondition(options: { return targetQuery.andWhere(onEntityIdField, 'in', matchQuery); } - // TODO(freben): Implement this for more use cases - for example simple - // objects (resulting in a $and and appending key strings) and maybe - // a subset of relations? + // Object form of $contains - currently only supports relation-style + // objects with "type" and optional "targetRef" keys. + // + // FROM: `{ "relations": { "$contains": { "type": "ownedBy", "targetRef": "group:default/team-a" } } }` + // + // TO: search for key = "relations.ownedby" AND value = "group:default/team-a" + if (isObject(target)) { + if (key === 'relations') { + return applyContainsRelation({ + target, + targetQuery, + onEntityIdField, + knex, + }); + } + + throw new InputError( + `Object form of $contains is not supported for field "${key}"`, + ); + } + + const actual = JSON.stringify(target); throw new InputError( - 'Non primitive forms of the $contains operator is not supported', + `Unsupported $contains target for field "${key}": ${actual}`, ); } } const actual = JSON.stringify(value); throw new InputError( - `Invalid filter predicate value for field "${key}": expected a primitive value, $exists, $in, or $hasPrefix operator, but got ${actual}`, + `Invalid filter predicate value for field "${key}": expected a primitive value, $exists, $in, $hasPrefix, or $contains operator, but got ${actual}`, ); } + +/** + * Handles expressions on the form + * + * ``` + * { + * "relations": { + * "$contains": { + * "type": "ownedBy", + * "targetRef": "group:default/team-a" + * } + * } + * } + * ``` + * + * which map onto the search table's special `relation.: ` + * syntax. + * + * Only the keys "type" and "targetRef" are supported. The "type" key is + * required. If "targetRef" is omitted, it becomes an existence check for any + * relation of that type. The "targetRef" value can be a string or an `$in` + * array. + */ +function applyContainsRelation(options: { + target: Record; + targetQuery: Knex.QueryBuilder; + onEntityIdField: string; + knex: Knex; +}): Knex.QueryBuilder { + const { target: rawTarget, targetQuery, onEntityIdField, knex } = options; + + function parseStringOrIn(value: unknown): string[] { + if (typeof value === 'string') { + return [value.toLocaleLowerCase('en-US')]; + } + if ( + isObject(value) && + Object.keys(value).length === 1 && + '$in' in value && + Array.isArray(value.$in) && + value.$in.every((v): v is string => typeof v === 'string') + ) { + if (value.$in.length === 0) { + throw new InputError( + `Empty "$in" array for $contains on "relations" is not allowed`, + ); + } + return value.$in.map(v => v.toLocaleLowerCase('en-US')); + } + const actual = JSON.stringify(value); + throw new InputError( + `Unsupported value in $contains for "relations": expected a string or { "$in": [strings] }, but got ${actual}`, + ); + } + + let type: string | undefined; + let targetRef: string[] | undefined; + + for (const [rawKey, value] of Object.entries(rawTarget)) { + const key = rawKey.toLocaleLowerCase('en-US'); + + if (key === 'type') { + if (type !== undefined) { + throw new InputError( + `Duplicate key "${rawKey}" in $contains for "relations"`, + ); + } + if (typeof value !== 'string') { + throw new InputError( + `The $contains operator for "relations" requires a "type" string property`, + ); + } + type = value; + } else if (key === 'targetref') { + if (targetRef !== undefined) { + throw new InputError( + `Duplicate key "${rawKey}" in $contains for "relations"`, + ); + } + targetRef = parseStringOrIn(value); + } else { + throw new InputError( + `Unsupported key "${rawKey}" in $contains for "relations". Only "type" and "targetRef" are supported`, + ); + } + } + + if (!type) { + throw new InputError( + `The $contains operator for "relations" requires a "type" string property`, + ); + } + + const matchQuery = knex('search') + .select('search.entity_id') + .where({ key: `relations.${type.toLocaleLowerCase('en-US')}` }); + + if (targetRef) { + matchQuery.whereIn('value', targetRef); + } + + return targetQuery.andWhere(onEntityIdField, 'in', matchQuery); +} From c4aa99e8d5a7d31ee312e426ff9c9b74379a94b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sat, 21 Feb 2026 14:34:56 +0100 Subject: [PATCH 33/37] clarify changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/rich-ducks-ring.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/rich-ducks-ring.md b/.changeset/rich-ducks-ring.md index ea8a3e8016..5f046e31b2 100644 --- a/.changeset/rich-ducks-ring.md +++ b/.changeset/rich-ducks-ring.md @@ -5,6 +5,6 @@ Added predicate-based entity filtering via POST /entities/by-query endpoint. -Supports `$all`, `$any`, `$not`, `$exists`, `$in`, and `$hasPrefix` operators for expressive entity queries. Integrated into the existing `queryEntities` flow with full cursor-based pagination, permission enforcement, and `totalItems` support. +Supports `$all`, `$any`, `$not`, `$exists`, `$in`, `$hasPrefix`, and (partially) `$contains` operators for expressive entity queries. Integrated into the existing `queryEntities` flow with full cursor-based pagination, permission enforcement, and `totalItems` support. The catalog client's `queryEntities()` method automatically routes to the POST endpoint when a `query` predicate is provided. From 4a47c603e1ec2eedbe26821be292a30f41e66c21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sat, 21 Feb 2026 16:10:40 +0100 Subject: [PATCH 34/37] add docs too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- docs/features/software-catalog/api.md | 187 +++++++++++++++++++++++ packages/catalog-client/report.api.md | 2 +- packages/catalog-client/src/types/api.ts | 10 +- 3 files changed, 194 insertions(+), 5 deletions(-) diff --git a/docs/features/software-catalog/api.md b/docs/features/software-catalog/api.md index ca8b8f638d..3cd70538c2 100644 --- a/docs/features/software-catalog/api.md +++ b/docs/features/software-catalog/api.md @@ -210,6 +210,193 @@ if `prevCursor` exists, it can be used to retrieve the previous batch of entitie it isn't possible to change any of [`filter`, `orderField`, `fullTextFilter`] when passing `cursor` as query parameters, as changing any of these properties will affect pagination. If any of `filter`, `orderField`, `fullTextFilter` is specified together with `cursor`, only the latter is taken into consideration. +### `POST /entities/by-query` + +This supports the same features as the `GET` variant, but in a `POST` body to +not have to abide by URL length limits. Additionally, it supports advanced, more +expressive querying format - see below. The response format is identical. + +#### Querying by filter predicate + +You can pass in a filter predicate to select a subset of entities in the +catalog. They are comprised of an optional logical expression tree (using +`$all`, `$any`, `$not`), ending in filter sets that can have custom matchers +(e.g. `$exists`, `$in`, `$hasPrefix`, `$contains`). + +This is an example of what such a filter predicate expression might look like: + +```js +{ + "query": { + "$all": [ + { + "kind": "Component", + "spec.type": { "$in": ["service", "website"] } + }, + { + "$not": { + "metadata.annotations.backstage.io/orphan": "true" + } + } + ] + } +} +``` + +A filter set is an object whose keys are dot separated paths into an object, and +the values are either primitives (string, number, or boolean) or custom matchers +as per below. An example of a simple such filter set is: + +```js +// All of the following must be true for a given entity (there's an +// implicit AND between them) +{ + // The kind field is matched against a literal, case insensitively + "kind": "Component", + // The type field inside the spec is matched using a custom matcher, see below + "spec.type": { "$in": ["service", "website"] } +} +``` + +The root of the query is always an object, whether there is a logic expression +tree or not. Nodes with a single key that starts with a `$` sign have special +meaning. + +- `$not`: Logical negation. + + Its value must be a single expression. Example: + + ```js + // Matches entities that do NOT have kind Component + { + "$not": { + "kind": "Component", + } + } + ``` + + Note that `$not` cannot be used in a right hand side value matcher. + + ```js + // ❌ WRONG + { "kind": { "$not": "Component" } } + // ✅ CORRECT + { "$not": { "kind": "Component" } } + ``` + +- `$all`: Require that all given expressions match each entity. + + Its value must be an array of expressions. Example: + + ```js + // Matches entities that BOTH have kind Component and type website + { + "$all": [ + { "kind": "Component" }, + { "spec.type": "website" } + ] + } + ``` + + An empty array always matches every entity. + +- `$any`: Require that at least one of a set of expressions match a given entity. + + Its value must be an array of expressions. Example: + + ```js + // Matches entities that EITHER have kind Component or type website + { + "$any": [ + { "kind": "Component" }, + { "spec.type": "website" } + ] + } + ``` + + An empty array never matches anything. + +- `$exists`: Assert on the existence of fields. + + Its value is either `true`, meaning that the field must exist on the entity + (no matter what its value), or `false`, meaning that it must not exist. + Example: + + ```js + // Matches entities that DO NOT have that annotation, ignoring what the + // value might be + { + "metadata.annotations.backstage.io/orphan": { + "$exists": false + }, + } + ``` + +- `$in`: Assert that a field has any of a set of primitive values. + + Its value must be an array of string, number, and/or boolean values. Example: + + ```js + // Matches entities whose type is EITHER service or website + { + "spec.type": { + "$in": ["service", "website"] + } + } + ``` + + The matching is case insensitive. An empty array never matches anything. + +- `$hasPrefix`: Assert that a field is a string that starts with a certain prefix text. + + Its value is a string. Example: + + ```js + // Matches entities whose project slug annotation starts with "backstage/" + { + "metadata.annotations.github.com/project-slug": { + "$hasPrefix": "backstage/" + } + } + ``` + + The matching is case insensitive, and captures both exact matches and strings + that start with the given prefix. + +- `$contains`: Assert that an array contains an element that matches the given expression. + + There is only limited support for this matcher. One use case is for relations: + + ```js + { + // Specifically type and (optionally) targetRef supported, and only + // with equality or "$in" for the targetRef + "relations": { + "$contains": { + "type": "ownedBy", + "targetRef": { + "$in": ["user:default/foo", "group:default/bar"] + } + } + } + } + ``` + + The other use case is for arrays where you match with a primitive value, such + as labels. Example: + + ```js + { + // Works for any field, as long as the value is a primitive + // (either string, number, or boolean) + "metadata.labels": { + "$contains": "java" + } + } + ``` + + In every case, the matching is case insensitive. + ### `GET /entities` Lists entities. diff --git a/packages/catalog-client/report.api.md b/packages/catalog-client/report.api.md index cc83cce443..0d71e6bc59 100644 --- a/packages/catalog-client/report.api.md +++ b/packages/catalog-client/report.api.md @@ -8,7 +8,7 @@ import type { AnalyzeLocationResponse } from '@backstage/plugin-catalog-common'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { Entity } from '@backstage/catalog-model'; import type { FilterPredicate } from '@backstage/filter-predicates'; -import { SerializedError } from '@backstage/errors'; +import type { SerializedError } from '@backstage/errors'; // @public export type AddLocationRequest = { diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index 64d01c0972..c39c0c872e 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import { CompoundEntityRef, Entity } from '@backstage/catalog-model'; -import { SerializedError } from '@backstage/errors'; +import type { CompoundEntityRef, Entity } from '@backstage/catalog-model'; +import type { SerializedError } from '@backstage/errors'; import type { AnalyzeLocationRequest, AnalyzeLocationResponse, @@ -438,7 +438,9 @@ export type QueryEntitiesInitialRequest = { */ filter?: EntityFilterQuery; /** - * Predicate-based filter with logical operators ($all, $any, $not, $exists, $in, $hasPrefix). + * Predicate-based filter with operators for logical expressions (`$all`, + * `$any`, and `$not`) and matching (`$exists`, `$in`, `$hasPrefix`, and + * (partially) `$contains`). * * @example * ```typescript @@ -446,7 +448,7 @@ export type QueryEntitiesInitialRequest = { * query: { * $all: [ * { kind: 'component' }, - * { 'spec.type': 'service' } + * { 'spec.type': { $in: ['service', 'website'] } } * ] * } * } From 0a4b3a16aed0c96ca57a4340a7e8735d3d274b5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 23 Feb 2026 09:54:43 +0100 Subject: [PATCH 35/37] Update docs/features/software-catalog/api.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Fredrik Adelöw --- docs/features/software-catalog/api.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/features/software-catalog/api.md b/docs/features/software-catalog/api.md index 3cd70538c2..97f579c5a3 100644 --- a/docs/features/software-catalog/api.md +++ b/docs/features/software-catalog/api.md @@ -383,20 +383,18 @@ meaning. ``` The other use case is for arrays where you match with a primitive value, such - as labels. Example: + as tags. Example: ```js { - // Works for any field, as long as the value is a primitive - // (either string, number, or boolean) - "metadata.labels": { + // Works for array fields whose items are primitive values + // (typically strings, but numbers and booleans are also supported) + "metadata.tags": { "$contains": "java" } } ``` - In every case, the matching is case insensitive. - ### `GET /entities` Lists entities. From b06bcdcb353ddb93c2291f11f1e30fd37c55a2e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 23 Feb 2026 13:54:29 +0100 Subject: [PATCH 36/37] transfer filters across to the query too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- packages/catalog-client/src/CatalogClient.ts | 38 +++++++-- packages/catalog-client/src/utils.test.ts | 82 +++++++++++++++++++- packages/catalog-client/src/utils.ts | 45 +++++++++++ 3 files changed, 159 insertions(+), 6 deletions(-) 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 From 63ad13904b0bb9e72aa077e621cb9de3ee5f679c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 23 Feb 2026 16:43:36 +0100 Subject: [PATCH 37/37] do not use the very particular 'true' array syntax, but instead the normal one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../request/applyPredicateEntityFilterToQuery.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index a44f878abd..d915b8c4b5 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -175,18 +175,18 @@ function applyFieldCondition(options: { // // FROM: `{ "a": { "$contains": "b" } }` // - // TO: `{ "a.b": "true" }` + // TO: `{ "a": "b" }` // // The search table does not actually show us that "a" was an array to // begin with, so this can mistakenly also match on an object that had a - // "b" key with a true value. We'll consider that an acceptable tradeoff - // though. + // "b" key with a primitive value. We'll consider that an acceptable + // tradeoff though. if (isPrimitive(target)) { const matchQuery = knex('search') .select('search.entity_id') .where({ - key: `${key}.${String(target).toLocaleLowerCase('en-US')}`, - value: 'true', + key, + value: String(target).toLocaleLowerCase('en-US'), }); return targetQuery.andWhere(onEntityIdField, 'in', matchQuery); }