Merge pull request #32874 from backstage/benjdlambert/fix-predicate-review-feedback

`feat(catalog)`: support entity predicate filtering
This commit is contained in:
Fredrik Adelöw
2026-02-25 14:51:38 +01:00
committed by GitHub
35 changed files with 2766 additions and 49 deletions
+10
View File
@@ -0,0 +1,10 @@
---
'@backstage/catalog-client': minor
'@backstage/plugin-catalog-backend': minor
---
Added predicate-based entity filtering via POST /entities/by-query endpoint.
Supports `$all`, `$any`, `$not`, `$exists`, `$in`, `$hasPrefix`, and (partially) `$contains` operators for expressive entity queries. Integrated into the existing `queryEntities` flow with full cursor-based pagination, permission enforcement, and `totalItems` support.
The catalog client's `queryEntities()` method automatically routes to the POST endpoint when a `query` predicate is provided.
+185
View File
@@ -210,6 +210,191 @@ if `prevCursor` exists, it can be used to retrieve the previous batch of entitie
it isn't possible to change any of [`filter`, `orderField`, `fullTextFilter`] when passing `cursor` as query parameters,
as changing any of these properties will affect pagination. If any of `filter`, `orderField`, `fullTextFilter` is specified together with `cursor`, only the latter is taken into consideration.
### `POST /entities/by-query`
This supports the same features as the `GET` variant, but in a `POST` body to
not have to abide by URL length limits. Additionally, it supports advanced, more
expressive querying format - see below. The response format is identical.
#### Querying by filter predicate
You can pass in a filter predicate to select a subset of entities in the
catalog. They are comprised of an optional logical expression tree (using
`$all`, `$any`, `$not`), ending in filter sets that can have custom matchers
(e.g. `$exists`, `$in`, `$hasPrefix`, `$contains`).
This is an example of what such a filter predicate expression might look like:
```js
{
"query": {
"$all": [
{
"kind": "Component",
"spec.type": { "$in": ["service", "website"] }
},
{
"$not": {
"metadata.annotations.backstage.io/orphan": "true"
}
}
]
}
}
```
A filter set is an object whose keys are dot separated paths into an object, and
the values are either primitives (string, number, or boolean) or custom matchers
as per below. An example of a simple such filter set is:
```js
// All of the following must be true for a given entity (there's an
// implicit AND between them)
{
// The kind field is matched against a literal, case insensitively
"kind": "Component",
// The type field inside the spec is matched using a custom matcher, see below
"spec.type": { "$in": ["service", "website"] }
}
```
The root of the query is always an object, whether there is a logic expression
tree or not. Nodes with a single key that starts with a `$` sign have special
meaning.
- `$not`: Logical negation.
Its value must be a single expression. Example:
```js
// Matches entities that do NOT have kind Component
{
"$not": {
"kind": "Component",
}
}
```
Note that `$not` cannot be used in a right hand side value matcher.
```js
// ❌ WRONG
{ "kind": { "$not": "Component" } }
// ✅ CORRECT
{ "$not": { "kind": "Component" } }
```
- `$all`: Require that all given expressions match each entity.
Its value must be an array of expressions. Example:
```js
// Matches entities that BOTH have kind Component and type website
{
"$all": [
{ "kind": "Component" },
{ "spec.type": "website" }
]
}
```
An empty array always matches every entity.
- `$any`: Require that at least one of a set of expressions match a given entity.
Its value must be an array of expressions. Example:
```js
// Matches entities that EITHER have kind Component or type website
{
"$any": [
{ "kind": "Component" },
{ "spec.type": "website" }
]
}
```
An empty array never matches anything.
- `$exists`: Assert on the existence of fields.
Its value is either `true`, meaning that the field must exist on the entity
(no matter what its value), or `false`, meaning that it must not exist.
Example:
```js
// Matches entities that DO NOT have that annotation, ignoring what the
// value might be
{
"metadata.annotations.backstage.io/orphan": {
"$exists": false
},
}
```
- `$in`: Assert that a field has any of a set of primitive values.
Its value must be an array of string, number, and/or boolean values. Example:
```js
// Matches entities whose type is EITHER service or website
{
"spec.type": {
"$in": ["service", "website"]
}
}
```
The matching is case insensitive. An empty array never matches anything.
- `$hasPrefix`: Assert that a field is a string that starts with a certain prefix text.
Its value is a string. Example:
```js
// Matches entities whose project slug annotation starts with "backstage/"
{
"metadata.annotations.github.com/project-slug": {
"$hasPrefix": "backstage/"
}
}
```
The matching is case insensitive, and captures both exact matches and strings
that start with the given prefix.
- `$contains`: Assert that an array contains an element that matches the given expression.
There is only limited support for this matcher. One use case is for relations:
```js
{
// Specifically type and (optionally) targetRef supported, and only
// with equality or "$in" for the targetRef
"relations": {
"$contains": {
"type": "ownedBy",
"targetRef": {
"$in": ["user:default/foo", "group:default/bar"]
}
}
}
}
```
The other use case is for arrays where you match with a primitive value, such
as tags. Example:
```js
{
// Works for array fields whose items are primitive values
// (typically strings, but numbers and booleans are also supported)
"metadata.tags": {
"$contains": "java"
}
}
```
### `GET /entities`
Lists entities.
+3 -2
View File
@@ -7,8 +7,8 @@ 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 { SerializedError } from '@backstage/errors';
import type { FilterPredicate } from '@backstage/filter-predicates';
import type { SerializedError } from '@backstage/errors';
// @public
export type AddLocationRequest = {
@@ -320,6 +320,7 @@ export type QueryEntitiesInitialRequest = {
limit?: number;
offset?: number;
filter?: EntityFilterQuery;
query?: FilterPredicate;
orderFields?: EntityOrderQuery;
fullTextFilter?: {
term: string;
@@ -540,6 +540,350 @@ describe('CatalogClient', () => {
});
});
describe('queryEntities with predicate-based queries (POST endpoint)', () => {
const defaultResponse = {
items: [
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'service-1',
namespace: 'default',
},
spec: {
type: 'service',
owner: 'team-a',
},
},
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'service-2',
namespace: 'default',
},
spec: {
type: 'service',
owner: 'team-b',
},
},
],
totalItems: 2,
pageInfo: {},
};
it('should use POST endpoint when query is provided', async () => {
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
expect(req.method).toBe('POST');
expect(req.body).toMatchObject({
query: { kind: 'component' },
limit: 20,
});
return res(ctx.json(defaultResponse));
});
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
const response = await client.queryEntities({
query: { kind: 'component' },
limit: 20,
});
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
expect(response.items).toEqual(defaultResponse.items);
expect(response.totalItems).toBe(2);
});
it('should support $all operator', async () => {
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
expect(req.body).toMatchObject({
query: {
$all: [{ kind: 'component' }, { 'spec.type': 'service' }],
},
});
return res(ctx.json(defaultResponse));
});
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
await client.queryEntities({
query: {
$all: [{ kind: 'component' }, { 'spec.type': 'service' }],
},
});
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
});
it('should support $any operator', async () => {
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
expect(req.body).toMatchObject({
query: {
$any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }],
},
});
return res(ctx.json(defaultResponse));
});
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
await client.queryEntities({
query: {
$any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }],
},
});
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
});
it('should support $not operator', async () => {
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
expect(req.body).toMatchObject({
query: {
$not: { 'spec.lifecycle': 'experimental' },
},
});
return res(ctx.json(defaultResponse));
});
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
await client.queryEntities({
query: {
$not: { 'spec.lifecycle': 'experimental' },
},
});
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
});
it('should support $exists operator', async () => {
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
expect(req.body).toMatchObject({
query: {
'spec.owner': { $exists: true },
},
});
return res(ctx.json(defaultResponse));
});
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
await client.queryEntities({
query: {
'spec.owner': { $exists: true },
},
});
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
});
it('should support $in operator', async () => {
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
expect(req.body).toMatchObject({
query: {
'spec.owner': { $in: ['team-a', 'team-b', 'team-c'] },
},
});
return res(ctx.json(defaultResponse));
});
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
await client.queryEntities({
query: {
'spec.owner': { $in: ['team-a', 'team-b', 'team-c'] },
},
});
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
});
it('should support complex nested predicates', async () => {
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
expect(req.body).toMatchObject({
query: {
$all: [
{ kind: 'component' },
{
$any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }],
},
{
$not: {
'spec.lifecycle': 'experimental',
},
},
],
},
});
return res(ctx.json(defaultResponse));
});
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
await client.queryEntities({
query: {
$all: [
{ kind: 'component' },
{
$any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }],
},
{
$not: {
'spec.lifecycle': 'experimental',
},
},
],
},
});
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
});
it('should send orderFields with correct format', async () => {
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
expect(req.body.orderBy).toEqual([
{ field: 'metadata.name', order: 'asc' },
]);
return res(ctx.json(defaultResponse));
});
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
await client.queryEntities({
query: { kind: 'component' },
orderFields: { field: 'metadata.name', order: 'asc' },
});
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
});
it('should send multiple orderFields with correct format', async () => {
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
expect(req.body.orderBy).toEqual([
{ field: 'metadata.name', order: 'asc' },
{ field: 'spec.type', order: 'desc' },
]);
return res(ctx.json(defaultResponse));
});
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
await client.queryEntities({
query: { kind: 'component' },
orderFields: [
{ field: 'metadata.name', order: 'asc' },
{ field: 'spec.type', order: 'desc' },
],
});
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
});
it('should send limit and offset parameters in the body', async () => {
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
expect(req.body.limit).toBe(50);
return res(ctx.json(defaultResponse));
});
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
await client.queryEntities({
query: { kind: 'component' },
limit: 50,
});
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
});
it('should paginate using POST when cursor contains a query', async () => {
// Simulate a cursor that contains a query predicate (as the server would encode it)
const cursorPayload = Buffer.from(
JSON.stringify({
orderFields: [],
orderFieldValues: [],
isPrevious: false,
query: { kind: 'component' },
totalItems: 100,
}),
).toString('base64');
const page2Response = {
items: [
{
apiVersion: '1',
kind: 'Component',
metadata: { name: 'service-3', namespace: 'default' },
},
],
totalItems: 100,
pageInfo: {},
};
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
expect(req.method).toBe('POST');
expect(req.body).toMatchObject({ cursor: cursorPayload });
return res(ctx.json(page2Response));
});
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
const response = await client.queryEntities({
cursor: cursorPayload,
});
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
expect(response.items).toEqual(page2Response.items);
expect(response.totalItems).toBe(100);
});
it('should use GET endpoint for cursor without query', async () => {
// A cursor that does NOT contain a query field should go to GET
const cursorPayload = Buffer.from(
JSON.stringify({
orderFields: [],
orderFieldValues: [],
isPrevious: false,
totalItems: 50,
}),
).toString('base64');
const mockedGetEndpoint = jest.fn().mockImplementation((_req, res, ctx) =>
res(
ctx.json({
items: [],
totalItems: 50,
pageInfo: {},
}),
),
);
const mockedPostEndpoint = jest.fn();
server.use(
rest.get(`${mockBaseUrl}/entities/by-query`, mockedGetEndpoint),
rest.post(`${mockBaseUrl}/entities/by-query`, mockedPostEndpoint),
);
await client.queryEntities({ cursor: cursorPayload });
expect(mockedGetEndpoint).toHaveBeenCalledTimes(1);
expect(mockedPostEndpoint).not.toHaveBeenCalled();
});
it('should handle errors from POST endpoint', async () => {
const mockedEndpoint = jest
.fn()
.mockImplementation((_req, res, ctx) => res(ctx.status(400)));
server.use(rest.post(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
await expect(() =>
client.queryEntities({ query: { kind: 'component' } }),
).rejects.toThrow(/Request failed with 400/);
});
});
describe('streamEntities', () => {
const defaultResponse: QueryEntitiesResponse = {
items: [
+107 -6
View File
@@ -20,7 +20,8 @@ import {
parseEntityRef,
stringifyLocationRef,
} from '@backstage/catalog-model';
import { ResponseError } from '@backstage/errors';
import { InputError, ResponseError } from '@backstage/errors';
import { FilterPredicate } from '@backstage/filter-predicates';
import {
AddLocationRequest,
AddLocationResponse,
@@ -46,10 +47,17 @@ import {
StreamEntitiesRequest,
ValidateEntityResponse,
} from './types/api';
import { isQueryEntitiesInitialRequest, splitRefsIntoChunks } from './utils';
import {
convertFilterToPredicate,
isQueryEntitiesInitialRequest,
splitRefsIntoChunks,
cursorContainsQuery,
} from './utils';
import {
DefaultApiClient,
GetEntitiesByQuery,
GetLocationsByQueryRequest,
QueryEntitiesByPredicateRequest,
TypedResponse,
} from './schema/openapi';
import type {
@@ -266,11 +274,26 @@ export class CatalogClient implements CatalogApi {
request: QueryEntitiesRequest = {},
options?: CatalogRequestOptions,
): Promise<QueryEntitiesResponse> {
const params: Partial<
Parameters<typeof this.apiClient.getEntitiesByQuery>[0]['query']
> = {};
const isInitialRequest = isQueryEntitiesInitialRequest(request);
if (isQueryEntitiesInitialRequest(request)) {
// Route to POST endpoint if query predicate is provided (initial request)
if (isInitialRequest && request.query) {
return this.queryEntitiesByPredicate(request, options);
}
// Route to POST endpoint if cursor contains a query predicate (pagination)
// TODO(freben): It's costly and non-opaque to have to introspect the cursor
// like this. It should be refactored in the future to not need this.
// Suggestion: make the GET and POST endpoints understand the same cursor
// format, and pick which one to call ONLY based on whether the cursor size
// risks hitting url length limits
if (!isInitialRequest && cursorContainsQuery(request.cursor)) {
return this.queryEntitiesByPredicate(request, options);
}
const params: Partial<GetEntitiesByQuery['query']> = {};
if (isInitialRequest) {
const {
fields = [],
filter,
@@ -320,6 +343,84 @@ export class CatalogClient implements CatalogApi {
);
}
/**
* Query entities using predicate-based filters (POST endpoint).
* @internal
*/
private async queryEntitiesByPredicate(
request: QueryEntitiesRequest,
options?: CatalogRequestOptions,
): Promise<QueryEntitiesResponse> {
const body: QueryEntitiesByPredicateRequest = {};
if (isQueryEntitiesInitialRequest(request)) {
const {
filter,
query,
limit,
offset,
orderFields,
fullTextFilter,
fields,
} = request;
let filterPredicate: FilterPredicate | undefined;
if (query !== undefined) {
if (
typeof query !== 'object' ||
query === null ||
Array.isArray(query)
) {
throw new InputError('Query must be an object');
}
filterPredicate = query;
}
if (filter !== undefined) {
const converted = convertFilterToPredicate(filter);
filterPredicate = filterPredicate
? { $all: [filterPredicate, converted] }
: converted;
}
if (filterPredicate !== undefined) {
body.query = filterPredicate as unknown as { [key: string]: any };
}
if (limit !== undefined) {
body.limit = limit;
}
if (offset !== undefined) {
body.offset = offset;
}
if (orderFields !== undefined) {
body.orderBy = [orderFields].flat();
}
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;
}
}
const res = await this.requestRequired(
await this.apiClient.queryEntitiesByPredicate({ body }, options),
);
return {
items: res.items,
totalItems: res.totalItems,
pageInfo: res.pageInfo,
};
}
/**
* {@inheritdoc CatalogApi.getEntityByRef}
*/
@@ -29,6 +29,7 @@ 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 { QueryEntitiesByPredicateRequest } from '../models/QueryEntitiesByPredicateRequest.model';
import { RefreshEntityRequest } from '../models/RefreshEntityRequest.model';
import { ValidateEntityRequest } from '../models/ValidateEntityRequest.model';
import { AnalyzeLocationRequest } from '../models/AnalyzeLocationRequest.model';
@@ -139,6 +140,12 @@ export type GetEntityFacets = {
filter?: Array<string>;
};
};
/**
* @public
*/
export type QueryEntitiesByPredicate = {
body: QueryEntitiesByPredicateRequest;
};
/**
* @public
*/
@@ -449,6 +456,31 @@ export class DefaultApiClient {
});
}
/**
* Query entities using predicate-based filters.
* @param queryEntitiesByPredicateRequest -
*/
public async queryEntitiesByPredicate(
// @ts-ignore
request: QueryEntitiesByPredicate,
options?: RequestOptions,
): Promise<TypedResponse<EntitiesQueryResponse>> {
const baseUrl = await this.discoveryApi.getBaseUrl(pluginId);
const uriTemplate = `/entities/by-query`;
const uri = parser.parse(uriTemplate).expand({});
return await this.fetchApi.fetch(`${baseUrl}${uri}`, {
headers: {
'Content-Type': 'application/json',
...(options?.token && { Authorization: `Bearer ${options?.token}` }),
},
method: 'POST',
body: JSON.stringify(request.body),
});
}
/**
* Refresh the entity related to entityRef.
* @param refreshEntityRequest -
@@ -0,0 +1,38 @@
/*
* 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 { QueryEntitiesByPredicateRequestFullTextFilter } from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model';
import { QueryEntitiesByPredicateRequestOrderByInner } from '../models/QueryEntitiesByPredicateRequestOrderByInner.model';
/**
* @public
*/
export interface QueryEntitiesByPredicateRequest {
cursor?: string;
limit?: number;
offset?: number;
orderBy?: Array<QueryEntitiesByPredicateRequestOrderByInner>;
fullTextFilter?: QueryEntitiesByPredicateRequestFullTextFilter;
fields?: Array<string>;
/**
* A type representing all allowed JSON object values.
*/
query?: { [key: string]: any };
}
@@ -0,0 +1,27 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
/**
* @public
*/
export interface QueryEntitiesByPredicateRequestFullTextFilter {
term?: string;
fields?: Array<string>;
}
@@ -0,0 +1,34 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
/**
* @public
*/
export interface QueryEntitiesByPredicateRequestOrderByInner {
field: string;
order: QueryEntitiesByPredicateRequestOrderByInnerOrderEnum;
}
/**
* @public
*/
export type QueryEntitiesByPredicateRequestOrderByInnerOrderEnum =
| 'asc'
| 'desc';
@@ -45,6 +45,9 @@ export * from '../models/LocationsQueryResponse.model';
export * from '../models/LocationsQueryResponsePageInfo.model';
export * from '../models/ModelError.model';
export * from '../models/NullableEntity.model';
export * from '../models/QueryEntitiesByPredicateRequest.model';
export * from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model';
export * from '../models/QueryEntitiesByPredicateRequestOrderByInner.model';
export * from '../models/RecursivePartialEntity.model';
export * from '../models/RecursivePartialEntityMeta.model';
export * from '../models/RecursivePartialEntityMetaAllOf.model';
@@ -683,6 +683,83 @@ describe('InMemoryCatalogClient', () => {
]);
});
it('filters by predicate query', async () => {
const client = new InMemoryCatalogClient({ entities });
const result = await client.queryEntities({
query: { kind: 'CustomKind' },
});
expect(result.items).toEqual([entity1, entity3]);
expect(result.totalItems).toBe(2);
});
it('filters by predicate query with $all', async () => {
const client = new InMemoryCatalogClient({ entities });
const result = await client.queryEntities({
query: {
$all: [{ kind: 'CustomKind' }, { 'spec.type': 'service' }],
},
});
expect(result.items).toEqual([entity1, entity3]);
});
it('filters by predicate query with $any', async () => {
const client = new InMemoryCatalogClient({ entities });
const result = await client.queryEntities({
query: {
$any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }],
},
});
expect(result.items).toEqual([entity1, entity3, entity4]);
});
it('filters by predicate query with $not', async () => {
const client = new InMemoryCatalogClient({ entities });
const result = await client.queryEntities({
query: {
$all: [
{ kind: 'CustomKind' },
{ $not: { 'spec.lifecycle': 'production' } },
],
},
});
expect(result.items).toEqual([]);
});
it('filters by predicate query with $in', async () => {
const client = new InMemoryCatalogClient({ entities });
const result = await client.queryEntities({
query: { 'spec.type': { $in: ['service', 'library'] } },
});
expect(result.items).toEqual([entity1, entity2, entity3]);
});
it('filters by predicate query with $exists', async () => {
const client = new InMemoryCatalogClient({ entities });
const result = await client.queryEntities({
query: { 'spec.lifecycle': { $exists: false } },
});
expect(result.items).toEqual([entity4]);
});
it('preserves query predicate through cursor pagination', async () => {
const client = new InMemoryCatalogClient({ entities });
const page1 = await client.queryEntities({
query: { kind: 'CustomKind' },
orderFields: { field: 'metadata.name', order: 'asc' },
limit: 1,
});
expect(page1.items.map(e => e.metadata.name)).toEqual(['e1']);
expect(page1.totalItems).toBe(2);
expect(page1.pageInfo.nextCursor).toBeDefined();
const page2 = await client.queryEntities({
cursor: page1.pageInfo.nextCursor!,
limit: 1,
});
expect(page2.items.map(e => e.metadata.name)).toEqual(['e3']);
expect(page2.pageInfo.nextCursor).toBeUndefined();
});
it('throws InputError for invalid cursor', async () => {
const client = new InMemoryCatalogClient({ entities });
await expect(
@@ -51,6 +51,10 @@ import {
NotFoundError,
NotImplementedError,
} from '@backstage/errors';
import {
FilterPredicate,
filterPredicateToFilterFunction,
} from '@backstage/filter-predicates';
import lodash from 'lodash';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { traverse } from '../../../../plugins/catalog-backend/src/database/operations/stitcher/buildEntitySearch';
@@ -373,6 +377,7 @@ export class InMemoryCatalogClient implements CatalogApi {
): Promise<QueryEntitiesResponse> {
// Decode query parameters from cursor or from the request directly
let filter: EntityFilterQuery | undefined;
let query: FilterPredicate | undefined;
let orderFields: EntityOrderQuery | undefined;
let fullTextFilter: { term: string; fields?: string[] } | undefined;
let offset: number;
@@ -386,12 +391,14 @@ export class InMemoryCatalogClient implements CatalogApi {
throw new InputError('Invalid cursor');
}
filter = deserializeFilter(c.filter as any[]);
query = c.query as FilterPredicate | undefined;
orderFields = c.orderFields as EntityOrderQuery | undefined;
fullTextFilter = c.fullTextFilter as typeof fullTextFilter;
offset = c.offset as number;
limit = request.limit;
} else {
filter = request?.filter;
query = request?.query;
orderFields = request?.orderFields;
fullTextFilter = request?.fullTextFilter;
offset = request?.offset ?? 0;
@@ -401,6 +408,11 @@ export class InMemoryCatalogClient implements CatalogApi {
// Apply filter
let items = this.#entities.filter(createFilter(filter));
// Apply predicate-based query filter
if (query) {
items = items.filter(filterPredicateToFilterFunction(query));
}
// Apply full-text filter, defaulting to the sort field or metadata.uid
if (fullTextFilter) {
const orderFieldsList = orderFields ? [orderFields].flat() : [];
@@ -432,6 +444,7 @@ export class InMemoryCatalogClient implements CatalogApi {
const cursorBase = {
filter: serializeFilter(filter),
query,
orderFields,
fullTextFilter,
totalItems,
+39 -7
View File
@@ -14,13 +14,13 @@
* limitations under the License.
*/
import { CompoundEntityRef, Entity } from '@backstage/catalog-model';
import { SerializedError } from '@backstage/errors';
import type { CompoundEntityRef, Entity } from '@backstage/catalog-model';
import type { SerializedError } from '@backstage/errors';
import type {
AnalyzeLocationRequest,
AnalyzeLocationResponse,
} 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.
@@ -418,16 +418,43 @@ export type QueryEntitiesRequest =
* The method takes this type in an initial pagination request,
* when requesting the first batch of entities.
*
* The properties filter, sortField, query and sortFieldOrder, are going
* The properties filter, query, sortField and sortFieldOrder, are going
* to be immutable for the entire lifecycle of the following requests.
*
* @remarks
*
* Either `filter` or `query` can be provided, or even both:
* - `filter`: Uses the traditional key-value filter syntax (GET endpoint)
* - `query`: Uses the predicate-based filter syntax with logical operators (POST endpoint)
*
* @public
*/
export type QueryEntitiesInitialRequest = {
fields?: string[];
limit?: number;
offset?: number;
/**
* Traditional key-value based filter.
*/
filter?: EntityFilterQuery;
/**
* Predicate-based filter with operators for logical expressions (`$all`,
* `$any`, and `$not`) and matching (`$exists`, `$in`, `$hasPrefix`, and
* (partially) `$contains`).
*
* @example
* ```typescript
* {
* query: {
* $all: [
* { kind: 'component' },
* { 'spec.type': { $in: ['service', 'website'] } }
* ]
* }
* }
* ```
*/
query?: FilterPredicate;
orderFields?: EntityOrderQuery;
fullTextFilter?: {
term: string;
@@ -567,6 +594,7 @@ export interface CatalogApi {
* const response = await catalogClient.queryEntities({
* filter: [{ kind: 'group' }],
* limit: 20,
* fields: ['metadata', 'kind'],
* fullTextFilter: {
* term: 'A',
* },
@@ -583,11 +611,15 @@ export interface CatalogApi {
*
* ```
* const secondBatchResponse = await catalogClient
* .queryEntities({ cursor: response.nextCursor });
* .queryEntities({
* cursor: response.nextCursor,
* limit: 20,
* fields: ['metadata', 'kind'],
* });
* ```
*
* secondBatchResponse will contain the next batch of (maximum) 20 entities,
* together with a prevCursor property, useful to fetch the previous batch.
* `secondBatchResponse` will contain the next batch of (maximum) 20 entities,
* together with a `prevCursor` property, useful to fetch the previous batch.
*
* @public
*
+81 -1
View File
@@ -14,7 +14,87 @@
* limitations under the License.
*/
import { splitRefsIntoChunks } from './utils';
import { CATALOG_FILTER_EXISTS } from './types/api';
import { convertFilterToPredicate, splitRefsIntoChunks } from './utils';
describe('convertFilterToPredicate', () => {
it('converts a single string value', () => {
expect(convertFilterToPredicate({ kind: 'component' })).toEqual({
kind: 'component',
});
});
it('converts multiple keys into $all', () => {
expect(
convertFilterToPredicate({
kind: 'component',
'spec.type': 'service',
}),
).toEqual({
$all: [{ kind: 'component' }, { 'spec.type': 'service' }],
});
});
it('converts an array of string values into $in', () => {
expect(
convertFilterToPredicate({ 'spec.type': ['service', 'website'] }),
).toEqual({
'spec.type': { $in: ['service', 'website'] },
});
});
it('converts CATALOG_FILTER_EXISTS into $exists', () => {
expect(
convertFilterToPredicate({ 'spec.owner': CATALOG_FILTER_EXISTS }),
).toEqual({
'spec.owner': { $exists: true },
});
});
it('converts an array of records into $any (OR)', () => {
expect(
convertFilterToPredicate([{ kind: 'component' }, { kind: 'api' }]),
).toEqual({
$any: [{ kind: 'component' }, { kind: 'api' }],
});
});
it('converts array of records with multiple keys each', () => {
expect(
convertFilterToPredicate([
{ kind: 'component', 'spec.type': 'service' },
{ kind: 'api' },
]),
).toEqual({
$any: [
{ $all: [{ kind: 'component' }, { 'spec.type': 'service' }] },
{ kind: 'api' },
],
});
});
it('treats CATALOG_FILTER_EXISTS mixed with string values as just existence', () => {
expect(
convertFilterToPredicate({
'spec.owner': [CATALOG_FILTER_EXISTS, 'team-a'],
}),
).toEqual({
'spec.owner': { $exists: true },
});
});
it('converts a single-element array filter without wrapping in $any', () => {
expect(convertFilterToPredicate([{ kind: 'component' }])).toEqual({
kind: 'component',
});
});
it('ignores entries with no valid values', () => {
expect(
convertFilterToPredicate({ kind: 'component', other: [] as string[] }),
).toEqual({ kind: 'component' });
});
});
describe('splitRefsIntoChunks', () => {
it('splits by count limit', () => {
+58
View File
@@ -14,7 +14,13 @@
* limitations under the License.
*/
import type {
FilterPredicate,
FilterPredicateExpression,
} from '@backstage/filter-predicates';
import {
CATALOG_FILTER_EXISTS,
EntityFilterQuery,
QueryEntitiesCursorRequest,
QueryEntitiesInitialRequest,
QueryEntitiesRequest,
@@ -26,6 +32,58 @@ export function isQueryEntitiesInitialRequest(
return !(request as QueryEntitiesCursorRequest).cursor;
}
/**
* Check if a cursor contains a predicate query by attempting to decode it.
* @internal
*/
export function cursorContainsQuery(cursor: string): boolean {
try {
const decoded = JSON.parse(atob(cursor));
return 'query' in decoded;
} catch {
return false;
}
}
/**
* Converts an {@link EntityFilterQuery} into a predicate query object.
* @internal
*/
export function convertFilterToPredicate(filter: EntityFilterQuery):
| FilterPredicateExpression
| {
$all: FilterPredicate[];
}
| {
$any: FilterPredicate[];
} {
const records = [filter].flat();
const clauses = records.map(record => {
const parts: FilterPredicateExpression[] = [];
for (const [key, value] of Object.entries(record)) {
const values = [value].flat();
const strings = values.filter((v): v is string => typeof v === 'string');
const hasExists = values.some(v => v === CATALOG_FILTER_EXISTS);
if (hasExists) {
// Ignore whether there ALSO were some strings - that would boil down to
// just existence anyway since there's effectively an OR between them
parts.push({ [key]: { $exists: true } } as FilterPredicateExpression);
} else if (strings.length === 1) {
parts.push({ [key]: strings[0] } as FilterPredicateExpression);
} else if (strings.length > 1) {
parts.push({ [key]: { $in: strings } } as FilterPredicateExpression);
}
}
return parts.length === 1 ? parts[0] : { $all: parts };
});
return clauses.length === 1 ? clauses[0] : { $any: clauses };
}
/**
* Takes a set of entity refs, and splits them into chunks (groups) such that
* the total string length in each chunk does not exceed the default Express.js
@@ -17,6 +17,7 @@
import { BackstageCredentials } from '@backstage/backend-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { EntityFilter } from '@backstage/plugin-catalog-node';
import { FilterPredicate } from '@backstage/filter-predicates';
/**
* A pagination rule for entities.
@@ -212,6 +213,10 @@ export interface QueryEntitiesInitialRequest {
limit?: number;
offset?: number;
filter?: EntityFilter;
/**
* Predicate-based query for filtering entities.
*/
query?: FilterPredicate;
orderFields?: EntityOrder[];
fullTextFilter?: {
term: string;
@@ -273,6 +278,10 @@ export type Cursor = {
* A filter to be applied to the full list of entities.
*/
filter?: EntityFilter;
/**
* A predicate-based query to be applied to the full list of entities.
*/
query?: FilterPredicate;
/**
* true if the cursor is a previous cursor.
*/
@@ -1095,6 +1095,68 @@ paths:
type: string
explode: false
style: form
post:
operationId: QueryEntitiesByPredicate
tags:
- Entity
description: Query entities using predicate-based filters.
responses:
'200':
description: Ok
content:
application/json:
schema:
$ref: '#/components/schemas/EntitiesQueryResponse'
'400':
$ref: '#/components/responses/ErrorResponse'
default:
$ref: '#/components/responses/ErrorResponse'
security:
- {}
- JWT: []
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
cursor:
type: string
limit:
type: number
offset:
type: number
orderBy:
type: array
items:
type: object
required:
- field
- order
properties:
field:
type: string
order:
type: string
enum:
- asc
- desc
fullTextFilter:
type: object
properties:
term:
type: string
fields:
type: array
items:
type: string
fields:
type: array
items:
type: string
query:
$ref: '#/components/schemas/JsonObject'
/entity-facets:
get:
operationId: GetEntityFacets
@@ -26,6 +26,7 @@ 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 { QueryEntitiesByPredicateRequest } from '../models/QueryEntitiesByPredicateRequest.model';
import { RefreshEntityRequest } from '../models/RefreshEntityRequest.model';
import { ValidateEntity400Response } from '../models/ValidateEntity400Response.model';
import { ValidateEntityRequest } from '../models/ValidateEntityRequest.model';
@@ -120,6 +121,13 @@ export type GetEntityFacets = {
};
response: EntityFacetsResponse | Error | Error;
};
/**
* @public
*/
export type QueryEntitiesByPredicate = {
body: QueryEntitiesByPredicateRequest;
response: EntitiesQueryResponse | Error | Error;
};
/**
* @public
*/
@@ -220,6 +228,8 @@ export type EndpointMap = {
'#get|/entity-facets': GetEntityFacets;
'#post|/entities/by-query': QueryEntitiesByPredicate;
'#post|/refresh': RefreshEntity;
'#post|/validate-entity': ValidateEntity;
@@ -0,0 +1,38 @@
/*
* 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 { QueryEntitiesByPredicateRequestFullTextFilter } from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model';
import { QueryEntitiesByPredicateRequestOrderByInner } from '../models/QueryEntitiesByPredicateRequestOrderByInner.model';
/**
* @public
*/
export interface QueryEntitiesByPredicateRequest {
cursor?: string;
limit?: number;
offset?: number;
orderBy?: Array<QueryEntitiesByPredicateRequestOrderByInner>;
fullTextFilter?: QueryEntitiesByPredicateRequestFullTextFilter;
fields?: Array<string>;
/**
* A type representing all allowed JSON object values.
*/
query?: { [key: string]: any };
}
@@ -0,0 +1,27 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
/**
* @public
*/
export interface QueryEntitiesByPredicateRequestFullTextFilter {
term?: string;
fields?: Array<string>;
}
@@ -0,0 +1,34 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
/**
* @public
*/
export interface QueryEntitiesByPredicateRequestOrderByInner {
field: string;
order: QueryEntitiesByPredicateRequestOrderByInnerOrderEnum;
}
/**
* @public
*/
export type QueryEntitiesByPredicateRequestOrderByInnerOrderEnum =
| 'asc'
| 'desc';
@@ -45,6 +45,9 @@ export * from '../models/LocationsQueryResponse.model';
export * from '../models/LocationsQueryResponsePageInfo.model';
export * from '../models/ModelError.model';
export * from '../models/NullableEntity.model';
export * from '../models/QueryEntitiesByPredicateRequest.model';
export * from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model';
export * from '../models/QueryEntitiesByPredicateRequestOrderByInner.model';
export * from '../models/RecursivePartialEntity.model';
export * from '../models/RecursivePartialEntityMeta.model';
export * from '../models/RecursivePartialEntityMetaAllOf.model';
@@ -1228,6 +1228,95 @@ export const spec = {
},
],
},
post: {
operationId: 'QueryEntitiesByPredicate',
tags: ['Entity'],
description: 'Query entities using predicate-based filters.',
responses: {
'200': {
description: 'Ok',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/EntitiesQueryResponse',
},
},
},
},
'400': {
$ref: '#/components/responses/ErrorResponse',
},
default: {
$ref: '#/components/responses/ErrorResponse',
},
},
security: [
{},
{
JWT: [],
},
],
requestBody: {
required: false,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
cursor: {
type: 'string',
},
limit: {
type: 'number',
},
offset: {
type: 'number',
},
orderBy: {
type: 'array',
items: {
type: 'object',
required: ['field', 'order'],
properties: {
field: {
type: 'string',
},
order: {
type: 'string',
enum: ['asc', 'desc'],
},
},
},
},
fullTextFilter: {
type: 'object',
properties: {
term: {
type: 'string',
},
fields: {
type: 'array',
items: {
type: 'string',
},
},
},
},
fields: {
type: 'array',
items: {
type: 'string',
},
},
query: {
$ref: '#/components/schemas/JsonObject',
},
},
},
},
},
},
},
},
'/entity-facets': {
get: {
@@ -23,6 +23,7 @@ import { AuthorizedEntitiesCatalog } from './AuthorizedEntitiesCatalog';
import { Cursor, QueryEntitiesResponse } from '../catalog/types';
import { Entity } from '@backstage/catalog-model';
import { EntityFilter } from '@backstage/plugin-catalog-node';
import { FilterPredicate } from '@backstage/filter-predicates';
import { mockCredentials } from '@backstage/backend-test-utils';
describe('AuthorizedEntitiesCatalog', () => {
@@ -306,6 +307,144 @@ describe('AuthorizedEntitiesCatalog', () => {
},
});
});
it('passes through query alongside permission filter on CONDITIONAL with initial request', async () => {
fakePermissionApi.authorizeConditional.mockResolvedValue([
{
result: AuthorizeResult.CONDITIONAL,
conditions: {
rule: 'IS_ENTITY_KIND',
params: { kinds: ['b'] },
},
},
]);
const userQuery: FilterPredicate = { 'metadata.name': 'my-entity' };
const entities = [
{
kind: 'component',
namespace: 'default',
name: 'a',
} as unknown as Entity,
];
fakeCatalog.queryEntities.mockResolvedValue({
items: { type: 'object', entities },
pageInfo: {
nextCursor: {
isPrevious: false,
orderFieldValues: ['xxx', null],
query: userQuery,
filter: { key: 'kind', values: ['b'] },
orderFields: [{ field: 'name', order: 'asc' }],
},
},
totalItems: 1,
} as QueryEntitiesResponse);
const catalog = createCatalog(isEntityKind);
const response = await catalog.queryEntities({
credentials: mockCredentials.none(),
query: userQuery,
});
expect(fakeCatalog.queryEntities).toHaveBeenCalledWith({
credentials: mockCredentials.none(),
query: userQuery,
filter: { key: 'kind', values: ['b'] },
});
expect(response.pageInfo.nextCursor).toEqual({
isPrevious: false,
orderFieldValues: ['xxx', null],
query: userQuery,
filter: undefined,
orderFields: [{ field: 'name', order: 'asc' }],
});
});
it('passes through cursor query alongside permission filter on CONDITIONAL with cursor request', async () => {
fakePermissionApi.authorizeConditional.mockResolvedValue([
{
result: AuthorizeResult.CONDITIONAL,
conditions: {
rule: 'IS_ENTITY_KIND',
params: { kinds: ['b'] },
},
},
]);
const userQuery: FilterPredicate = { 'metadata.name': 'my-entity' };
const entities = [
{
kind: 'component',
namespace: 'default',
name: 'a',
} as unknown as Entity,
];
fakeCatalog.queryEntities.mockResolvedValue({
items: { type: 'object', entities },
pageInfo: {
nextCursor: {
isPrevious: false,
orderFieldValues: ['yyy', null],
query: userQuery,
filter: { key: 'kind', values: ['b'] },
orderFields: [{ field: 'name', order: 'asc' }],
},
prevCursor: {
isPrevious: true,
orderFieldValues: ['aaa', null],
query: userQuery,
filter: { key: 'kind', values: ['b'] },
orderFields: [{ field: 'name', order: 'asc' }],
},
},
totalItems: 3,
} as QueryEntitiesResponse);
const catalog = createCatalog(isEntityKind);
const cursor: Cursor = {
query: userQuery,
orderFields: [{ field: 'name', order: 'asc' }],
isPrevious: false,
orderFieldValues: ['xxx', null],
};
const response = await catalog.queryEntities({
credentials: mockCredentials.none(),
cursor,
});
expect(fakeCatalog.queryEntities).toHaveBeenCalledWith({
credentials: mockCredentials.none(),
cursor: {
...cursor,
filter: { key: 'kind', values: ['b'] },
},
});
expect(response.pageInfo.nextCursor).toEqual({
isPrevious: false,
orderFieldValues: ['yyy', null],
query: userQuery,
filter: undefined,
orderFields: [{ field: 'name', order: 'asc' }],
});
expect(response.pageInfo.prevCursor).toEqual({
isPrevious: true,
orderFieldValues: ['aaa', null],
query: userQuery,
filter: undefined,
orderFields: [{ field: 'name', order: 'asc' }],
});
});
});
describe('removeEntityByUid', () => {
@@ -35,6 +35,7 @@ import {
QueryEntitiesRequest,
QueryEntitiesResponse,
} from '../catalog/types';
import { FilterPredicate } from '@backstage/filter-predicates';
import { basicEntityFilter } from './request';
import { isQueryEntitiesCursorRequest } from './util';
import { EntityFilter } from '@backstage/plugin-catalog-node';
@@ -147,9 +148,11 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog {
let permissionedRequest: QueryEntitiesRequest;
let requestFilter: EntityFilter | undefined;
let requestQuery: FilterPredicate | undefined;
if (isQueryEntitiesCursorRequest(request)) {
requestFilter = request.cursor.filter;
requestQuery = request.cursor.query;
permissionedRequest = {
...request,
@@ -161,13 +164,15 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog {
},
};
} else {
requestFilter = request.filter;
requestQuery = request.query;
permissionedRequest = {
...request,
filter: request.filter
? { allOf: [permissionFilter, request.filter] }
: permissionFilter,
};
requestFilter = request.filter;
}
const response = await this.entitiesCatalog.queryEntities(
@@ -177,11 +182,13 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog {
const prevCursor: Cursor | undefined = response.pageInfo.prevCursor && {
...response.pageInfo.prevCursor,
filter: requestFilter,
query: requestQuery,
};
const nextCursor: Cursor | undefined = response.pageInfo.nextCursor && {
...response.pageInfo.nextCursor,
filter: requestFilter,
query: requestQuery,
};
return {
@@ -193,6 +200,7 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog {
};
}
// The ALLOW case
return this.entitiesCatalog.queryEntities(request);
}
@@ -2054,6 +2054,38 @@ describe('DefaultEntitiesCatalog', () => {
]);
},
);
it.each(databases.eachSupportedId())(
'should apply both filter and query when both are given, %p',
async databaseId => {
await createDatabase(databaseId);
// Add entities with different kinds and names
await addEntityToSearch(entityFrom('A', { kind: 'component' }));
await addEntityToSearch(entityFrom('B', { kind: 'component' }));
await addEntityToSearch(entityFrom('C', { kind: 'api' }));
await addEntityToSearch(entityFrom('D', { kind: 'api' }));
const catalog = new DefaultEntitiesCatalog({
database: knex,
logger: mockServices.logger.mock(),
stitcher,
});
// Use filter to restrict to kind=component, and query to restrict to name=A
const response = await catalog.queryEntities({
filter: { key: 'kind', values: ['component'] },
query: { 'metadata.name': 'a' },
orderFields: [{ field: 'metadata.name', order: 'asc' }],
credentials: mockCredentials.none(),
});
const resultEntities = entitiesResponseToObjects(response.items);
expect(resultEntities).toEqual([
entityFrom('A', { kind: 'component' }),
]);
},
);
});
describe('removeEntityByUid', () => {
@@ -18,7 +18,6 @@ import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { InputError, NotFoundError } from '@backstage/errors';
import { Knex } from 'knex';
import { chunk as lodashChunk, isEqual } from 'lodash';
import { z } from 'zod';
import {
Cursor,
EntitiesBatchRequest,
@@ -49,7 +48,6 @@ import {
isQueryEntitiesCursorRequest,
isQueryEntitiesInitialRequest,
} from './util';
import { EntityFilter } from '@backstage/plugin-catalog-node';
import { LoggerService } from '@backstage/backend-plugin-api';
import { applyEntityFilterToQuery } from './request/applyEntityFilterToQuery';
import { processRawEntitiesResult } from './response';
@@ -303,10 +301,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,
@@ -713,40 +712,24 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
}
}
const entityFilterParser: z.ZodSchema<EntityFilter> = z.lazy(() =>
z
.object({
key: z.string(),
values: z.array(z.string()).optional(),
})
.or(z.object({ not: entityFilterParser }))
.or(z.object({ anyOf: z.array(entityFilterParser) }))
.or(z.object({ allOf: z.array(entityFilterParser) })),
);
export const cursorParser: z.ZodSchema<Cursor> = z.object({
orderFields: z.array(
z.object({ field: z.string(), order: z.enum(['asc', 'desc']) }),
),
orderFieldValues: z.array(z.string().or(z.null())),
filter: entityFilterParser.optional(),
isPrevious: z.boolean(),
query: z.string().optional(),
firstSortFieldValues: z.array(z.string().or(z.null())).optional(),
totalItems: z.number().optional(),
});
function parseCursorFromRequest(
request?: QueryEntitiesRequest,
): Partial<Cursor> & { skipTotalItems: boolean } {
if (isQueryEntitiesInitialRequest(request)) {
const {
filter,
query,
orderFields: sortFields = [],
fullTextFilter,
skipTotalItems = false,
} = request;
return { filter, orderFields: sortFields, fullTextFilter, skipTotalItems };
return {
filter,
query,
orderFields: sortFields,
fullTextFilter,
skipTotalItems,
};
}
if (isQueryEntitiesCursorRequest(request)) {
return {
@@ -479,6 +479,87 @@ 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('queries entities with an offset', async () => {
const items: Entity[] = [
{ apiVersion: 'a', kind: 'b', metadata: { name: 'n' } },
];
entitiesCatalog.queryEntities.mockResolvedValue({
items: { type: 'object', entities: items },
pageInfo: {},
totalItems: 5,
});
const response = await request(app)
.post('/entities/by-query')
.send({ query: { kind: 'b' }, limit: 2, offset: 3 });
expect(response.status).toEqual(200);
expect(entitiesCatalog.queryEntities).toHaveBeenCalledWith(
expect.objectContaining({
query: { kind: 'b' },
limit: 2,
offset: 3,
credentials: mockCredentials.user(),
}),
);
});
it('paginates with a cursor in the body', async () => {
const items: Entity[] = [
{ apiVersion: 'a', kind: 'b', metadata: { name: 'n' } },
];
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 = {
@@ -65,6 +65,7 @@ import {
encodeLocationQueryCursor,
parseLocationQuery,
} from './request/parseLocationQuery';
import { parseEntityQuery } from './request/parseEntityQuery';
/**
* Options used by {@link createRouter}.
@@ -258,6 +259,59 @@ export async function createRouter(
throw err;
}
})
.post('/entities/by-query', async (req, res) => {
const auditorEvent = await auditor.createEvent({
eventId: 'entity-fetch',
request: req,
meta: {
queryType: 'by-query',
},
});
try {
const credentials = await httpAuth.credentials(req);
const { fields: rawFields, ...parsed } = parseEntityQuery(
req.body ?? {},
);
const fields = rawFields?.length
? parseEntityTransformParams({ fields: rawFields })
: undefined;
const { items, pageInfo, totalItems } =
await entitiesCatalog.queryEntities({
credentials,
fields,
...parsed,
});
const meta = {
totalItems,
pageInfo: {
...(pageInfo.nextCursor && {
nextCursor: encodeCursor(pageInfo.nextCursor),
}),
...(pageInfo.prevCursor && {
prevCursor: encodeCursor(pageInfo.prevCursor),
}),
},
};
await auditorEvent?.success({ meta });
await writeEntitiesResponse({
res,
items,
alwaysUseObjectMode: enableRelationsCompatibility,
responseWrapper: entities => ({
items: entities,
...meta,
}),
});
} catch (err) {
await auditorEvent?.fail({ error: err });
throw err;
}
})
.get('/entities/by-query', async (req, res) => {
const auditorEvent = await auditor.createEvent({
eventId: 'entity-fetch',
@@ -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,29 @@ 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;
}
@@ -0,0 +1,430 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TestDatabases } from '@backstage/backend-test-utils';
import { applyEntityFilterToQuery } from './applyEntityFilterToQuery';
import {
DbFinalEntitiesRow,
DbRefreshStateRow,
DbSearchRow,
} from '../../database/tables';
import { Knex } from 'knex';
import { applyDatabaseMigrations } from '../../database/migrations';
import { FilterPredicate } from '@backstage/filter-predicates';
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { v4 as uuid } from 'uuid';
import { buildEntitySearch } from '../../database/operations/stitcher/buildEntitySearch';
jest.setTimeout(60_000);
const databases = TestDatabases.create();
describe.each(databases.eachSupportedId())(
'applyEntityFilterToQuery with predicate queries, %p',
databaseId => {
let knex: Knex;
beforeAll(async () => {
knex = await databases.init(databaseId);
await applyDatabaseMigrations(knex);
await addEntity({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'service-a', namespace: 'default' },
spec: { type: 'service', lifecycle: 'production', owner: 'team-a' },
relations: [
{ type: 'ownedBy', targetRef: 'group:default/team-a' },
{ type: 'consumesApi', targetRef: 'api:default/api-d' },
],
});
await addEntity({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'service-b', namespace: 'default' },
spec: { type: 'service', lifecycle: 'experimental', owner: 'team-b' },
relations: [{ type: 'ownedBy', targetRef: 'group:default/team-b' }],
});
await addEntity({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'website-c', namespace: 'default' },
spec: { type: 'website', lifecycle: 'production', owner: 'team-a' },
relations: [{ type: 'ownedBy', targetRef: 'group:default/team-a' }],
});
await addEntity({
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: { name: 'api-d', namespace: 'default' },
spec: { type: 'openapi', lifecycle: 'production' },
});
await addEntity({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'bare-e',
namespace: 'default',
tags: ['java', 'backend'],
},
spec: {},
});
});
afterAll(async () => {
knex.destroy();
});
async function addEntity(entity: Entity) {
const id = uuid();
const entityRef = stringifyEntityRef(entity);
const entityJson = JSON.stringify(entity);
await knex<DbRefreshStateRow>('refresh_state').insert({
entity_id: id,
entity_ref: entityRef,
unprocessed_entity: entityJson,
errors: '[]',
next_update_at: '2031-01-01 23:00:00',
last_discovery_at: '2021-04-01 13:37:00',
});
await knex<DbFinalEntitiesRow>('final_entities').insert({
entity_id: id,
entity_ref: entityRef,
final_entity: entityJson,
hash: 'h',
stitch_ticket: '',
});
const search = await buildEntitySearch(id, entity);
await knex<DbSearchRow>('search').insert(search);
}
async function query(predicate: FilterPredicate): Promise<string[]> {
const q =
knex<DbFinalEntitiesRow>('final_entities').whereNotNull('final_entity');
applyEntityFilterToQuery({
query: predicate,
targetQuery: q,
onEntityIdField: 'final_entities.entity_id',
knex,
});
return await q.then(rows =>
rows.map(row => JSON.parse(row.final_entity!).metadata.name).toSorted(),
);
}
describe('field expressions', () => {
it('matches everything for empty field expression', async () => {
await expect(query({})).resolves.toEqual([
'api-d',
'bare-e',
'service-a',
'service-b',
'website-c',
]);
});
it('filters by direct field value', async () => {
await expect(query({ kind: 'component' })).resolves.toEqual([
'bare-e',
'service-a',
'service-b',
'website-c',
]);
});
it('filters by exact spec field value', async () => {
await expect(query({ 'spec.type': 'service' })).resolves.toEqual([
'service-a',
'service-b',
]);
});
it('throws on unsupported field operator', async () => {
await expect(query({ kind: { $bad: 'value' } as any })).rejects.toThrow(
/\$contains operator, but got/,
);
});
it('throws on top-level primitive', async () => {
await expect(query('bad-value' as any)).rejects.toThrow(
/top-level primitive values are not supported/,
);
});
});
describe('$all', () => {
it('filters with $all', async () => {
await expect(
query({
$all: [{ kind: 'component' }, { 'spec.type': 'service' }],
}),
).resolves.toEqual(['service-a', 'service-b']);
});
it('matches everything for empty $all', async () => {
await expect(query({ $all: [] })).resolves.toEqual([
'api-d',
'bare-e',
'service-a',
'service-b',
'website-c',
]);
});
});
describe('$any', () => {
it('filters with $any', async () => {
await expect(
query({
$any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }],
}),
).resolves.toEqual(['service-a', 'service-b', 'website-c']);
});
it('returns nothing for empty $any', async () => {
await expect(query({ $any: [] })).resolves.toEqual([]);
});
});
describe('$not', () => {
it('filters with $not', async () => {
await expect(
query({
$all: [
{ kind: 'component' },
{ $not: { 'spec.lifecycle': 'experimental' } },
],
}),
).resolves.toEqual(['bare-e', 'service-a', 'website-c']);
});
it('matches nothing for {$not: {}}', async () => {
await expect(query({ $not: {} })).resolves.toEqual([]);
});
});
describe('$in', () => {
it('filters with $in', async () => {
await expect(
query({ 'spec.type': { $in: ['service', 'openapi'] } }),
).resolves.toEqual(['api-d', 'service-a', 'service-b']);
});
});
describe('$exists', () => {
it('filters with $exists true', async () => {
await expect(
query({
$all: [{ kind: 'component' }, { 'spec.owner': { $exists: true } }],
}),
).resolves.toEqual(['service-a', 'service-b', 'website-c']);
});
it('filters with $exists false', async () => {
await expect(
query({
$all: [{ kind: 'component' }, { 'spec.owner': { $exists: false } }],
}),
).resolves.toEqual(['bare-e']);
});
});
describe('$hasPrefix', () => {
it('filters with $hasPrefix', async () => {
await expect(
query({ 'metadata.name': { $hasPrefix: 'service' } }),
).resolves.toEqual(['service-a', 'service-b']);
});
it('filters with $hasPrefix case-insensitively', async () => {
await expect(
query({ 'metadata.name': { $hasPrefix: 'Service' } }),
).resolves.toEqual(['service-a', 'service-b']);
});
});
describe('$contains', () => {
it('filters with primitive $contains on array fields', async () => {
await expect(
query({ 'metadata.tags': { $contains: 'java' } }),
).resolves.toEqual(['bare-e']);
});
it('matches relations by type only (existence)', async () => {
await expect(
query({ relations: { $contains: { type: 'consumesApi' } } }),
).resolves.toEqual(['service-a']);
});
it('matches relations by type and exact targetRef', async () => {
await expect(
query({
relations: {
$contains: {
type: 'ownedBy',
targetRef: 'group:default/team-a',
},
},
}),
).resolves.toEqual(['service-a', 'website-c']);
});
it('matches relations by type and targetRef case-insensitively', async () => {
await expect(
query({
relations: {
$contains: {
type: 'OwnedBy',
targetRef: 'Group:Default/Team-A',
},
},
}),
).resolves.toEqual(['service-a', 'website-c']);
});
it('handles mixed-case keys in $contains object', async () => {
await expect(
query({
relations: {
$contains: {
TyPe: 'ownedBy',
TargetRef: 'group:default/team-a',
},
},
} as any),
).resolves.toEqual(['service-a', 'website-c']);
});
it('matches relations by type and targetRef with $in', async () => {
await expect(
query({
relations: {
$contains: {
type: 'ownedBy',
targetRef: {
$in: ['group:default/team-a', 'group:default/team-b'],
},
},
},
}),
).resolves.toEqual(['service-a', 'service-b', 'website-c']);
});
it('throws on unsupported keys in $contains object', async () => {
await expect(
query({
relations: {
$contains: { type: 'ownedBy', badKey: 'value' },
} as any,
}),
).rejects.toThrow(/Unsupported key "badKey" in \$contains/);
});
it('throws on duplicate keys in $contains object', async () => {
await expect(
query({
relations: {
// JSON.parse allows duplicate keys, last one wins, but we
// simulate via an object that has both casings
$contains: JSON.parse('{"type": "ownedBy", "Type": "ownedBy"}'),
},
}),
).rejects.toThrow(/Duplicate key "Type" in \$contains/);
});
it('throws on $contains object without type', async () => {
await expect(
query({
relations: {
$contains: { targetRef: 'group:default/team-a' },
} as any,
}),
).rejects.toThrow(/requires a "type" string property/);
});
it('throws on empty $in array in targetRef', async () => {
await expect(
query({
relations: {
$contains: { type: 'ownedBy', targetRef: { $in: [] } },
},
}),
).rejects.toThrow(/Empty "\$in" array for \$contains on "relations"/);
});
it('throws on unsupported targetRef value', async () => {
await expect(
query({
relations: {
$contains: { type: 'ownedBy', targetRef: ['bad'] },
} as any,
}),
).rejects.toThrow(/Unsupported value in \$contains for "relations"/);
});
it('throws on object $contains for non-relations fields', async () => {
await expect(
query({
'metadata.tags': {
$contains: { type: 'something' },
} as any,
}),
).rejects.toThrow(
/Object form of \$contains is not supported for field/,
);
});
});
describe('nested operators', () => {
it('handles nested logical operators', async () => {
await expect(
query({
$all: [
{ kind: 'component' },
{
$any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }],
},
{ $not: { 'spec.lifecycle': 'experimental' } },
],
}),
).resolves.toEqual(['service-a', 'website-c']);
});
});
describe('combined filter and query', () => {
it('combines filter and query independently', async () => {
const q =
knex<DbFinalEntitiesRow>('final_entities').whereNotNull(
'final_entity',
);
applyEntityFilterToQuery({
filter: { key: 'kind', values: ['component'] },
query: { 'spec.type': 'service' },
targetQuery: q,
onEntityIdField: 'final_entities.entity_id',
knex,
});
const result = await q.then(rows =>
rows
.map(row => JSON.parse(row.final_entity!).metadata.name)
.toSorted(),
);
expect(result).toEqual(['service-a', 'service-b']);
});
});
},
);
@@ -0,0 +1,329 @@
/*
* 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 {
FilterPredicate,
FilterPredicatePrimitive,
FilterPredicateValue,
} from '@backstage/filter-predicates';
import { InputError } from '@backstage/errors';
import { Knex } from 'knex';
import { DbSearchRow } from '../../database/tables';
function isPrimitive(value: unknown): value is FilterPredicatePrimitive {
return (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
);
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function applyPredicateEntityFilterToQuery(options: {
filter: FilterPredicate;
targetQuery: Knex.QueryBuilder;
onEntityIdField: string;
knex: Knex;
}): Knex.QueryBuilder {
const { filter, targetQuery, onEntityIdField, knex } = options;
// We do not support top-level primitives; all matching happens through objects
if (!isObject(filter)) {
const actual = JSON.stringify(filter);
throw new InputError(
`Invalid filter predicate: top-level primitive values are not supported. Wrap the value in a field expression, e.g. { "kind": ${actual} }`,
);
}
if ('$not' in filter) {
return targetQuery.andWhereNot(inner =>
applyPredicateEntityFilterToQuery({
filter: filter.$not,
targetQuery: inner,
onEntityIdField,
knex,
}),
);
}
if ('$all' in filter) {
if (filter.$all.length === 0) {
return targetQuery.andWhereRaw('1 = 1');
}
return targetQuery.andWhere(outer => {
for (const subFilter of filter.$all) {
outer.andWhere(inner =>
applyPredicateEntityFilterToQuery({
filter: subFilter,
targetQuery: inner,
onEntityIdField,
knex,
}),
);
}
});
}
if ('$any' in filter) {
if (filter.$any.length === 0) {
return targetQuery.andWhereRaw('1 = 0');
}
return targetQuery.andWhere(outer => {
for (const subFilter of filter.$any) {
outer.orWhere(inner =>
applyPredicateEntityFilterToQuery({
filter: subFilter,
targetQuery: inner,
onEntityIdField,
knex,
}),
);
}
});
}
// Treat the filter as a field expression like { "kind": "component" } or { "spec.type": { "$in": ["service", "website"] } }
if (Object.keys(filter).length === 0) {
return targetQuery.andWhereRaw('1 = 1');
}
return targetQuery.andWhere(inner => {
for (const [keyAnyCase, value] of Object.entries(filter)) {
applyFieldCondition({
key: keyAnyCase.toLocaleLowerCase('en-US'),
value,
targetQuery: inner,
onEntityIdField,
knex,
});
}
});
}
/**
* Applies a single { key: value } filter to the target query.
*/
function applyFieldCondition(options: {
key: string;
value: FilterPredicateValue;
targetQuery: Knex.QueryBuilder;
onEntityIdField: string;
knex: Knex;
}): Knex.QueryBuilder {
const { key, value, targetQuery, onEntityIdField, knex } = options;
if (isPrimitive(value)) {
const matchQuery = knex<DbSearchRow>('search')
.select('search.entity_id')
.where({
key,
value: String(value).toLocaleLowerCase('en-US'),
});
return targetQuery.andWhere(onEntityIdField, 'in', matchQuery);
}
if (isObject(value)) {
if ('$exists' in value) {
const existsQuery = knex<DbSearchRow>('search')
.select('search.entity_id')
.where({ key });
return targetQuery.andWhere(
onEntityIdField,
value.$exists ? 'in' : 'not in',
existsQuery,
);
}
if ('$in' in value) {
const values = value.$in.map(v => String(v).toLocaleLowerCase('en-US'));
const matchQuery = knex<DbSearchRow>('search')
.select('search.entity_id')
.where({ key })
.whereIn('value', values);
return targetQuery.andWhere(onEntityIdField, 'in', matchQuery);
}
if ('$hasPrefix' in value) {
const prefix = value.$hasPrefix.toLocaleLowerCase('en-US');
const escaped = prefix.replace(/[%_\\]/g, c => `\\${c}`);
const matchQuery = knex<DbSearchRow>('search')
.select('search.entity_id')
.where({ key })
.andWhereRaw('?? like ? escape ?', ['value', `${escaped}%`, '\\']);
return targetQuery.andWhere(onEntityIdField, 'in', matchQuery);
}
if ('$contains' in value) {
const target = value.$contains;
// If the target is a primitive, match on the special array syntax.
//
// FROM: `{ "a": { "$contains": "b" } }`
//
// TO: `{ "a": "b" }`
//
// The search table does not actually show us that "a" was an array to
// begin with, so this can mistakenly also match on an object that had a
// "b" key with a primitive value. We'll consider that an acceptable
// tradeoff though.
if (isPrimitive(target)) {
const matchQuery = knex<DbSearchRow>('search')
.select('search.entity_id')
.where({
key,
value: String(target).toLocaleLowerCase('en-US'),
});
return targetQuery.andWhere(onEntityIdField, 'in', matchQuery);
}
// Object form of $contains - currently only supports relation-style
// objects with "type" and optional "targetRef" keys.
//
// FROM: `{ "relations": { "$contains": { "type": "ownedBy", "targetRef": "group:default/team-a" } } }`
//
// TO: search for key = "relations.ownedby" AND value = "group:default/team-a"
if (isObject(target)) {
if (key === 'relations') {
return applyContainsRelation({
target,
targetQuery,
onEntityIdField,
knex,
});
}
throw new InputError(
`Object form of $contains is not supported for field "${key}"`,
);
}
const actual = JSON.stringify(target);
throw new InputError(
`Unsupported $contains target for field "${key}": ${actual}`,
);
}
}
const actual = JSON.stringify(value);
throw new InputError(
`Invalid filter predicate value for field "${key}": expected a primitive value, $exists, $in, $hasPrefix, or $contains operator, but got ${actual}`,
);
}
/**
* Handles expressions on the form
*
* ```
* {
* "relations": {
* "$contains": {
* "type": "ownedBy",
* "targetRef": "group:default/team-a"
* }
* }
* }
* ```
*
* which map onto the search table's special `relation.<type>: <targetRef>`
* syntax.
*
* Only the keys "type" and "targetRef" are supported. The "type" key is
* required. If "targetRef" is omitted, it becomes an existence check for any
* relation of that type. The "targetRef" value can be a string or an `$in`
* array.
*/
function applyContainsRelation(options: {
target: Record<string, unknown>;
targetQuery: Knex.QueryBuilder;
onEntityIdField: string;
knex: Knex;
}): Knex.QueryBuilder {
const { target: rawTarget, targetQuery, onEntityIdField, knex } = options;
function parseStringOrIn(value: unknown): string[] {
if (typeof value === 'string') {
return [value.toLocaleLowerCase('en-US')];
}
if (
isObject(value) &&
Object.keys(value).length === 1 &&
'$in' in value &&
Array.isArray(value.$in) &&
value.$in.every((v): v is string => typeof v === 'string')
) {
if (value.$in.length === 0) {
throw new InputError(
`Empty "$in" array for $contains on "relations" is not allowed`,
);
}
return value.$in.map(v => v.toLocaleLowerCase('en-US'));
}
const actual = JSON.stringify(value);
throw new InputError(
`Unsupported value in $contains for "relations": expected a string or { "$in": [strings] }, but got ${actual}`,
);
}
let type: string | undefined;
let targetRef: string[] | undefined;
for (const [rawKey, value] of Object.entries(rawTarget)) {
const key = rawKey.toLocaleLowerCase('en-US');
if (key === 'type') {
if (type !== undefined) {
throw new InputError(
`Duplicate key "${rawKey}" in $contains for "relations"`,
);
}
if (typeof value !== 'string') {
throw new InputError(
`The $contains operator for "relations" requires a "type" string property`,
);
}
type = value;
} else if (key === 'targetref') {
if (targetRef !== undefined) {
throw new InputError(
`Duplicate key "${rawKey}" in $contains for "relations"`,
);
}
targetRef = parseStringOrIn(value);
} else {
throw new InputError(
`Unsupported key "${rawKey}" in $contains for "relations". Only "type" and "targetRef" are supported`,
);
}
}
if (!type) {
throw new InputError(
`The $contains operator for "relations" requires a "type" string property`,
);
}
const matchQuery = knex<DbSearchRow>('search')
.select('search.entity_id')
.where({ key: `relations.${type.toLocaleLowerCase('en-US')}` });
if (targetRef) {
matchQuery.whereIn('value', targetRef);
}
return targetQuery.andWhere(onEntityIdField, 'in', matchQuery);
}
@@ -0,0 +1,218 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { parseEntityQuery } from './parseEntityQuery';
import { encodeCursor } from '../util';
import { Cursor } from '../../catalog/types';
describe('parseEntityQuery', () => {
describe('initial request', () => {
it('returns empty result for empty request', () => {
const result = parseEntityQuery({});
expect(result).toEqual({
query: undefined,
orderFields: undefined,
fullTextFilter: undefined,
fields: undefined,
limit: undefined,
offset: undefined,
});
});
it('parses a simple query predicate', () => {
const query = { kind: 'component' };
const result = parseEntityQuery({ query });
expect(result).toEqual(
expect.objectContaining({ query: { kind: 'component' } }),
);
});
it('parses a complex query with $all, $any, $not', () => {
const query = {
$all: [
{ kind: 'component' },
{ $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }] },
{ $not: { 'spec.lifecycle': 'experimental' } },
],
};
const result = parseEntityQuery({ query });
expect(result).toEqual(expect.objectContaining({ query }));
});
it('parses query with $exists operator', () => {
const query = { 'metadata.labels.team': { $exists: true } };
const result = parseEntityQuery({ query });
expect(result).toEqual(expect.objectContaining({ query }));
});
it('parses query with $in operator', () => {
const query = { kind: { $in: ['component', 'api'] } };
const result = parseEntityQuery({ query });
expect(result).toEqual(expect.objectContaining({ query }));
});
it('passes through limit', () => {
const result = parseEntityQuery({ limit: 50 });
expect(result).toEqual(expect.objectContaining({ limit: 50 }));
});
it('passes through offset', () => {
const result = parseEntityQuery({ offset: 100 });
expect(result).toEqual(expect.objectContaining({ offset: 100 }));
});
it('passes through limit and offset together', () => {
const result = parseEntityQuery({ limit: 50, offset: 100 });
expect(result).toEqual(
expect.objectContaining({ limit: 50, offset: 100 }),
);
});
it('passes through fields', () => {
const result = parseEntityQuery({
fields: ['metadata.name', 'kind'],
});
expect(result).toEqual(
expect.objectContaining({ fields: ['metadata.name', 'kind'] }),
);
});
it('parses orderBy into orderFields', () => {
const result = parseEntityQuery({
orderBy: [
{ field: 'metadata.name', order: 'asc' },
{ field: 'metadata.namespace', order: 'desc' },
],
});
expect(result).toEqual(
expect.objectContaining({
orderFields: [
{ field: 'metadata.name', order: 'asc' },
{ field: 'metadata.namespace', order: 'desc' },
],
}),
);
});
it('parses fullTextFilter', () => {
const result = parseEntityQuery({
fullTextFilter: { term: 'search term', fields: ['metadata.name'] },
});
expect(result).toEqual(
expect.objectContaining({
fullTextFilter: {
term: 'search term',
fields: ['metadata.name'],
},
}),
);
});
it('defaults fullTextFilter term to empty string when missing', () => {
const result = parseEntityQuery({
fullTextFilter: {} as any,
});
expect(result).toEqual(
expect.objectContaining({
fullTextFilter: { term: '', fields: undefined },
}),
);
});
it('throws on invalid query predicate', () => {
expect(() =>
parseEntityQuery({ query: { $invalid: true } as any }),
).toThrow(/Invalid query/);
});
it('throws when query root is not an object', () => {
expect(() => parseEntityQuery({ query: 'bad' as any })).toThrow(
/Query must be an object/,
);
});
it('throws on invalid orderBy order value', () => {
expect(() =>
parseEntityQuery({
// @ts-expect-error - invalid order value
orderBy: [{ field: 'metadata.name', order: 'sideways' }],
}),
).toThrow(/Invalid order field order/);
});
});
describe('cursor request', () => {
function makeCursor(partial: Partial<Cursor>): string {
const full: Cursor = {
orderFields: [],
orderFieldValues: [],
isPrevious: false,
...partial,
};
return encodeCursor(full);
}
it('decodes a valid cursor', () => {
const cursor = makeCursor({
orderFields: [{ field: 'metadata.name', order: 'asc' }],
orderFieldValues: ['test'],
isPrevious: false,
});
const result = parseEntityQuery({ cursor });
expect(result).toEqual(
expect.objectContaining({
cursor: expect.objectContaining({
orderFields: [{ field: 'metadata.name', order: 'asc' }],
orderFieldValues: ['test'],
isPrevious: false,
}),
}),
);
});
it('passes through limit and fields with cursor', () => {
const cursor = makeCursor({});
const result = parseEntityQuery({
cursor,
limit: 25,
fields: ['metadata.name'],
});
expect(result).toEqual(
expect.objectContaining({
limit: 25,
fields: ['metadata.name'],
}),
);
});
it('throws on empty cursor string', () => {
expect(() => parseEntityQuery({ cursor: '' })).toThrow(
/Cursor cannot be empty/,
);
});
it('throws on invalid base64 cursor', () => {
expect(() => parseEntityQuery({ cursor: '!!not-valid!!' })).toThrow(
/Malformed cursor/,
);
});
it('throws on invalid JSON in cursor', () => {
const cursor = Buffer.from('not json', 'utf8').toString('base64');
expect(() => parseEntityQuery({ cursor })).toThrow(/Malformed cursor/);
});
});
});
@@ -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/v3';
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: Array<{ field: string; order: string }> | undefined,
): EntityOrder[] | undefined {
if (!orderField?.length) {
return undefined;
}
return orderField.map(({ field, order }) => {
if (order !== 'asc' && order !== 'desc') {
throw new InputError('Invalid order field order, must be asc or desc');
}
return { field, order };
});
}
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.orderBy);
return {
query,
orderFields,
fullTextFilter: request.fullTextFilter
? {
term: request.fullTextFilter.term ?? '',
fields: request.fullTextFilter.fields,
}
: undefined,
fields: request.fields,
limit: request.limit,
offset: request.offset,
};
}
+4 -1
View File
@@ -15,6 +15,7 @@
*/
import { InputError, NotAllowedError } from '@backstage/errors';
import { createZodV3FilterPredicateSchema } from '@backstage/filter-predicates';
import { Request } from 'express';
import lodash from 'lodash';
import { z } from 'zod';
@@ -109,6 +110,8 @@ const entityFilterParser: z.ZodSchema<EntityFilter> = z.lazy(() =>
.or(z.object({ allOf: z.array(entityFilterParser) })),
);
const filterPredicateSchema = createZodV3FilterPredicateSchema(z);
export const cursorParser: z.ZodSchema<Cursor> = z.object({
orderFields: z.array(
z.object({ field: z.string(), order: z.enum(['asc', 'desc']) }),
@@ -122,7 +125,7 @@ export const cursorParser: z.ZodSchema<Cursor> = z.object({
orderFieldValues: z.array(z.string().or(z.null())),
filter: entityFilterParser.optional(),
isPrevious: z.boolean(),
query: z.string().optional(),
query: filterPredicateSchema.optional(),
firstSortFieldValues: z.array(z.string().or(z.null())).optional(),
totalItems: z.number().optional(),
});