From 02b33b0650f4b0707dd6c9d3c54ffa231b585511 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 17 Feb 2026 10:10:53 +0100 Subject: [PATCH] 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, + }; +}