refactor: integrate predicate filtering into queryEntities flow
Remove separate queryEntitiesByPredicate method and integrate predicate-based filtering into the existing queryEntities path. POST /entities/by-query now uses the same queryEntities call with proper permission enforcement via applyEntityFilterToQuery. Aligns with the locations query pattern from PR #32846. Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
@@ -3,13 +3,8 @@
|
||||
'@backstage/plugin-catalog-backend': minor
|
||||
---
|
||||
|
||||
New POST /entities/by-query endpoint
|
||||
Added predicate-based entity filtering via POST /entities/by-query endpoint.
|
||||
|
||||
- Supports predicate-based entity filtering using advanced query operators ($all, $any, $in, $not, $exists)
|
||||
- Enables complex nested queries for more powerful entity searches
|
||||
- Provides cursor-based pagination for efficient result traversal
|
||||
Supports `$all`, `$any`, `$not`, `$exists`, and `$in` operators for expressive entity queries. Integrated into the existing `queryEntities` flow with full cursor-based pagination, permission enforcement, and `totalItems` support.
|
||||
|
||||
Updated Catalog Client
|
||||
|
||||
- Enhanced queryEntities() method to automatically route requests to POST endpoint when query predicate is provided
|
||||
- Validates mutual exclusivity between filter (legacy) and query (predicate-based) parameters
|
||||
The catalog client's `queryEntities()` method automatically routes to the POST endpoint when a `query` predicate is provided.
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { AnalyzeLocationRequest } from '@backstage/plugin-catalog-common';
|
||||
import type { AnalyzeLocationResponse } from '@backstage/plugin-catalog-common';
|
||||
import { CompoundEntityRef } from '@backstage/catalog-model';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
import type { FilterPredicate } from '@backstage/filter-predicates';
|
||||
import { SerializedError } from '@backstage/errors';
|
||||
|
||||
// @public
|
||||
@@ -231,56 +231,6 @@ export type EntityOrderQuery =
|
||||
order: 'asc' | 'desc';
|
||||
}>;
|
||||
|
||||
// @public
|
||||
export type EntityPredicate =
|
||||
| EntityPredicateAll
|
||||
| EntityPredicateAny
|
||||
| EntityPredicateNot
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| {
|
||||
[key: string]: EntityPredicateValue;
|
||||
};
|
||||
|
||||
// @public
|
||||
export interface EntityPredicateAll {
|
||||
// (undocumented)
|
||||
$all: Array<EntityPredicate>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface EntityPredicateAny {
|
||||
// (undocumented)
|
||||
$any: Array<EntityPredicate>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface EntityPredicateExists {
|
||||
// (undocumented)
|
||||
$exists: boolean;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface EntityPredicateIn {
|
||||
// (undocumented)
|
||||
$in: Array<string | number | boolean>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface EntityPredicateNot {
|
||||
// (undocumented)
|
||||
$not: EntityPredicate;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type EntityPredicateValue =
|
||||
| EntityPredicateExists
|
||||
| EntityPredicateIn
|
||||
| boolean
|
||||
| number
|
||||
| string;
|
||||
|
||||
// @public
|
||||
export interface GetEntitiesByRefsRequest {
|
||||
entityRefs: string[];
|
||||
@@ -370,7 +320,7 @@ export type QueryEntitiesInitialRequest = {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
filter?: EntityFilterQuery;
|
||||
query?: EntityPredicate;
|
||||
query?: FilterPredicate;
|
||||
orderFields?: EntityOrderQuery;
|
||||
fullTextFilter?: {
|
||||
term: string;
|
||||
|
||||
@@ -568,6 +568,7 @@ describe('CatalogClient', () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
totalItems: 2,
|
||||
pageInfo: {},
|
||||
};
|
||||
|
||||
@@ -575,9 +576,8 @@ describe('CatalogClient', () => {
|
||||
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
|
||||
expect(req.method).toBe('POST');
|
||||
expect(req.body).toMatchObject({
|
||||
query: {
|
||||
kind: 'component',
|
||||
},
|
||||
query: { kind: 'component' },
|
||||
limit: 20,
|
||||
});
|
||||
return res(ctx.json(defaultResponse));
|
||||
});
|
||||
@@ -591,7 +591,7 @@ describe('CatalogClient', () => {
|
||||
|
||||
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
|
||||
expect(response.items).toEqual(defaultResponse.items);
|
||||
expect(response.totalItems).toBe(defaultResponse.items.length);
|
||||
expect(response.totalItems).toBe(2);
|
||||
});
|
||||
|
||||
it('should throw error when both filter and query are provided', async () => {
|
||||
@@ -753,10 +753,7 @@ describe('CatalogClient', () => {
|
||||
|
||||
it('should send orderFields with correct format (field,order)', async () => {
|
||||
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
|
||||
const url = new URL(req.url);
|
||||
expect(url.searchParams.getAll('orderField')).toEqual([
|
||||
'metadata.name,asc',
|
||||
]);
|
||||
expect(req.body.orderField).toEqual(['metadata.name,asc']);
|
||||
return res(ctx.json(defaultResponse));
|
||||
});
|
||||
|
||||
@@ -772,8 +769,7 @@ describe('CatalogClient', () => {
|
||||
|
||||
it('should send multiple orderFields with correct format', async () => {
|
||||
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
|
||||
const url = new URL(req.url);
|
||||
expect(url.searchParams.getAll('orderField')).toEqual([
|
||||
expect(req.body.orderField).toEqual([
|
||||
'metadata.name,asc',
|
||||
'spec.type,desc',
|
||||
]);
|
||||
@@ -793,11 +789,9 @@ describe('CatalogClient', () => {
|
||||
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should send limit and offset parameters', async () => {
|
||||
it('should send limit and offset parameters in the body', async () => {
|
||||
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
|
||||
const url = new URL(req.url);
|
||||
expect(url.searchParams.get('limit')).toBe('50');
|
||||
expect(url.searchParams.get('offset')).toBe('10');
|
||||
expect(req.body.limit).toBe(50);
|
||||
return res(ctx.json(defaultResponse));
|
||||
});
|
||||
|
||||
@@ -806,32 +800,11 @@ describe('CatalogClient', () => {
|
||||
await client.queryEntities({
|
||||
query: { kind: 'component' },
|
||||
limit: 50,
|
||||
offset: 10,
|
||||
});
|
||||
|
||||
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not allow cursor with query (cursor takes precedence)', async () => {
|
||||
// When cursor is provided, it's not an initial request, so the query
|
||||
// parameter is ignored and it goes to GET endpoint
|
||||
const mockedEndpoint = jest.fn().mockImplementation((req, res, ctx) => {
|
||||
// Should use GET endpoint, not POST
|
||||
expect(req.method).toBe('GET');
|
||||
return res(ctx.json({ items: [], totalItems: 0, pageInfo: {} }));
|
||||
});
|
||||
|
||||
server.use(rest.get(`${mockBaseUrl}/entities/by-query`, mockedEndpoint));
|
||||
|
||||
// This will use GET endpoint with cursor, ignoring the query parameter
|
||||
await client.queryEntities({
|
||||
cursor: 'some-cursor',
|
||||
query: { kind: 'component' },
|
||||
} as any);
|
||||
|
||||
expect(mockedEndpoint).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle errors from POST endpoint', async () => {
|
||||
const mockedEndpoint = jest
|
||||
.fn()
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
import {
|
||||
DefaultApiClient,
|
||||
GetLocationsByQueryRequest,
|
||||
QueryEntitiesByPredicateRequest,
|
||||
TypedResponse,
|
||||
} from './schema/openapi';
|
||||
import type {
|
||||
@@ -356,61 +357,48 @@ export class CatalogClient implements CatalogApi {
|
||||
request: QueryEntitiesRequest,
|
||||
options?: CatalogRequestOptions,
|
||||
): Promise<QueryEntitiesResponse> {
|
||||
const params: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
after?: string;
|
||||
orderField?: string[];
|
||||
cursor?: string;
|
||||
fields?: string[];
|
||||
} = {};
|
||||
|
||||
let query;
|
||||
const body: Record<string, unknown> = {};
|
||||
|
||||
if (isQueryEntitiesInitialRequest(request)) {
|
||||
// Initial request with query predicate
|
||||
const { query: requestQuery, limit, offset, orderFields } = request;
|
||||
query = requestQuery;
|
||||
|
||||
if (limit !== undefined) {
|
||||
params.limit = limit;
|
||||
const { query, limit, orderFields, fullTextFilter, fields } = request;
|
||||
if (query && typeof query === 'object') {
|
||||
body.query = query;
|
||||
}
|
||||
if (offset !== undefined) {
|
||||
params.offset = offset;
|
||||
if (limit !== undefined) {
|
||||
body.limit = limit;
|
||||
}
|
||||
if (orderFields !== undefined) {
|
||||
params.orderField = (
|
||||
body.orderField = (
|
||||
Array.isArray(orderFields) ? orderFields : [orderFields]
|
||||
).map(({ field, order }) => `${field},${order}`);
|
||||
}
|
||||
} else {
|
||||
// Cursor-based pagination request
|
||||
const { cursor, limit } = request;
|
||||
|
||||
params.after = cursor;
|
||||
if (limit !== undefined) {
|
||||
params.limit = limit;
|
||||
if (fullTextFilter) {
|
||||
body.fullTextFilter = fullTextFilter;
|
||||
}
|
||||
if (fields?.length) {
|
||||
body.fields = fields;
|
||||
}
|
||||
} else {
|
||||
body.cursor = request.cursor;
|
||||
if (request.limit !== undefined) {
|
||||
body.limit = request.limit;
|
||||
}
|
||||
if (request.fields?.length) {
|
||||
body.fields = request.fields;
|
||||
}
|
||||
// Query will be extracted from cursor on the backend
|
||||
query = undefined;
|
||||
}
|
||||
|
||||
const response = await this.apiClient.queryEntitiesByPredicate(
|
||||
{
|
||||
body: query ? { query } : {},
|
||||
query: params,
|
||||
},
|
||||
options,
|
||||
const res = await this.requestRequired(
|
||||
await this.apiClient.queryEntitiesByPredicate(
|
||||
{ body: body as unknown as QueryEntitiesByPredicateRequest },
|
||||
options,
|
||||
),
|
||||
);
|
||||
|
||||
const result = await this.requestRequired(response);
|
||||
|
||||
return {
|
||||
items: result.items,
|
||||
totalItems: result.items.length,
|
||||
pageInfo: {
|
||||
nextCursor: result.pageInfo?.nextCursor,
|
||||
},
|
||||
items: res.items,
|
||||
totalItems: res.totalItems,
|
||||
pageInfo: res.pageInfo,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ import { Entity } from '../models/Entity.model';
|
||||
import { EntityAncestryResponse } from '../models/EntityAncestryResponse.model';
|
||||
import { EntityFacetsResponse } from '../models/EntityFacetsResponse.model';
|
||||
import { GetEntitiesByRefsRequest } from '../models/GetEntitiesByRefsRequest.model';
|
||||
import { QueryEntitiesByPredicate200Response } from '../models/QueryEntitiesByPredicate200Response.model';
|
||||
import { QueryEntitiesByPredicateRequest } from '../models/QueryEntitiesByPredicateRequest.model';
|
||||
import { RefreshEntityRequest } from '../models/RefreshEntityRequest.model';
|
||||
import { ValidateEntityRequest } from '../models/ValidateEntityRequest.model';
|
||||
@@ -146,12 +145,6 @@ export type GetEntityFacets = {
|
||||
*/
|
||||
export type QueryEntitiesByPredicate = {
|
||||
body: QueryEntitiesByPredicateRequest;
|
||||
query: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
orderField?: Array<string>;
|
||||
after?: string;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* @public
|
||||
@@ -466,23 +459,17 @@ export class DefaultApiClient {
|
||||
/**
|
||||
* Query entities using predicate-based filters. This endpoint provides an alternative filtering method with a more expressive filter syntax supporting logical operators ($all, $any, $not) and value operators ($exists, $in). Example query: ```json { \"query\": { \"$all\": [ {\"kind\": \"component\"}, {\"$any\": [ {\"spec.type\": \"service\"}, {\"spec.type\": \"website\"} ]}, {\"$not\": {\"spec.lifecycle\": \"experimental\"}} ] } } ```
|
||||
* @param queryEntitiesByPredicateRequest -
|
||||
* @param limit - Number of records to return in the response.
|
||||
* @param offset - Number of records to skip in the query page.
|
||||
* @param orderField - By default the entities are returned ordered by their internal uid. You can customize the `orderField` query parameters to affect that ordering. For example, to return entities by their name: `/entities/by-query?orderField=metadata.name,asc` Each parameter can be followed by `asc` for ascending lexicographical order or `desc` for descending (reverse) lexicographical order.
|
||||
* @param after - Pointer to the previous page of results.
|
||||
*/
|
||||
public async queryEntitiesByPredicate(
|
||||
// @ts-ignore
|
||||
request: QueryEntitiesByPredicate,
|
||||
options?: RequestOptions,
|
||||
): Promise<TypedResponse<QueryEntitiesByPredicate200Response>> {
|
||||
): Promise<TypedResponse<EntitiesQueryResponse>> {
|
||||
const baseUrl = await this.discoveryApi.getBaseUrl(pluginId);
|
||||
|
||||
const uriTemplate = `/entities/by-query{?limit,offset,orderField*,after}`;
|
||||
const uriTemplate = `/entities/by-query`;
|
||||
|
||||
const uri = parser.parse(uriTemplate).expand({
|
||||
...request.query,
|
||||
});
|
||||
const uri = parser.parse(uriTemplate).expand({});
|
||||
|
||||
return await this.fetchApi.fetch(`${baseUrl}${uri}`, {
|
||||
headers: {
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// ******************************************************************
|
||||
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
|
||||
// ******************************************************************
|
||||
import { EntityPredicateAll } from '../models/EntityPredicateAll.model';
|
||||
import { EntityPredicateAny } from '../models/EntityPredicateAny.model';
|
||||
import { EntityPredicateNot } from '../models/EntityPredicateNot.model';
|
||||
import { EntityPredicateValue } from '../models/EntityPredicateValue.model';
|
||||
|
||||
/**
|
||||
* A predicate-based filter supporting logical operators. - $all: All conditions must match (AND) - $any: At least one condition must match (OR) - $not: Negates the condition - $exists: Check if field exists - $in: Match any value in array
|
||||
* @public
|
||||
*/
|
||||
export type EntityPredicate =
|
||||
| EntityPredicateAll
|
||||
| EntityPredicateAny
|
||||
| EntityPredicateNot
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| { [key: string]: EntityPredicateValue };
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// ******************************************************************
|
||||
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
|
||||
// ******************************************************************
|
||||
import { EntityPredicate } from '../models/EntityPredicate.model';
|
||||
|
||||
/**
|
||||
* All conditions must match (AND logic)
|
||||
* @public
|
||||
*/
|
||||
export interface EntityPredicateAll {
|
||||
$all: Array<EntityPredicate>;
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// ******************************************************************
|
||||
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
|
||||
// ******************************************************************
|
||||
import { EntityPredicate } from '../models/EntityPredicate.model';
|
||||
|
||||
/**
|
||||
* At least one condition must match (OR logic)
|
||||
* @public
|
||||
*/
|
||||
export interface EntityPredicateAny {
|
||||
$any: Array<EntityPredicate>;
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// ******************************************************************
|
||||
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
|
||||
// ******************************************************************
|
||||
import { EntityPredicateInInInner } from '../models/EntityPredicateInInInner.model';
|
||||
|
||||
/**
|
||||
* Match any value in array
|
||||
* @public
|
||||
*/
|
||||
export interface EntityPredicateIn {
|
||||
$in: Array<EntityPredicateInInInner>;
|
||||
}
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// ******************************************************************
|
||||
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
|
||||
// ******************************************************************
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type EntityPredicateInInInner = boolean | number | string;
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// ******************************************************************
|
||||
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
|
||||
// ******************************************************************
|
||||
import { EntityPredicate } from '../models/EntityPredicate.model';
|
||||
|
||||
/**
|
||||
* Negates the condition
|
||||
* @public
|
||||
*/
|
||||
export interface EntityPredicateNot {
|
||||
$not: EntityPredicate;
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// ******************************************************************
|
||||
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
|
||||
// ******************************************************************
|
||||
import { EntityPredicateExists } from '../models/EntityPredicateExists.model';
|
||||
import { EntityPredicateIn } from '../models/EntityPredicateIn.model';
|
||||
|
||||
/**
|
||||
* Value for a field predicate
|
||||
* @public
|
||||
*/
|
||||
export type EntityPredicateValue =
|
||||
| EntityPredicateExists
|
||||
| EntityPredicateIn
|
||||
| boolean
|
||||
| number
|
||||
| string;
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// ******************************************************************
|
||||
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
|
||||
// ******************************************************************
|
||||
import { Entity } from '../models/Entity.model';
|
||||
import { QueryEntitiesByPredicate200ResponsePageInfo } from '../models/QueryEntitiesByPredicate200ResponsePageInfo.model';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface QueryEntitiesByPredicate200Response {
|
||||
/**
|
||||
* The list of entities matching the predicate filter.
|
||||
*/
|
||||
items: Array<Entity>;
|
||||
pageInfo: QueryEntitiesByPredicate200ResponsePageInfo;
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// ******************************************************************
|
||||
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
|
||||
// ******************************************************************
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface QueryEntitiesByPredicate200ResponsePageInfo {
|
||||
/**
|
||||
* The cursor for the next batch of entities.
|
||||
*/
|
||||
nextCursor?: string;
|
||||
}
|
||||
+11
-2
@@ -17,11 +17,20 @@
|
||||
// ******************************************************************
|
||||
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
|
||||
// ******************************************************************
|
||||
import { EntityPredicate } from '../models/EntityPredicate.model';
|
||||
|
||||
import { QueryEntitiesByPredicateRequestFullTextFilter } from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface QueryEntitiesByPredicateRequest {
|
||||
query?: EntityPredicate;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
orderField?: Array<string>;
|
||||
fullTextFilter?: QueryEntitiesByPredicateRequestFullTextFilter;
|
||||
fields?: Array<string>;
|
||||
/**
|
||||
* A type representing all allowed JSON object values.
|
||||
*/
|
||||
query?: { [key: string]: any };
|
||||
}
|
||||
|
||||
+3
-3
@@ -19,9 +19,9 @@
|
||||
// ******************************************************************
|
||||
|
||||
/**
|
||||
* Check if field exists
|
||||
* @public
|
||||
*/
|
||||
export interface EntityPredicateExists {
|
||||
$exists: boolean;
|
||||
export interface QueryEntitiesByPredicateRequestFullTextFilter {
|
||||
term?: string;
|
||||
fields?: Array<string>;
|
||||
}
|
||||
@@ -31,14 +31,6 @@ export * from '../models/EntityFacet.model';
|
||||
export * from '../models/EntityFacetsResponse.model';
|
||||
export * from '../models/EntityLink.model';
|
||||
export * from '../models/EntityMeta.model';
|
||||
export * from '../models/EntityPredicate.model';
|
||||
export * from '../models/EntityPredicateAll.model';
|
||||
export * from '../models/EntityPredicateAny.model';
|
||||
export * from '../models/EntityPredicateExists.model';
|
||||
export * from '../models/EntityPredicateIn.model';
|
||||
export * from '../models/EntityPredicateInInInner.model';
|
||||
export * from '../models/EntityPredicateNot.model';
|
||||
export * from '../models/EntityPredicateValue.model';
|
||||
export * from '../models/EntityRelation.model';
|
||||
export * from '../models/ErrorError.model';
|
||||
export * from '../models/ErrorRequest.model';
|
||||
@@ -53,9 +45,8 @@ export * from '../models/LocationsQueryResponse.model';
|
||||
export * from '../models/LocationsQueryResponsePageInfo.model';
|
||||
export * from '../models/ModelError.model';
|
||||
export * from '../models/NullableEntity.model';
|
||||
export * from '../models/QueryEntitiesByPredicate200Response.model';
|
||||
export * from '../models/QueryEntitiesByPredicate200ResponsePageInfo.model';
|
||||
export * from '../models/QueryEntitiesByPredicateRequest.model';
|
||||
export * from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model';
|
||||
export * from '../models/RecursivePartialEntity.model';
|
||||
export * from '../models/RecursivePartialEntityMeta.model';
|
||||
export * from '../models/RecursivePartialEntityMetaAllOf.model';
|
||||
|
||||
@@ -20,7 +20,7 @@ import type {
|
||||
AnalyzeLocationRequest,
|
||||
AnalyzeLocationResponse,
|
||||
} from '@backstage/plugin-catalog-common';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
import type { FilterPredicate } from '@backstage/filter-predicates';
|
||||
|
||||
/**
|
||||
* This symbol can be used in place of a value when passed to filters in e.g.
|
||||
|
||||
@@ -44,13 +44,4 @@ export type {
|
||||
QueryLocationsInitialRequest,
|
||||
QueryLocationsResponse,
|
||||
} from './api';
|
||||
export type {
|
||||
EntityPredicate,
|
||||
EntityPredicateAll,
|
||||
EntityPredicateAny,
|
||||
EntityPredicateNot,
|
||||
EntityPredicateValue,
|
||||
EntityPredicateExists,
|
||||
EntityPredicateIn,
|
||||
} from './predicate';
|
||||
export { ENTITY_STATUS_CATALOG_PROCESSING_TYPE } from './status';
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A predicate-based filter supporting logical operators.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This provides a more expressive filter syntax compared to the traditional
|
||||
* EntityFilterQuery. It supports:
|
||||
* - Logical operators: $all (AND), $any (OR), $not (negation)
|
||||
* - Value operators: $exists, $in
|
||||
* - Direct field matching with primitive values
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Match all service components
|
||||
* {
|
||||
* $all: [
|
||||
* { kind: 'component' },
|
||||
* { 'spec.type': 'service' }
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* // Match components owned by specific teams
|
||||
* {
|
||||
* $all: [
|
||||
* { kind: 'component' },
|
||||
* { 'spec.owner': { $in: ['backend-team', 'platform-team'] } }
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* // Match non-production services
|
||||
* {
|
||||
* $all: [
|
||||
* { kind: 'component' },
|
||||
* { 'spec.type': 'service' },
|
||||
* { $not: { 'spec.lifecycle': 'production' } }
|
||||
* ]
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type EntityPredicate =
|
||||
| EntityPredicateAll
|
||||
| EntityPredicateAny
|
||||
| EntityPredicateNot
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| { [key: string]: EntityPredicateValue };
|
||||
|
||||
/**
|
||||
* All conditions must match (AND logic).
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface EntityPredicateAll {
|
||||
$all: Array<EntityPredicate>;
|
||||
}
|
||||
|
||||
/**
|
||||
* At least one condition must match (OR logic).
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface EntityPredicateAny {
|
||||
$any: Array<EntityPredicate>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Negates the condition.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface EntityPredicateNot {
|
||||
$not: EntityPredicate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Value for a field predicate.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type EntityPredicateValue =
|
||||
| EntityPredicateExists
|
||||
| EntityPredicateIn
|
||||
| boolean
|
||||
| number
|
||||
| string;
|
||||
|
||||
/**
|
||||
* Check if field exists.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface EntityPredicateExists {
|
||||
$exists: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match any value in array.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface EntityPredicateIn {
|
||||
$in: Array<string | number | boolean>;
|
||||
}
|
||||
@@ -32,9 +32,8 @@ export function isQueryEntitiesInitialRequest(
|
||||
*/
|
||||
export function cursorContainsQuery(cursor: string): boolean {
|
||||
try {
|
||||
// Use browser-compatible base64 decoding
|
||||
const decoded = JSON.parse(atob(cursor));
|
||||
return !!decoded.query;
|
||||
return 'query' in decoded;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -53,13 +53,6 @@ export type EntitiesRequest = {
|
||||
credentials: BackstageCredentials;
|
||||
};
|
||||
|
||||
export type EntityPredicateRequest = {
|
||||
query?: FilterPredicate;
|
||||
order?: EntityOrder[];
|
||||
pagination?: EntityPagination;
|
||||
credentials: BackstageCredentials;
|
||||
};
|
||||
|
||||
/**
|
||||
* Encapsulates either a deserialized or serialized entities to be sent in a response.
|
||||
* @internal
|
||||
@@ -173,15 +166,6 @@ export interface EntitiesCatalog {
|
||||
*/
|
||||
queryEntities(request: QueryEntitiesRequest): Promise<QueryEntitiesResponse>;
|
||||
|
||||
/**
|
||||
* Fetch entities using predicate-based filters.
|
||||
*
|
||||
* @param request - Request options with predicate filter
|
||||
*/
|
||||
queryEntitiesByPredicate(
|
||||
request?: EntityPredicateRequest,
|
||||
): Promise<EntitiesResponse>;
|
||||
|
||||
/**
|
||||
* Removes a single entity.
|
||||
*
|
||||
|
||||
@@ -338,86 +338,6 @@ components:
|
||||
properties: {}
|
||||
description: A type representing all allowed JSON object values.
|
||||
additionalProperties: {}
|
||||
EntityPredicate:
|
||||
description: |
|
||||
A predicate-based filter supporting logical operators.
|
||||
- $all: All conditions must match (AND)
|
||||
- $any: At least one condition must match (OR)
|
||||
- $not: Negates the condition
|
||||
- $exists: Check if field exists
|
||||
- $in: Match any value in array
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: number
|
||||
- type: boolean
|
||||
- $ref: '#/components/schemas/EntityPredicateAll'
|
||||
- $ref: '#/components/schemas/EntityPredicateAny'
|
||||
- $ref: '#/components/schemas/EntityPredicateNot'
|
||||
- type: object
|
||||
additionalProperties:
|
||||
$ref: '#/components/schemas/EntityPredicateValue'
|
||||
EntityPredicateAll:
|
||||
type: object
|
||||
description: All conditions must match (AND logic)
|
||||
additionalProperties: false
|
||||
properties:
|
||||
$all:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/EntityPredicate'
|
||||
required:
|
||||
- $all
|
||||
EntityPredicateAny:
|
||||
type: object
|
||||
description: At least one condition must match (OR logic)
|
||||
additionalProperties: false
|
||||
properties:
|
||||
$any:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/EntityPredicate'
|
||||
required:
|
||||
- $any
|
||||
EntityPredicateNot:
|
||||
type: object
|
||||
description: Negates the condition
|
||||
additionalProperties: false
|
||||
properties:
|
||||
$not:
|
||||
$ref: '#/components/schemas/EntityPredicate'
|
||||
required:
|
||||
- $not
|
||||
EntityPredicateValue:
|
||||
description: Value for a field predicate
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: number
|
||||
- type: boolean
|
||||
- $ref: '#/components/schemas/EntityPredicateExists'
|
||||
- $ref: '#/components/schemas/EntityPredicateIn'
|
||||
EntityPredicateExists:
|
||||
type: object
|
||||
description: Check if field exists
|
||||
additionalProperties: false
|
||||
properties:
|
||||
$exists:
|
||||
type: boolean
|
||||
required:
|
||||
- $exists
|
||||
EntityPredicateIn:
|
||||
type: object
|
||||
description: Match any value in array
|
||||
additionalProperties: false
|
||||
properties:
|
||||
$in:
|
||||
type: array
|
||||
items:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: number
|
||||
- type: boolean
|
||||
required:
|
||||
- $in
|
||||
MapStringString:
|
||||
type: object
|
||||
properties: {}
|
||||
@@ -1205,22 +1125,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Entity'
|
||||
description: The list of entities matching the predicate filter.
|
||||
pageInfo:
|
||||
type: object
|
||||
properties:
|
||||
nextCursor:
|
||||
type: string
|
||||
description: The cursor for the next batch of entities.
|
||||
required:
|
||||
- items
|
||||
- pageInfo
|
||||
$ref: '#/components/schemas/EntitiesQueryResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/ErrorResponse'
|
||||
default:
|
||||
@@ -1228,45 +1133,36 @@ paths:
|
||||
security:
|
||||
- {}
|
||||
- JWT: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/limit'
|
||||
- $ref: '#/components/parameters/offset'
|
||||
- $ref: '#/components/parameters/orderField'
|
||||
- $ref: '#/components/parameters/after'
|
||||
requestBody:
|
||||
required: true
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
cursor:
|
||||
type: string
|
||||
limit:
|
||||
type: number
|
||||
orderField:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
fullTextFilter:
|
||||
type: object
|
||||
properties:
|
||||
term:
|
||||
type: string
|
||||
fields:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
fields:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
query:
|
||||
$ref: '#/components/schemas/EntityPredicate'
|
||||
examples:
|
||||
Get all service components:
|
||||
value:
|
||||
query:
|
||||
$all:
|
||||
- kind: component
|
||||
- spec.type: service
|
||||
Get components owned by specific teams:
|
||||
value:
|
||||
query:
|
||||
$all:
|
||||
- kind: component
|
||||
- spec.owner:
|
||||
$in:
|
||||
- backend-team
|
||||
- platform-team
|
||||
Get non-production services:
|
||||
value:
|
||||
query:
|
||||
$all:
|
||||
- kind: component
|
||||
- spec.type: service
|
||||
- $not:
|
||||
spec.lifecycle: production
|
||||
$ref: '#/components/schemas/JsonObject'
|
||||
/entity-facets:
|
||||
get:
|
||||
operationId: GetEntityFacets
|
||||
|
||||
@@ -26,7 +26,6 @@ import { Entity } from '../models/Entity.model';
|
||||
import { EntityAncestryResponse } from '../models/EntityAncestryResponse.model';
|
||||
import { EntityFacetsResponse } from '../models/EntityFacetsResponse.model';
|
||||
import { GetEntitiesByRefsRequest } from '../models/GetEntitiesByRefsRequest.model';
|
||||
import { QueryEntitiesByPredicate200Response } from '../models/QueryEntitiesByPredicate200Response.model';
|
||||
import { QueryEntitiesByPredicateRequest } from '../models/QueryEntitiesByPredicateRequest.model';
|
||||
import { RefreshEntityRequest } from '../models/RefreshEntityRequest.model';
|
||||
import { ValidateEntity400Response } from '../models/ValidateEntity400Response.model';
|
||||
@@ -127,13 +126,7 @@ export type GetEntityFacets = {
|
||||
*/
|
||||
export type QueryEntitiesByPredicate = {
|
||||
body: QueryEntitiesByPredicateRequest;
|
||||
query: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
orderField?: Array<string>;
|
||||
after?: string;
|
||||
};
|
||||
response: QueryEntitiesByPredicate200Response | Error | Error;
|
||||
response: EntitiesQueryResponse | Error | Error;
|
||||
};
|
||||
/**
|
||||
* @public
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// ******************************************************************
|
||||
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
|
||||
// ******************************************************************
|
||||
import { EntityPredicateAll } from '../models/EntityPredicateAll.model';
|
||||
import { EntityPredicateAny } from '../models/EntityPredicateAny.model';
|
||||
import { EntityPredicateNot } from '../models/EntityPredicateNot.model';
|
||||
import { EntityPredicateValue } from '../models/EntityPredicateValue.model';
|
||||
|
||||
/**
|
||||
* A predicate-based filter supporting logical operators. - $all: All conditions must match (AND) - $any: At least one condition must match (OR) - $not: Negates the condition - $exists: Check if field exists - $in: Match any value in array
|
||||
* @public
|
||||
*/
|
||||
export type EntityPredicate =
|
||||
| EntityPredicateAll
|
||||
| EntityPredicateAny
|
||||
| EntityPredicateNot
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| { [key: string]: EntityPredicateValue };
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// ******************************************************************
|
||||
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
|
||||
// ******************************************************************
|
||||
import { EntityPredicate } from '../models/EntityPredicate.model';
|
||||
|
||||
/**
|
||||
* All conditions must match (AND logic)
|
||||
* @public
|
||||
*/
|
||||
export interface EntityPredicateAll {
|
||||
$all: Array<EntityPredicate>;
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// ******************************************************************
|
||||
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
|
||||
// ******************************************************************
|
||||
import { EntityPredicate } from '../models/EntityPredicate.model';
|
||||
|
||||
/**
|
||||
* At least one condition must match (OR logic)
|
||||
* @public
|
||||
*/
|
||||
export interface EntityPredicateAny {
|
||||
$any: Array<EntityPredicate>;
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// ******************************************************************
|
||||
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
|
||||
// ******************************************************************
|
||||
import { EntityPredicateInInInner } from '../models/EntityPredicateInInInner.model';
|
||||
|
||||
/**
|
||||
* Match any value in array
|
||||
* @public
|
||||
*/
|
||||
export interface EntityPredicateIn {
|
||||
$in: Array<EntityPredicateInInInner>;
|
||||
}
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// ******************************************************************
|
||||
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
|
||||
// ******************************************************************
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type EntityPredicateInInInner = boolean | number | string;
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// ******************************************************************
|
||||
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
|
||||
// ******************************************************************
|
||||
import { EntityPredicate } from '../models/EntityPredicate.model';
|
||||
|
||||
/**
|
||||
* Negates the condition
|
||||
* @public
|
||||
*/
|
||||
export interface EntityPredicateNot {
|
||||
$not: EntityPredicate;
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// ******************************************************************
|
||||
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
|
||||
// ******************************************************************
|
||||
import { EntityPredicateExists } from '../models/EntityPredicateExists.model';
|
||||
import { EntityPredicateIn } from '../models/EntityPredicateIn.model';
|
||||
|
||||
/**
|
||||
* Value for a field predicate
|
||||
* @public
|
||||
*/
|
||||
export type EntityPredicateValue =
|
||||
| EntityPredicateExists
|
||||
| EntityPredicateIn
|
||||
| boolean
|
||||
| number
|
||||
| string;
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// ******************************************************************
|
||||
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
|
||||
// ******************************************************************
|
||||
import { Entity } from '../models/Entity.model';
|
||||
import { QueryEntitiesByPredicate200ResponsePageInfo } from '../models/QueryEntitiesByPredicate200ResponsePageInfo.model';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface QueryEntitiesByPredicate200Response {
|
||||
/**
|
||||
* The list of entities matching the predicate filter.
|
||||
*/
|
||||
items: Array<Entity>;
|
||||
pageInfo: QueryEntitiesByPredicate200ResponsePageInfo;
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// ******************************************************************
|
||||
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
|
||||
// ******************************************************************
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface QueryEntitiesByPredicate200ResponsePageInfo {
|
||||
/**
|
||||
* The cursor for the next batch of entities.
|
||||
*/
|
||||
nextCursor?: string;
|
||||
}
|
||||
+11
-2
@@ -17,11 +17,20 @@
|
||||
// ******************************************************************
|
||||
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
|
||||
// ******************************************************************
|
||||
import { EntityPredicate } from '../models/EntityPredicate.model';
|
||||
|
||||
import { QueryEntitiesByPredicateRequestFullTextFilter } from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface QueryEntitiesByPredicateRequest {
|
||||
query?: EntityPredicate;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
orderField?: Array<string>;
|
||||
fullTextFilter?: QueryEntitiesByPredicateRequestFullTextFilter;
|
||||
fields?: Array<string>;
|
||||
/**
|
||||
* A type representing all allowed JSON object values.
|
||||
*/
|
||||
query?: { [key: string]: any };
|
||||
}
|
||||
|
||||
+3
-3
@@ -19,9 +19,9 @@
|
||||
// ******************************************************************
|
||||
|
||||
/**
|
||||
* Check if field exists
|
||||
* @public
|
||||
*/
|
||||
export interface EntityPredicateExists {
|
||||
$exists: boolean;
|
||||
export interface QueryEntitiesByPredicateRequestFullTextFilter {
|
||||
term?: string;
|
||||
fields?: Array<string>;
|
||||
}
|
||||
@@ -31,14 +31,6 @@ export * from '../models/EntityFacet.model';
|
||||
export * from '../models/EntityFacetsResponse.model';
|
||||
export * from '../models/EntityLink.model';
|
||||
export * from '../models/EntityMeta.model';
|
||||
export * from '../models/EntityPredicate.model';
|
||||
export * from '../models/EntityPredicateAll.model';
|
||||
export * from '../models/EntityPredicateAny.model';
|
||||
export * from '../models/EntityPredicateExists.model';
|
||||
export * from '../models/EntityPredicateIn.model';
|
||||
export * from '../models/EntityPredicateInInInner.model';
|
||||
export * from '../models/EntityPredicateNot.model';
|
||||
export * from '../models/EntityPredicateValue.model';
|
||||
export * from '../models/EntityRelation.model';
|
||||
export * from '../models/ErrorError.model';
|
||||
export * from '../models/ErrorRequest.model';
|
||||
@@ -53,9 +45,8 @@ export * from '../models/LocationsQueryResponse.model';
|
||||
export * from '../models/LocationsQueryResponsePageInfo.model';
|
||||
export * from '../models/ModelError.model';
|
||||
export * from '../models/NullableEntity.model';
|
||||
export * from '../models/QueryEntitiesByPredicate200Response.model';
|
||||
export * from '../models/QueryEntitiesByPredicate200ResponsePageInfo.model';
|
||||
export * from '../models/QueryEntitiesByPredicateRequest.model';
|
||||
export * from '../models/QueryEntitiesByPredicateRequestFullTextFilter.model';
|
||||
export * from '../models/RecursivePartialEntity.model';
|
||||
export * from '../models/RecursivePartialEntityMeta.model';
|
||||
export * from '../models/RecursivePartialEntityMetaAllOf.model';
|
||||
|
||||
@@ -262,130 +262,6 @@ export const spec = {
|
||||
description: 'A type representing all allowed JSON object values.',
|
||||
additionalProperties: {},
|
||||
},
|
||||
EntityPredicate: {
|
||||
description:
|
||||
'A predicate-based filter supporting logical operators.\n- $all: All conditions must match (AND)\n- $any: At least one condition must match (OR)\n- $not: Negates the condition\n- $exists: Check if field exists\n- $in: Match any value in array\n',
|
||||
oneOf: [
|
||||
{
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
type: 'boolean',
|
||||
},
|
||||
{
|
||||
$ref: '#/components/schemas/EntityPredicateAll',
|
||||
},
|
||||
{
|
||||
$ref: '#/components/schemas/EntityPredicateAny',
|
||||
},
|
||||
{
|
||||
$ref: '#/components/schemas/EntityPredicateNot',
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: {
|
||||
$ref: '#/components/schemas/EntityPredicateValue',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
EntityPredicateAll: {
|
||||
type: 'object',
|
||||
description: 'All conditions must match (AND logic)',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
$all: {
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: '#/components/schemas/EntityPredicate',
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['$all'],
|
||||
},
|
||||
EntityPredicateAny: {
|
||||
type: 'object',
|
||||
description: 'At least one condition must match (OR logic)',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
$any: {
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: '#/components/schemas/EntityPredicate',
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['$any'],
|
||||
},
|
||||
EntityPredicateNot: {
|
||||
type: 'object',
|
||||
description: 'Negates the condition',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
$not: {
|
||||
$ref: '#/components/schemas/EntityPredicate',
|
||||
},
|
||||
},
|
||||
required: ['$not'],
|
||||
},
|
||||
EntityPredicateValue: {
|
||||
description: 'Value for a field predicate',
|
||||
oneOf: [
|
||||
{
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
type: 'boolean',
|
||||
},
|
||||
{
|
||||
$ref: '#/components/schemas/EntityPredicateExists',
|
||||
},
|
||||
{
|
||||
$ref: '#/components/schemas/EntityPredicateIn',
|
||||
},
|
||||
],
|
||||
},
|
||||
EntityPredicateExists: {
|
||||
type: 'object',
|
||||
description: 'Check if field exists',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
$exists: {
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
required: ['$exists'],
|
||||
},
|
||||
EntityPredicateIn: {
|
||||
type: 'object',
|
||||
description: 'Match any value in array',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
$in: {
|
||||
type: 'array',
|
||||
items: {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
type: 'boolean',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['$in'],
|
||||
},
|
||||
MapStringString: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
@@ -1363,28 +1239,7 @@ export const spec = {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
items: {
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: '#/components/schemas/Entity',
|
||||
},
|
||||
description:
|
||||
'The list of entities matching the predicate filter.',
|
||||
},
|
||||
pageInfo: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nextCursor: {
|
||||
type: 'string',
|
||||
description:
|
||||
'The cursor for the next batch of entities.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['items', 'pageInfo'],
|
||||
$ref: '#/components/schemas/EntitiesQueryResponse',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1402,81 +1257,47 @@ export const spec = {
|
||||
JWT: [],
|
||||
},
|
||||
],
|
||||
parameters: [
|
||||
{
|
||||
$ref: '#/components/parameters/limit',
|
||||
},
|
||||
{
|
||||
$ref: '#/components/parameters/offset',
|
||||
},
|
||||
{
|
||||
$ref: '#/components/parameters/orderField',
|
||||
},
|
||||
{
|
||||
$ref: '#/components/parameters/after',
|
||||
},
|
||||
],
|
||||
requestBody: {
|
||||
required: true,
|
||||
required: false,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
cursor: {
|
||||
type: 'string',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
},
|
||||
orderField: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
fullTextFilter: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
term: {
|
||||
type: 'string',
|
||||
},
|
||||
fields: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
query: {
|
||||
$ref: '#/components/schemas/EntityPredicate',
|
||||
},
|
||||
},
|
||||
},
|
||||
examples: {
|
||||
'Get all service components': {
|
||||
value: {
|
||||
query: {
|
||||
$all: [
|
||||
{
|
||||
kind: 'component',
|
||||
},
|
||||
{
|
||||
'spec.type': 'service',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
'Get components owned by specific teams': {
|
||||
value: {
|
||||
query: {
|
||||
$all: [
|
||||
{
|
||||
kind: 'component',
|
||||
},
|
||||
{
|
||||
'spec.owner': {
|
||||
$in: ['backend-team', 'platform-team'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
'Get non-production services': {
|
||||
value: {
|
||||
query: {
|
||||
$all: [
|
||||
{
|
||||
kind: 'component',
|
||||
},
|
||||
{
|
||||
'spec.type': 'service',
|
||||
},
|
||||
{
|
||||
$not: {
|
||||
'spec.lifecycle': 'production',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
$ref: '#/components/schemas/JsonObject',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -29,7 +29,6 @@ describe('AuthorizedEntitiesCatalog', () => {
|
||||
const fakeCatalog = {
|
||||
entities: jest.fn(),
|
||||
entitiesBatch: jest.fn(),
|
||||
queryEntitiesByPredicate: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
entityAncestry: jest.fn(),
|
||||
facets: jest.fn(),
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
EntityAncestryResponse,
|
||||
EntityFacetsRequest,
|
||||
EntityFacetsResponse,
|
||||
EntityPredicateRequest,
|
||||
QueryEntitiesRequest,
|
||||
QueryEntitiesResponse,
|
||||
} from '../catalog/types';
|
||||
@@ -197,36 +196,6 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog {
|
||||
return this.entitiesCatalog.queryEntities(request);
|
||||
}
|
||||
|
||||
async queryEntitiesByPredicate(
|
||||
request?: EntityPredicateRequest,
|
||||
): Promise<EntitiesResponse> {
|
||||
if (!request) {
|
||||
return {
|
||||
entities: { type: 'object', entities: [] },
|
||||
pageInfo: { hasNextPage: false },
|
||||
};
|
||||
}
|
||||
|
||||
const authorizeDecision = (
|
||||
await this.permissionApi.authorizeConditional(
|
||||
[{ permission: catalogEntityReadPermission }],
|
||||
{ credentials: request.credentials },
|
||||
)
|
||||
)[0];
|
||||
|
||||
if (authorizeDecision.result === AuthorizeResult.DENY) {
|
||||
return {
|
||||
entities: { type: 'object', entities: [] },
|
||||
pageInfo: { hasNextPage: false },
|
||||
};
|
||||
}
|
||||
|
||||
// Note: For CONDITIONAL results, we pass through to the underlying catalog
|
||||
// since EntityPredicate filters are separate from EntityFilter permission conditions.
|
||||
// The permission filter would need to be applied separately if needed.
|
||||
return this.entitiesCatalog.queryEntitiesByPredicate(request);
|
||||
}
|
||||
|
||||
async removeEntityByUid(
|
||||
uid: string,
|
||||
options: { credentials: BackstageCredentials },
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
EntityFacetsResponse,
|
||||
EntityOrder,
|
||||
EntityPagination,
|
||||
EntityPredicateRequest,
|
||||
QueryEntitiesRequest,
|
||||
QueryEntitiesResponse,
|
||||
} from '../catalog/types';
|
||||
@@ -46,8 +45,6 @@ import {
|
||||
import { Stitcher } from '../stitching/types';
|
||||
|
||||
import {
|
||||
decodeCursor,
|
||||
encodeCursor,
|
||||
expandLegacyCompoundRelationsInEntity,
|
||||
isQueryEntitiesCursorRequest,
|
||||
isQueryEntitiesInitialRequest,
|
||||
@@ -55,7 +52,6 @@ import {
|
||||
import { EntityFilter } from '@backstage/plugin-catalog-node';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { applyEntityFilterToQuery } from './request/applyEntityFilterToQuery';
|
||||
import { applyPredicateEntityFilterToQuery } from './request/applyPredicateEntityFilterToQuery';
|
||||
import { processRawEntitiesResult } from './response';
|
||||
|
||||
const DEFAULT_LIMIT = 200;
|
||||
@@ -217,156 +213,6 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
|
||||
};
|
||||
}
|
||||
|
||||
async queryEntitiesByPredicate(
|
||||
request?: EntityPredicateRequest,
|
||||
): Promise<EntitiesResponse> {
|
||||
const db = this.database;
|
||||
const { limit, offset } = parsePagination(request?.pagination);
|
||||
|
||||
const cursor = request?.pagination?.after
|
||||
? decodeCursor(request.pagination.after)
|
||||
: {
|
||||
query: request?.query,
|
||||
orderFields: request?.order || [],
|
||||
isPrevious: false,
|
||||
orderFieldValues: undefined,
|
||||
firstSortFieldValues: undefined,
|
||||
};
|
||||
|
||||
// Use query from cursor if not provided in request (pagination case)
|
||||
const effectiveQuery = request?.query ?? cursor.query;
|
||||
const sortField = cursor.orderFields?.[0];
|
||||
|
||||
let entitiesQuery = db<DbFinalEntitiesRow>('final_entities');
|
||||
|
||||
// Join with search table if we have a sort field
|
||||
if (sortField) {
|
||||
entitiesQuery = entitiesQuery
|
||||
.distinct()
|
||||
.leftOuterJoin({ order_0: 'search' }, function search(inner) {
|
||||
inner
|
||||
.on('order_0.entity_id', 'final_entities.entity_id')
|
||||
.andOn('order_0.key', db.raw('?', [sortField.field]));
|
||||
})
|
||||
.select({
|
||||
entity_id: 'final_entities.entity_id',
|
||||
final_entity: 'final_entities.final_entity',
|
||||
value: 'order_0.value',
|
||||
});
|
||||
} else {
|
||||
entitiesQuery = entitiesQuery.select({
|
||||
entity_id: 'final_entities.entity_id',
|
||||
final_entity: 'final_entities.final_entity',
|
||||
});
|
||||
}
|
||||
|
||||
entitiesQuery = entitiesQuery.whereNotNull('final_entities.final_entity');
|
||||
|
||||
// Apply predicate filter from cursor
|
||||
if (effectiveQuery) {
|
||||
entitiesQuery = applyPredicateEntityFilterToQuery({
|
||||
filter: effectiveQuery,
|
||||
targetQuery: entitiesQuery,
|
||||
onEntityIdField: 'final_entities.entity_id',
|
||||
knex: db,
|
||||
});
|
||||
}
|
||||
|
||||
// Apply cursor-based pagination (keyset pagination)
|
||||
if (cursor.orderFieldValues) {
|
||||
if (cursor.orderFieldValues.length === 2) {
|
||||
const [sortValue, entityId] = cursor.orderFieldValues;
|
||||
const isOrderingDescending = sortField?.order === 'desc';
|
||||
entitiesQuery = entitiesQuery.andWhere(function nested() {
|
||||
this.where(
|
||||
'order_0.value',
|
||||
isOrderingDescending ? '<' : '>',
|
||||
sortValue,
|
||||
)
|
||||
.orWhere('order_0.value', '=', sortValue)
|
||||
.andWhere('final_entities.entity_id', '>', entityId);
|
||||
});
|
||||
} else if (cursor.orderFieldValues.length === 1) {
|
||||
const [entityId] = cursor.orderFieldValues;
|
||||
entitiesQuery = entitiesQuery.andWhere(
|
||||
'final_entities.entity_id',
|
||||
'>',
|
||||
entityId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (sortField) {
|
||||
if (db.client.config.client === 'pg') {
|
||||
entitiesQuery = entitiesQuery.orderBy([
|
||||
{ column: 'order_0.value', order: sortField.order, nulls: 'last' },
|
||||
{ column: 'final_entities.entity_id', order: 'asc' },
|
||||
]);
|
||||
} else {
|
||||
entitiesQuery = entitiesQuery.orderBy([
|
||||
{ column: 'order_0.value', order: undefined, nulls: 'last' },
|
||||
{ column: 'order_0.value', order: sortField.order },
|
||||
{ column: 'final_entities.entity_id', order: 'asc' },
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
entitiesQuery = entitiesQuery.orderBy('final_entities.entity_id', 'asc');
|
||||
}
|
||||
|
||||
// Apply a manually set initial offset (only when not using cursor pagination)
|
||||
if (!request?.pagination?.after && offset !== undefined) {
|
||||
entitiesQuery = entitiesQuery.offset(offset);
|
||||
}
|
||||
const effectiveLimit = limit ?? DEFAULT_LIMIT;
|
||||
entitiesQuery = entitiesQuery.limit(effectiveLimit + 1);
|
||||
|
||||
let rows = await entitiesQuery;
|
||||
let pageInfo: DbPageInfo;
|
||||
|
||||
if (rows.length <= effectiveLimit) {
|
||||
pageInfo = { hasNextPage: false };
|
||||
} else {
|
||||
// Remove the extra row
|
||||
rows = rows.slice(0, -1);
|
||||
|
||||
const lastRow = rows[rows.length - 1];
|
||||
const firstRow = rows[0];
|
||||
|
||||
// Create proper cursor with query field
|
||||
const nextCursor: Cursor = {
|
||||
query: effectiveQuery,
|
||||
orderFields: cursor.orderFields || [],
|
||||
orderFieldValues: sortField
|
||||
? [(lastRow as any).value, lastRow.entity_id]
|
||||
: [lastRow.entity_id],
|
||||
isPrevious: false,
|
||||
firstSortFieldValues:
|
||||
cursor.firstSortFieldValues ||
|
||||
(sortField
|
||||
? [(firstRow as any).value, firstRow.entity_id]
|
||||
: [firstRow.entity_id]),
|
||||
};
|
||||
|
||||
pageInfo = {
|
||||
hasNextPage: true,
|
||||
endCursor: encodeCursor(nextCursor),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
entities: processRawEntitiesResult(
|
||||
rows.map(r => r.final_entity!),
|
||||
this.enableRelationsCompatibility
|
||||
? e => {
|
||||
expandLegacyCompoundRelationsInEntity(e);
|
||||
return e;
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
pageInfo,
|
||||
};
|
||||
}
|
||||
|
||||
async entitiesBatch(
|
||||
request: EntitiesBatchRequest,
|
||||
): Promise<EntitiesBatchResponse> {
|
||||
@@ -457,10 +303,11 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
|
||||
});
|
||||
}
|
||||
|
||||
// Add regular filters, if given
|
||||
if (cursor.filter) {
|
||||
// Add regular filters and/or predicate query, if given
|
||||
if (cursor.filter || cursor.query) {
|
||||
applyEntityFilterToQuery({
|
||||
filter: cursor.filter,
|
||||
query: cursor.query,
|
||||
targetQuery: inner,
|
||||
onEntityIdField: 'final_entities.entity_id',
|
||||
knex: this.database,
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('createRouter readonly disabled', () => {
|
||||
beforeEach(async () => {
|
||||
entitiesCatalog = {
|
||||
entities: jest.fn(),
|
||||
queryEntitiesByPredicate: jest.fn(),
|
||||
|
||||
entitiesBatch: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
entityAncestry: jest.fn(),
|
||||
@@ -480,6 +480,62 @@ describe('createRouter readonly disabled', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /entities/by-query', () => {
|
||||
it('queries entities with a predicate filter', async () => {
|
||||
const items: Entity[] = [
|
||||
{ apiVersion: 'a', kind: 'b', metadata: { name: 'n' } },
|
||||
];
|
||||
entitiesCatalog.queryEntities.mockResolvedValue({
|
||||
items: { type: 'object', entities: items },
|
||||
pageInfo: {},
|
||||
totalItems: 1,
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/entities/by-query')
|
||||
.send({ query: { kind: 'b' }, limit: 10 });
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual({
|
||||
items,
|
||||
totalItems: 1,
|
||||
pageInfo: {},
|
||||
});
|
||||
expect(entitiesCatalog.queryEntities).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
query: { kind: 'b' },
|
||||
limit: 10,
|
||||
credentials: mockCredentials.user(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('paginates with a cursor in the body', async () => {
|
||||
const items: Entity[] = [
|
||||
{ apiVersion: 'a', kind: 'b', metadata: { name: 'n' } },
|
||||
];
|
||||
const cursor = mockCursor({ totalItems: 100, isPrevious: false });
|
||||
|
||||
entitiesCatalog.queryEntities.mockResolvedValue({
|
||||
items: { type: 'object', entities: items },
|
||||
pageInfo: { nextCursor: mockCursor() },
|
||||
totalItems: 100,
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/entities/by-query')
|
||||
.send({ cursor: encodeCursor(cursor) });
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(entitiesCatalog.queryEntities).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cursor,
|
||||
credentials: mockCredentials.user(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /entities/by-uid/:uid', () => {
|
||||
it('can fetch entity by uid', async () => {
|
||||
const entity: Entity = {
|
||||
@@ -1121,7 +1177,7 @@ describe('createRouter readonly and raw json enabled', () => {
|
||||
beforeAll(async () => {
|
||||
entitiesCatalog = {
|
||||
entities: jest.fn(),
|
||||
queryEntitiesByPredicate: jest.fn(),
|
||||
|
||||
entitiesBatch: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
entityAncestry: jest.fn(),
|
||||
@@ -1338,7 +1394,7 @@ describe('NextRouter permissioning', () => {
|
||||
beforeAll(async () => {
|
||||
entitiesCatalog = {
|
||||
entities: jest.fn(),
|
||||
queryEntitiesByPredicate: jest.fn(),
|
||||
|
||||
entitiesBatch: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
entityAncestry: jest.fn(),
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
} from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import { InputError, serializeError } from '@backstage/errors';
|
||||
import { parseFilterPredicate } from '@backstage/filter-predicates';
|
||||
import { LocationAnalyzer } from '@backstage/plugin-catalog-node';
|
||||
import express from 'express';
|
||||
import yn from 'yn';
|
||||
@@ -66,7 +65,7 @@ import {
|
||||
encodeLocationQueryCursor,
|
||||
parseLocationQuery,
|
||||
} from './request/parseLocationQuery';
|
||||
import { parseEntityOrderFieldParams } from './request/parseEntityOrderFieldParams';
|
||||
import { parseEntityQuery } from './request/parseEntityQuery';
|
||||
|
||||
/**
|
||||
* Options used by {@link createRouter}.
|
||||
@@ -265,31 +264,34 @@ export async function createRouter(
|
||||
eventId: 'entity-fetch',
|
||||
request: req,
|
||||
meta: {
|
||||
queryType: 'by-query-predicate',
|
||||
queryType: 'by-query',
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// Validate the query using the Zod schema from @backstage/filter-predicates
|
||||
const query = req.body.query
|
||||
? parseFilterPredicate(req.body.query)
|
||||
: undefined;
|
||||
const order = parseEntityOrderFieldParams(req.query);
|
||||
const pagination = parseEntityPaginationParams(req.query);
|
||||
const credentials = await httpAuth.credentials(req);
|
||||
const { fields: rawFields, ...parsed } = parseEntityQuery(
|
||||
req.body ?? {},
|
||||
);
|
||||
const fields = rawFields?.length
|
||||
? parseEntityTransformParams({ fields: rawFields })
|
||||
: undefined;
|
||||
|
||||
const { entities, pageInfo } =
|
||||
await entitiesCatalog.queryEntitiesByPredicate({
|
||||
query,
|
||||
order,
|
||||
pagination,
|
||||
const { items, pageInfo, totalItems } =
|
||||
await entitiesCatalog.queryEntities({
|
||||
credentials,
|
||||
fields,
|
||||
...parsed,
|
||||
});
|
||||
|
||||
const meta = {
|
||||
totalItems,
|
||||
pageInfo: {
|
||||
...(pageInfo.hasNextPage && {
|
||||
nextCursor: pageInfo.endCursor,
|
||||
...(pageInfo.nextCursor && {
|
||||
nextCursor: encodeCursor(pageInfo.nextCursor),
|
||||
}),
|
||||
...(pageInfo.prevCursor && {
|
||||
prevCursor: encodeCursor(pageInfo.prevCursor),
|
||||
}),
|
||||
},
|
||||
};
|
||||
@@ -298,10 +300,10 @@ export async function createRouter(
|
||||
|
||||
await writeEntitiesResponse({
|
||||
res,
|
||||
items: entities,
|
||||
items,
|
||||
alwaysUseObjectMode: enableRelationsCompatibility,
|
||||
responseWrapper: items => ({
|
||||
items,
|
||||
responseWrapper: entities => ({
|
||||
items: entities,
|
||||
...meta,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -108,7 +108,7 @@ describe.each(databases.eachSupportedId())(
|
||||
}
|
||||
// #endregion
|
||||
|
||||
describe.each(strategies)('with strategy %p', strategy => {
|
||||
describe.each(strategies)('with strategy %p', _strategy => {
|
||||
async function query(filter: EntityFilter): Promise<string[]> {
|
||||
const q =
|
||||
knex<DbFinalEntitiesRow>('final_entities').whereNotNull(
|
||||
@@ -119,7 +119,6 @@ describe.each(databases.eachSupportedId())(
|
||||
targetQuery: q,
|
||||
onEntityIdField: 'final_entities.entity_id',
|
||||
knex,
|
||||
strategy,
|
||||
});
|
||||
return await q.then(rows =>
|
||||
rows
|
||||
|
||||
@@ -18,8 +18,10 @@ import {
|
||||
EntitiesSearchFilter,
|
||||
EntityFilter,
|
||||
} from '@backstage/plugin-catalog-node';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
import { Knex } from 'knex';
|
||||
import { DbSearchRow } from '../../database/tables';
|
||||
import { applyPredicateEntityFilterToQuery } from './applyPredicateEntityFilterToQuery';
|
||||
|
||||
function isEntitiesSearchFilter(
|
||||
filter: EntitiesSearchFilter | EntityFilter,
|
||||
@@ -118,13 +120,28 @@ function applyInStrategy(
|
||||
|
||||
// The actual exported function
|
||||
export function applyEntityFilterToQuery(options: {
|
||||
filter: EntityFilter;
|
||||
filter?: EntityFilter;
|
||||
query?: FilterPredicate;
|
||||
targetQuery: Knex.QueryBuilder;
|
||||
onEntityIdField: string;
|
||||
knex: Knex;
|
||||
strategy?: 'in' | 'join';
|
||||
}): Knex.QueryBuilder {
|
||||
const { filter, targetQuery, onEntityIdField, knex } = options;
|
||||
const { filter, query, targetQuery, onEntityIdField, knex } = options;
|
||||
|
||||
return applyInStrategy(filter, targetQuery, onEntityIdField, knex, false);
|
||||
let result = targetQuery;
|
||||
|
||||
if (filter) {
|
||||
result = applyInStrategy(filter, result, onEntityIdField, knex, false);
|
||||
}
|
||||
|
||||
if (query) {
|
||||
result = applyPredicateEntityFilterToQuery({
|
||||
filter: query,
|
||||
targetQuery: result,
|
||||
onEntityIdField,
|
||||
knex,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -165,15 +165,14 @@ function applyPredicateInStrategy(
|
||||
);
|
||||
}
|
||||
|
||||
// Handle primitive value at top level (e.g., "component" shorthand)
|
||||
// Reject primitives at the top level. Matching by value without specifying
|
||||
// a field key is ambiguous and should not be allowed.
|
||||
if (isPrimitive(filter)) {
|
||||
const matchQuery = knex<DbSearchRow>('search')
|
||||
.select('search.entity_id')
|
||||
.where({ value: String(filter).toLowerCase() });
|
||||
return targetQuery.andWhere(
|
||||
onEntityIdField,
|
||||
negate ? 'not in' : 'in',
|
||||
matchQuery,
|
||||
throw new InputError(
|
||||
`Invalid filter predicate: top-level primitive values are not supported. ` +
|
||||
`Wrap the value in a field expression, e.g. { "kind": ${JSON.stringify(
|
||||
filter,
|
||||
)} }`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -233,7 +232,6 @@ export function applyPredicateEntityFilterToQuery(options: {
|
||||
targetQuery: Knex.QueryBuilder;
|
||||
onEntityIdField: string;
|
||||
knex: Knex;
|
||||
strategy?: 'in' | 'join';
|
||||
}): Knex.QueryBuilder {
|
||||
const { filter, targetQuery, onEntityIdField, knex } = options;
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { InputError } from '@backstage/errors';
|
||||
import {
|
||||
createZodV3FilterPredicateSchema,
|
||||
FilterPredicate,
|
||||
} from '@backstage/filter-predicates';
|
||||
import { z } from 'zod/v3';
|
||||
import { fromZodError } from 'zod-validation-error';
|
||||
import { QueryEntitiesByPredicateRequest } from '../../schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model';
|
||||
import { EntityOrder } from '../../catalog/types';
|
||||
import { Cursor } from '../../catalog/types';
|
||||
import { decodeCursor } from '../util';
|
||||
|
||||
const filterPredicateSchema = createZodV3FilterPredicateSchema(z);
|
||||
|
||||
function isSupportedFilterPredicateRoot(
|
||||
value: FilterPredicate | undefined,
|
||||
): boolean {
|
||||
if (value === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseOrderFields(
|
||||
orderField: string[] | undefined,
|
||||
): EntityOrder[] | undefined {
|
||||
if (!orderField?.length) {
|
||||
return undefined;
|
||||
}
|
||||
return orderField.map(entry => {
|
||||
const [field, order] = entry.split(',');
|
||||
if (order !== undefined && order !== 'asc' && order !== 'desc') {
|
||||
throw new InputError('Invalid order field order, must be asc or desc');
|
||||
}
|
||||
return { field, order: order as 'asc' | 'desc' };
|
||||
});
|
||||
}
|
||||
|
||||
export type ParsedEntityQuery =
|
||||
| {
|
||||
cursor: Cursor;
|
||||
fields?: string[];
|
||||
limit?: number;
|
||||
}
|
||||
| {
|
||||
query?: FilterPredicate;
|
||||
orderFields?: EntityOrder[];
|
||||
fullTextFilter?: { term: string; fields?: string[] };
|
||||
fields?: string[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export function parseEntityQuery(
|
||||
request: Readonly<QueryEntitiesByPredicateRequest>,
|
||||
): ParsedEntityQuery {
|
||||
if (request.cursor !== undefined) {
|
||||
if (!request.cursor) {
|
||||
throw new InputError('Cursor cannot be empty');
|
||||
}
|
||||
|
||||
const cursor = decodeCursor(request.cursor);
|
||||
return {
|
||||
cursor,
|
||||
fields: request.fields,
|
||||
limit: request.limit,
|
||||
};
|
||||
}
|
||||
|
||||
let query: FilterPredicate | undefined;
|
||||
if (request.query !== undefined) {
|
||||
const result = filterPredicateSchema.safeParse(request.query);
|
||||
if (!result.success) {
|
||||
throw new InputError(`Invalid query: ${fromZodError(result.error)}`);
|
||||
}
|
||||
if (!isSupportedFilterPredicateRoot(result.data)) {
|
||||
throw new InputError('Query must be an object');
|
||||
}
|
||||
query = result.data;
|
||||
}
|
||||
|
||||
const orderFields = parseOrderFields(request.orderField);
|
||||
|
||||
return {
|
||||
query,
|
||||
orderFields,
|
||||
fullTextFilter: request.fullTextFilter
|
||||
? {
|
||||
term: request.fullTextFilter.term ?? '',
|
||||
fields: request.fullTextFilter.fields,
|
||||
}
|
||||
: undefined,
|
||||
fields: request.fields,
|
||||
limit: request.limit,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user