Merge pull request #32874 from backstage/benjdlambert/fix-predicate-review-feedback
`feat(catalog)`: support entity predicate filtering
This commit is contained in:
@@ -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: [
|
||||
|
||||
@@ -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 -
|
||||
|
||||
+38
@@ -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 };
|
||||
}
|
||||
+27
@@ -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>;
|
||||
}
|
||||
+34
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user