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 <ben@blam.sh>
This commit is contained in:
benjdlambert
2026-02-17 10:10:53 +01:00
parent 51e23eb73e
commit 02b33b0650
46 changed files with 364 additions and 1437 deletions
+3 -8
View File
@@ -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.
+2 -52
View File
@@ -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<EntityPredicate>;
}
// @public
export interface EntityPredicateAny {
// (undocumented)
$any: Array<EntityPredicate>;
}
// @public
export interface EntityPredicateExists {
// (undocumented)
$exists: boolean;
}
// @public
export interface EntityPredicateIn {
// (undocumented)
$in: Array<string | number | boolean>;
}
// @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;
@@ -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()
+29 -41
View File
@@ -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<QueryEntitiesResponse> {
const params: {
limit?: number;
offset?: number;
after?: string;
orderField?: string[];
cursor?: string;
fields?: string[];
} = {};
let query;
const body: Record<string, unknown> = {};
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,
};
}
@@ -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<string>;
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 &#x60;orderField&#x60; query parameters to affect that ordering. For example, to return entities by their name: &#x60;/entities/by-query?orderField&#x3D;metadata.name,asc&#x60; Each parameter can be followed by &#x60;asc&#x60; for ascending lexicographical order or &#x60;desc&#x60; for descending (reverse) lexicographical order.
* @param after - Pointer to the previous page of results.
*/
public async queryEntitiesByPredicate(
// @ts-ignore
request: QueryEntitiesByPredicate,
options?: RequestOptions,
): Promise<TypedResponse<QueryEntitiesByPredicate200Response>> {
): Promise<TypedResponse<EntitiesQueryResponse>> {
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: {
@@ -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 };
@@ -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<EntityPredicate>;
}
@@ -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<EntityPredicate>;
}
@@ -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<EntityPredicateInInInner>;
}
@@ -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;
@@ -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;
}
@@ -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;
@@ -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<Entity>;
pageInfo: QueryEntitiesByPredicate200ResponsePageInfo;
}
@@ -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;
}
@@ -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<string>;
fullTextFilter?: QueryEntitiesByPredicateRequestFullTextFilter;
fields?: Array<string>;
/**
* A type representing all allowed JSON object values.
*/
query?: { [key: string]: any };
}
@@ -19,9 +19,9 @@
// ******************************************************************
/**
* Check if field exists
* @public
*/
export interface EntityPredicateExists {
$exists: boolean;
export interface QueryEntitiesByPredicateRequestFullTextFilter {
term?: string;
fields?: Array<string>;
}
@@ -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';
+1 -1
View File
@@ -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.
@@ -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';
@@ -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<EntityPredicate>;
}
/**
* At least one condition must match (OR logic).
*
* @public
*/
export interface EntityPredicateAny {
$any: Array<EntityPredicate>;
}
/**
* 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<string | number | boolean>;
}
+1 -2
View File
@@ -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;
}
@@ -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<QueryEntitiesResponse>;
/**
* Fetch entities using predicate-based filters.
*
* @param request - Request options with predicate filter
*/
queryEntitiesByPredicate(
request?: EntityPredicateRequest,
): Promise<EntitiesResponse>;
/**
* Removes a single entity.
*
+24 -128
View File
@@ -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
@@ -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<string>;
after?: string;
};
response: QueryEntitiesByPredicate200Response | Error | Error;
response: EntitiesQueryResponse | Error | Error;
};
/**
* @public
@@ -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 };
@@ -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<EntityPredicate>;
}
@@ -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<EntityPredicate>;
}
@@ -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<EntityPredicateInInInner>;
}
@@ -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;
@@ -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;
}
@@ -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;
@@ -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<Entity>;
pageInfo: QueryEntitiesByPredicate200ResponsePageInfo;
}
@@ -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;
}
@@ -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<string>;
fullTextFilter?: QueryEntitiesByPredicateRequestFullTextFilter;
fields?: Array<string>;
/**
* A type representing all allowed JSON object values.
*/
query?: { [key: string]: any };
}
@@ -19,9 +19,9 @@
// ******************************************************************
/**
* Check if field exists
* @public
*/
export interface EntityPredicateExists {
$exists: boolean;
export interface QueryEntitiesByPredicateRequestFullTextFilter {
term?: string;
fields?: Array<string>;
}
@@ -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';
@@ -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',
},
},
},
@@ -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(),
@@ -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<EntitiesResponse> {
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 },
@@ -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<EntitiesResponse> {
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<DbFinalEntitiesRow>('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<EntitiesBatchResponse> {
@@ -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,
@@ -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(),
@@ -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,
}),
});
@@ -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<string[]> {
const q =
knex<DbFinalEntitiesRow>('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
@@ -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;
}
@@ -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<DbSearchRow>('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;
@@ -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<QueryEntitiesByPredicateRequest>,
): 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,
};
}