Merge pull request #32874 from backstage/benjdlambert/fix-predicate-review-feedback
`feat(catalog)`: support entity predicate filtering
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
---
|
||||
'@backstage/catalog-client': minor
|
||||
'@backstage/plugin-catalog-backend': minor
|
||||
---
|
||||
|
||||
Added predicate-based entity filtering via POST /entities/by-query endpoint.
|
||||
|
||||
Supports `$all`, `$any`, `$not`, `$exists`, `$in`, `$hasPrefix`, and (partially) `$contains` operators for expressive entity queries. Integrated into the existing `queryEntities` flow with full cursor-based pagination, permission enforcement, and `totalItems` support.
|
||||
|
||||
The catalog client's `queryEntities()` method automatically routes to the POST endpoint when a `query` predicate is provided.
|
||||
@@ -210,6 +210,191 @@ if `prevCursor` exists, it can be used to retrieve the previous batch of entitie
|
||||
it isn't possible to change any of [`filter`, `orderField`, `fullTextFilter`] when passing `cursor` as query parameters,
|
||||
as changing any of these properties will affect pagination. If any of `filter`, `orderField`, `fullTextFilter` is specified together with `cursor`, only the latter is taken into consideration.
|
||||
|
||||
### `POST /entities/by-query`
|
||||
|
||||
This supports the same features as the `GET` variant, but in a `POST` body to
|
||||
not have to abide by URL length limits. Additionally, it supports advanced, more
|
||||
expressive querying format - see below. The response format is identical.
|
||||
|
||||
#### Querying by filter predicate
|
||||
|
||||
You can pass in a filter predicate to select a subset of entities in the
|
||||
catalog. They are comprised of an optional logical expression tree (using
|
||||
`$all`, `$any`, `$not`), ending in filter sets that can have custom matchers
|
||||
(e.g. `$exists`, `$in`, `$hasPrefix`, `$contains`).
|
||||
|
||||
This is an example of what such a filter predicate expression might look like:
|
||||
|
||||
```js
|
||||
{
|
||||
"query": {
|
||||
"$all": [
|
||||
{
|
||||
"kind": "Component",
|
||||
"spec.type": { "$in": ["service", "website"] }
|
||||
},
|
||||
{
|
||||
"$not": {
|
||||
"metadata.annotations.backstage.io/orphan": "true"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A filter set is an object whose keys are dot separated paths into an object, and
|
||||
the values are either primitives (string, number, or boolean) or custom matchers
|
||||
as per below. An example of a simple such filter set is:
|
||||
|
||||
```js
|
||||
// All of the following must be true for a given entity (there's an
|
||||
// implicit AND between them)
|
||||
{
|
||||
// The kind field is matched against a literal, case insensitively
|
||||
"kind": "Component",
|
||||
// The type field inside the spec is matched using a custom matcher, see below
|
||||
"spec.type": { "$in": ["service", "website"] }
|
||||
}
|
||||
```
|
||||
|
||||
The root of the query is always an object, whether there is a logic expression
|
||||
tree or not. Nodes with a single key that starts with a `$` sign have special
|
||||
meaning.
|
||||
|
||||
- `$not`: Logical negation.
|
||||
|
||||
Its value must be a single expression. Example:
|
||||
|
||||
```js
|
||||
// Matches entities that do NOT have kind Component
|
||||
{
|
||||
"$not": {
|
||||
"kind": "Component",
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note that `$not` cannot be used in a right hand side value matcher.
|
||||
|
||||
```js
|
||||
// ❌ WRONG
|
||||
{ "kind": { "$not": "Component" } }
|
||||
// ✅ CORRECT
|
||||
{ "$not": { "kind": "Component" } }
|
||||
```
|
||||
|
||||
- `$all`: Require that all given expressions match each entity.
|
||||
|
||||
Its value must be an array of expressions. Example:
|
||||
|
||||
```js
|
||||
// Matches entities that BOTH have kind Component and type website
|
||||
{
|
||||
"$all": [
|
||||
{ "kind": "Component" },
|
||||
{ "spec.type": "website" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
An empty array always matches every entity.
|
||||
|
||||
- `$any`: Require that at least one of a set of expressions match a given entity.
|
||||
|
||||
Its value must be an array of expressions. Example:
|
||||
|
||||
```js
|
||||
// Matches entities that EITHER have kind Component or type website
|
||||
{
|
||||
"$any": [
|
||||
{ "kind": "Component" },
|
||||
{ "spec.type": "website" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
An empty array never matches anything.
|
||||
|
||||
- `$exists`: Assert on the existence of fields.
|
||||
|
||||
Its value is either `true`, meaning that the field must exist on the entity
|
||||
(no matter what its value), or `false`, meaning that it must not exist.
|
||||
Example:
|
||||
|
||||
```js
|
||||
// Matches entities that DO NOT have that annotation, ignoring what the
|
||||
// value might be
|
||||
{
|
||||
"metadata.annotations.backstage.io/orphan": {
|
||||
"$exists": false
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- `$in`: Assert that a field has any of a set of primitive values.
|
||||
|
||||
Its value must be an array of string, number, and/or boolean values. Example:
|
||||
|
||||
```js
|
||||
// Matches entities whose type is EITHER service or website
|
||||
{
|
||||
"spec.type": {
|
||||
"$in": ["service", "website"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The matching is case insensitive. An empty array never matches anything.
|
||||
|
||||
- `$hasPrefix`: Assert that a field is a string that starts with a certain prefix text.
|
||||
|
||||
Its value is a string. Example:
|
||||
|
||||
```js
|
||||
// Matches entities whose project slug annotation starts with "backstage/"
|
||||
{
|
||||
"metadata.annotations.github.com/project-slug": {
|
||||
"$hasPrefix": "backstage/"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The matching is case insensitive, and captures both exact matches and strings
|
||||
that start with the given prefix.
|
||||
|
||||
- `$contains`: Assert that an array contains an element that matches the given expression.
|
||||
|
||||
There is only limited support for this matcher. One use case is for relations:
|
||||
|
||||
```js
|
||||
{
|
||||
// Specifically type and (optionally) targetRef supported, and only
|
||||
// with equality or "$in" for the targetRef
|
||||
"relations": {
|
||||
"$contains": {
|
||||
"type": "ownedBy",
|
||||
"targetRef": {
|
||||
"$in": ["user:default/foo", "group:default/bar"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The other use case is for arrays where you match with a primitive value, such
|
||||
as tags. Example:
|
||||
|
||||
```js
|
||||
{
|
||||
// Works for array fields whose items are primitive values
|
||||
// (typically strings, but numbers and booleans are also supported)
|
||||
"metadata.tags": {
|
||||
"$contains": "java"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /entities`
|
||||
|
||||
Lists entities.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { BackstageCredentials } from '@backstage/backend-plugin-api';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { EntityFilter } from '@backstage/plugin-catalog-node';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
|
||||
/**
|
||||
* A pagination rule for entities.
|
||||
@@ -212,6 +213,10 @@ export interface QueryEntitiesInitialRequest {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
filter?: EntityFilter;
|
||||
/**
|
||||
* Predicate-based query for filtering entities.
|
||||
*/
|
||||
query?: FilterPredicate;
|
||||
orderFields?: EntityOrder[];
|
||||
fullTextFilter?: {
|
||||
term: string;
|
||||
@@ -273,6 +278,10 @@ export type Cursor = {
|
||||
* A filter to be applied to the full list of entities.
|
||||
*/
|
||||
filter?: EntityFilter;
|
||||
/**
|
||||
* A predicate-based query to be applied to the full list of entities.
|
||||
*/
|
||||
query?: FilterPredicate;
|
||||
/**
|
||||
* true if the cursor is a previous cursor.
|
||||
*/
|
||||
|
||||
@@ -1095,6 +1095,68 @@ paths:
|
||||
type: string
|
||||
explode: false
|
||||
style: form
|
||||
post:
|
||||
operationId: QueryEntitiesByPredicate
|
||||
tags:
|
||||
- Entity
|
||||
description: Query entities using predicate-based filters.
|
||||
responses:
|
||||
'200':
|
||||
description: Ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EntitiesQueryResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/ErrorResponse'
|
||||
default:
|
||||
$ref: '#/components/responses/ErrorResponse'
|
||||
security:
|
||||
- {}
|
||||
- JWT: []
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
cursor:
|
||||
type: string
|
||||
limit:
|
||||
type: number
|
||||
offset:
|
||||
type: number
|
||||
orderBy:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- field
|
||||
- order
|
||||
properties:
|
||||
field:
|
||||
type: string
|
||||
order:
|
||||
type: string
|
||||
enum:
|
||||
- asc
|
||||
- desc
|
||||
fullTextFilter:
|
||||
type: object
|
||||
properties:
|
||||
term:
|
||||
type: string
|
||||
fields:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
fields:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
query:
|
||||
$ref: '#/components/schemas/JsonObject'
|
||||
/entity-facets:
|
||||
get:
|
||||
operationId: GetEntityFacets
|
||||
|
||||
@@ -26,6 +26,7 @@ import { Entity } from '../models/Entity.model';
|
||||
import { EntityAncestryResponse } from '../models/EntityAncestryResponse.model';
|
||||
import { EntityFacetsResponse } from '../models/EntityFacetsResponse.model';
|
||||
import { GetEntitiesByRefsRequest } from '../models/GetEntitiesByRefsRequest.model';
|
||||
import { QueryEntitiesByPredicateRequest } from '../models/QueryEntitiesByPredicateRequest.model';
|
||||
import { RefreshEntityRequest } from '../models/RefreshEntityRequest.model';
|
||||
import { ValidateEntity400Response } from '../models/ValidateEntity400Response.model';
|
||||
import { ValidateEntityRequest } from '../models/ValidateEntityRequest.model';
|
||||
@@ -120,6 +121,13 @@ export type GetEntityFacets = {
|
||||
};
|
||||
response: EntityFacetsResponse | Error | Error;
|
||||
};
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type QueryEntitiesByPredicate = {
|
||||
body: QueryEntitiesByPredicateRequest;
|
||||
response: EntitiesQueryResponse | Error | Error;
|
||||
};
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
@@ -220,6 +228,8 @@ export type EndpointMap = {
|
||||
|
||||
'#get|/entity-facets': GetEntityFacets;
|
||||
|
||||
'#post|/entities/by-query': QueryEntitiesByPredicate;
|
||||
|
||||
'#post|/refresh': RefreshEntity;
|
||||
|
||||
'#post|/validate-entity': ValidateEntity;
|
||||
|
||||
+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';
|
||||
|
||||
@@ -1228,6 +1228,95 @@ export const spec = {
|
||||
},
|
||||
],
|
||||
},
|
||||
post: {
|
||||
operationId: 'QueryEntitiesByPredicate',
|
||||
tags: ['Entity'],
|
||||
description: 'Query entities using predicate-based filters.',
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Ok',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
$ref: '#/components/schemas/EntitiesQueryResponse',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'400': {
|
||||
$ref: '#/components/responses/ErrorResponse',
|
||||
},
|
||||
default: {
|
||||
$ref: '#/components/responses/ErrorResponse',
|
||||
},
|
||||
},
|
||||
security: [
|
||||
{},
|
||||
{
|
||||
JWT: [],
|
||||
},
|
||||
],
|
||||
requestBody: {
|
||||
required: false,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
cursor: {
|
||||
type: 'string',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
},
|
||||
orderBy: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['field', 'order'],
|
||||
properties: {
|
||||
field: {
|
||||
type: 'string',
|
||||
},
|
||||
order: {
|
||||
type: 'string',
|
||||
enum: ['asc', 'desc'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
fullTextFilter: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
term: {
|
||||
type: 'string',
|
||||
},
|
||||
fields: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
query: {
|
||||
$ref: '#/components/schemas/JsonObject',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/entity-facets': {
|
||||
get: {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { AuthorizedEntitiesCatalog } from './AuthorizedEntitiesCatalog';
|
||||
import { Cursor, QueryEntitiesResponse } from '../catalog/types';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { EntityFilter } from '@backstage/plugin-catalog-node';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
import { mockCredentials } from '@backstage/backend-test-utils';
|
||||
|
||||
describe('AuthorizedEntitiesCatalog', () => {
|
||||
@@ -306,6 +307,144 @@ describe('AuthorizedEntitiesCatalog', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('passes through query alongside permission filter on CONDITIONAL with initial request', async () => {
|
||||
fakePermissionApi.authorizeConditional.mockResolvedValue([
|
||||
{
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
conditions: {
|
||||
rule: 'IS_ENTITY_KIND',
|
||||
params: { kinds: ['b'] },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const userQuery: FilterPredicate = { 'metadata.name': 'my-entity' };
|
||||
|
||||
const entities = [
|
||||
{
|
||||
kind: 'component',
|
||||
namespace: 'default',
|
||||
name: 'a',
|
||||
} as unknown as Entity,
|
||||
];
|
||||
|
||||
fakeCatalog.queryEntities.mockResolvedValue({
|
||||
items: { type: 'object', entities },
|
||||
pageInfo: {
|
||||
nextCursor: {
|
||||
isPrevious: false,
|
||||
orderFieldValues: ['xxx', null],
|
||||
query: userQuery,
|
||||
filter: { key: 'kind', values: ['b'] },
|
||||
orderFields: [{ field: 'name', order: 'asc' }],
|
||||
},
|
||||
},
|
||||
totalItems: 1,
|
||||
} as QueryEntitiesResponse);
|
||||
|
||||
const catalog = createCatalog(isEntityKind);
|
||||
|
||||
const response = await catalog.queryEntities({
|
||||
credentials: mockCredentials.none(),
|
||||
query: userQuery,
|
||||
});
|
||||
|
||||
expect(fakeCatalog.queryEntities).toHaveBeenCalledWith({
|
||||
credentials: mockCredentials.none(),
|
||||
query: userQuery,
|
||||
filter: { key: 'kind', values: ['b'] },
|
||||
});
|
||||
|
||||
expect(response.pageInfo.nextCursor).toEqual({
|
||||
isPrevious: false,
|
||||
orderFieldValues: ['xxx', null],
|
||||
query: userQuery,
|
||||
filter: undefined,
|
||||
orderFields: [{ field: 'name', order: 'asc' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('passes through cursor query alongside permission filter on CONDITIONAL with cursor request', async () => {
|
||||
fakePermissionApi.authorizeConditional.mockResolvedValue([
|
||||
{
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
conditions: {
|
||||
rule: 'IS_ENTITY_KIND',
|
||||
params: { kinds: ['b'] },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const userQuery: FilterPredicate = { 'metadata.name': 'my-entity' };
|
||||
|
||||
const entities = [
|
||||
{
|
||||
kind: 'component',
|
||||
namespace: 'default',
|
||||
name: 'a',
|
||||
} as unknown as Entity,
|
||||
];
|
||||
|
||||
fakeCatalog.queryEntities.mockResolvedValue({
|
||||
items: { type: 'object', entities },
|
||||
pageInfo: {
|
||||
nextCursor: {
|
||||
isPrevious: false,
|
||||
orderFieldValues: ['yyy', null],
|
||||
query: userQuery,
|
||||
filter: { key: 'kind', values: ['b'] },
|
||||
orderFields: [{ field: 'name', order: 'asc' }],
|
||||
},
|
||||
prevCursor: {
|
||||
isPrevious: true,
|
||||
orderFieldValues: ['aaa', null],
|
||||
query: userQuery,
|
||||
filter: { key: 'kind', values: ['b'] },
|
||||
orderFields: [{ field: 'name', order: 'asc' }],
|
||||
},
|
||||
},
|
||||
totalItems: 3,
|
||||
} as QueryEntitiesResponse);
|
||||
|
||||
const catalog = createCatalog(isEntityKind);
|
||||
|
||||
const cursor: Cursor = {
|
||||
query: userQuery,
|
||||
orderFields: [{ field: 'name', order: 'asc' }],
|
||||
isPrevious: false,
|
||||
orderFieldValues: ['xxx', null],
|
||||
};
|
||||
|
||||
const response = await catalog.queryEntities({
|
||||
credentials: mockCredentials.none(),
|
||||
cursor,
|
||||
});
|
||||
|
||||
expect(fakeCatalog.queryEntities).toHaveBeenCalledWith({
|
||||
credentials: mockCredentials.none(),
|
||||
cursor: {
|
||||
...cursor,
|
||||
filter: { key: 'kind', values: ['b'] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.pageInfo.nextCursor).toEqual({
|
||||
isPrevious: false,
|
||||
orderFieldValues: ['yyy', null],
|
||||
query: userQuery,
|
||||
filter: undefined,
|
||||
orderFields: [{ field: 'name', order: 'asc' }],
|
||||
});
|
||||
|
||||
expect(response.pageInfo.prevCursor).toEqual({
|
||||
isPrevious: true,
|
||||
orderFieldValues: ['aaa', null],
|
||||
query: userQuery,
|
||||
filter: undefined,
|
||||
orderFields: [{ field: 'name', order: 'asc' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeEntityByUid', () => {
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
QueryEntitiesRequest,
|
||||
QueryEntitiesResponse,
|
||||
} from '../catalog/types';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
import { basicEntityFilter } from './request';
|
||||
import { isQueryEntitiesCursorRequest } from './util';
|
||||
import { EntityFilter } from '@backstage/plugin-catalog-node';
|
||||
@@ -147,9 +148,11 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog {
|
||||
|
||||
let permissionedRequest: QueryEntitiesRequest;
|
||||
let requestFilter: EntityFilter | undefined;
|
||||
let requestQuery: FilterPredicate | undefined;
|
||||
|
||||
if (isQueryEntitiesCursorRequest(request)) {
|
||||
requestFilter = request.cursor.filter;
|
||||
requestQuery = request.cursor.query;
|
||||
|
||||
permissionedRequest = {
|
||||
...request,
|
||||
@@ -161,13 +164,15 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog {
|
||||
},
|
||||
};
|
||||
} else {
|
||||
requestFilter = request.filter;
|
||||
requestQuery = request.query;
|
||||
|
||||
permissionedRequest = {
|
||||
...request,
|
||||
filter: request.filter
|
||||
? { allOf: [permissionFilter, request.filter] }
|
||||
: permissionFilter,
|
||||
};
|
||||
requestFilter = request.filter;
|
||||
}
|
||||
|
||||
const response = await this.entitiesCatalog.queryEntities(
|
||||
@@ -177,11 +182,13 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog {
|
||||
const prevCursor: Cursor | undefined = response.pageInfo.prevCursor && {
|
||||
...response.pageInfo.prevCursor,
|
||||
filter: requestFilter,
|
||||
query: requestQuery,
|
||||
};
|
||||
|
||||
const nextCursor: Cursor | undefined = response.pageInfo.nextCursor && {
|
||||
...response.pageInfo.nextCursor,
|
||||
filter: requestFilter,
|
||||
query: requestQuery,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -193,6 +200,7 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog {
|
||||
};
|
||||
}
|
||||
|
||||
// The ALLOW case
|
||||
return this.entitiesCatalog.queryEntities(request);
|
||||
}
|
||||
|
||||
|
||||
@@ -2054,6 +2054,38 @@ describe('DefaultEntitiesCatalog', () => {
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should apply both filter and query when both are given, %p',
|
||||
async databaseId => {
|
||||
await createDatabase(databaseId);
|
||||
|
||||
// Add entities with different kinds and names
|
||||
await addEntityToSearch(entityFrom('A', { kind: 'component' }));
|
||||
await addEntityToSearch(entityFrom('B', { kind: 'component' }));
|
||||
await addEntityToSearch(entityFrom('C', { kind: 'api' }));
|
||||
await addEntityToSearch(entityFrom('D', { kind: 'api' }));
|
||||
|
||||
const catalog = new DefaultEntitiesCatalog({
|
||||
database: knex,
|
||||
logger: mockServices.logger.mock(),
|
||||
stitcher,
|
||||
});
|
||||
|
||||
// Use filter to restrict to kind=component, and query to restrict to name=A
|
||||
const response = await catalog.queryEntities({
|
||||
filter: { key: 'kind', values: ['component'] },
|
||||
query: { 'metadata.name': 'a' },
|
||||
orderFields: [{ field: 'metadata.name', order: 'asc' }],
|
||||
credentials: mockCredentials.none(),
|
||||
});
|
||||
|
||||
const resultEntities = entitiesResponseToObjects(response.items);
|
||||
expect(resultEntities).toEqual([
|
||||
entityFrom('A', { kind: 'component' }),
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('removeEntityByUid', () => {
|
||||
|
||||
@@ -18,7 +18,6 @@ import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import { InputError, NotFoundError } from '@backstage/errors';
|
||||
import { Knex } from 'knex';
|
||||
import { chunk as lodashChunk, isEqual } from 'lodash';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
Cursor,
|
||||
EntitiesBatchRequest,
|
||||
@@ -49,7 +48,6 @@ import {
|
||||
isQueryEntitiesCursorRequest,
|
||||
isQueryEntitiesInitialRequest,
|
||||
} from './util';
|
||||
import { EntityFilter } from '@backstage/plugin-catalog-node';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { applyEntityFilterToQuery } from './request/applyEntityFilterToQuery';
|
||||
import { processRawEntitiesResult } from './response';
|
||||
@@ -303,10 +301,11 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
|
||||
});
|
||||
}
|
||||
|
||||
// Add regular filters, if given
|
||||
if (cursor.filter) {
|
||||
// Add regular filters and/or predicate query, if given
|
||||
if (cursor.filter || cursor.query) {
|
||||
applyEntityFilterToQuery({
|
||||
filter: cursor.filter,
|
||||
query: cursor.query,
|
||||
targetQuery: inner,
|
||||
onEntityIdField: 'final_entities.entity_id',
|
||||
knex: this.database,
|
||||
@@ -713,40 +712,24 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
|
||||
}
|
||||
}
|
||||
|
||||
const entityFilterParser: z.ZodSchema<EntityFilter> = z.lazy(() =>
|
||||
z
|
||||
.object({
|
||||
key: z.string(),
|
||||
values: z.array(z.string()).optional(),
|
||||
})
|
||||
.or(z.object({ not: entityFilterParser }))
|
||||
.or(z.object({ anyOf: z.array(entityFilterParser) }))
|
||||
.or(z.object({ allOf: z.array(entityFilterParser) })),
|
||||
);
|
||||
|
||||
export const cursorParser: z.ZodSchema<Cursor> = z.object({
|
||||
orderFields: z.array(
|
||||
z.object({ field: z.string(), order: z.enum(['asc', 'desc']) }),
|
||||
),
|
||||
orderFieldValues: z.array(z.string().or(z.null())),
|
||||
filter: entityFilterParser.optional(),
|
||||
isPrevious: z.boolean(),
|
||||
query: z.string().optional(),
|
||||
firstSortFieldValues: z.array(z.string().or(z.null())).optional(),
|
||||
totalItems: z.number().optional(),
|
||||
});
|
||||
|
||||
function parseCursorFromRequest(
|
||||
request?: QueryEntitiesRequest,
|
||||
): Partial<Cursor> & { skipTotalItems: boolean } {
|
||||
if (isQueryEntitiesInitialRequest(request)) {
|
||||
const {
|
||||
filter,
|
||||
query,
|
||||
orderFields: sortFields = [],
|
||||
fullTextFilter,
|
||||
skipTotalItems = false,
|
||||
} = request;
|
||||
return { filter, orderFields: sortFields, fullTextFilter, skipTotalItems };
|
||||
return {
|
||||
filter,
|
||||
query,
|
||||
orderFields: sortFields,
|
||||
fullTextFilter,
|
||||
skipTotalItems,
|
||||
};
|
||||
}
|
||||
if (isQueryEntitiesCursorRequest(request)) {
|
||||
return {
|
||||
|
||||
@@ -479,6 +479,87 @@ describe('createRouter readonly disabled', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /entities/by-query', () => {
|
||||
it('queries entities with a predicate filter', async () => {
|
||||
const items: Entity[] = [
|
||||
{ apiVersion: 'a', kind: 'b', metadata: { name: 'n' } },
|
||||
];
|
||||
entitiesCatalog.queryEntities.mockResolvedValue({
|
||||
items: { type: 'object', entities: items },
|
||||
pageInfo: {},
|
||||
totalItems: 1,
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/entities/by-query')
|
||||
.send({ query: { kind: 'b' }, limit: 10 });
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual({
|
||||
items,
|
||||
totalItems: 1,
|
||||
pageInfo: {},
|
||||
});
|
||||
expect(entitiesCatalog.queryEntities).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
query: { kind: 'b' },
|
||||
limit: 10,
|
||||
credentials: mockCredentials.user(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('queries entities with an offset', async () => {
|
||||
const items: Entity[] = [
|
||||
{ apiVersion: 'a', kind: 'b', metadata: { name: 'n' } },
|
||||
];
|
||||
entitiesCatalog.queryEntities.mockResolvedValue({
|
||||
items: { type: 'object', entities: items },
|
||||
pageInfo: {},
|
||||
totalItems: 5,
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/entities/by-query')
|
||||
.send({ query: { kind: 'b' }, limit: 2, offset: 3 });
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(entitiesCatalog.queryEntities).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
query: { kind: 'b' },
|
||||
limit: 2,
|
||||
offset: 3,
|
||||
credentials: mockCredentials.user(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('paginates with a cursor in the body', async () => {
|
||||
const items: Entity[] = [
|
||||
{ apiVersion: 'a', kind: 'b', metadata: { name: 'n' } },
|
||||
];
|
||||
const cursor = mockCursor({ totalItems: 100, isPrevious: false });
|
||||
|
||||
entitiesCatalog.queryEntities.mockResolvedValue({
|
||||
items: { type: 'object', entities: items },
|
||||
pageInfo: { nextCursor: mockCursor() },
|
||||
totalItems: 100,
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/entities/by-query')
|
||||
.send({ cursor: encodeCursor(cursor) });
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(entitiesCatalog.queryEntities).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cursor,
|
||||
credentials: mockCredentials.user(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /entities/by-uid/:uid', () => {
|
||||
it('can fetch entity by uid', async () => {
|
||||
const entity: Entity = {
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
encodeLocationQueryCursor,
|
||||
parseLocationQuery,
|
||||
} from './request/parseLocationQuery';
|
||||
import { parseEntityQuery } from './request/parseEntityQuery';
|
||||
|
||||
/**
|
||||
* Options used by {@link createRouter}.
|
||||
@@ -258,6 +259,59 @@ export async function createRouter(
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
.post('/entities/by-query', async (req, res) => {
|
||||
const auditorEvent = await auditor.createEvent({
|
||||
eventId: 'entity-fetch',
|
||||
request: req,
|
||||
meta: {
|
||||
queryType: 'by-query',
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const credentials = await httpAuth.credentials(req);
|
||||
const { fields: rawFields, ...parsed } = parseEntityQuery(
|
||||
req.body ?? {},
|
||||
);
|
||||
const fields = rawFields?.length
|
||||
? parseEntityTransformParams({ fields: rawFields })
|
||||
: undefined;
|
||||
|
||||
const { items, pageInfo, totalItems } =
|
||||
await entitiesCatalog.queryEntities({
|
||||
credentials,
|
||||
fields,
|
||||
...parsed,
|
||||
});
|
||||
|
||||
const meta = {
|
||||
totalItems,
|
||||
pageInfo: {
|
||||
...(pageInfo.nextCursor && {
|
||||
nextCursor: encodeCursor(pageInfo.nextCursor),
|
||||
}),
|
||||
...(pageInfo.prevCursor && {
|
||||
prevCursor: encodeCursor(pageInfo.prevCursor),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await auditorEvent?.success({ meta });
|
||||
|
||||
await writeEntitiesResponse({
|
||||
res,
|
||||
items,
|
||||
alwaysUseObjectMode: enableRelationsCompatibility,
|
||||
responseWrapper: entities => ({
|
||||
items: entities,
|
||||
...meta,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
await auditorEvent?.fail({ error: err });
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
.get('/entities/by-query', async (req, res) => {
|
||||
const auditorEvent = await auditor.createEvent({
|
||||
eventId: 'entity-fetch',
|
||||
|
||||
@@ -18,8 +18,10 @@ import {
|
||||
EntitiesSearchFilter,
|
||||
EntityFilter,
|
||||
} from '@backstage/plugin-catalog-node';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
import { Knex } from 'knex';
|
||||
import { DbSearchRow } from '../../database/tables';
|
||||
import { applyPredicateEntityFilterToQuery } from './applyPredicateEntityFilterToQuery';
|
||||
|
||||
function isEntitiesSearchFilter(
|
||||
filter: EntitiesSearchFilter | EntityFilter,
|
||||
@@ -118,13 +120,29 @@ function applyInStrategy(
|
||||
|
||||
// The actual exported function
|
||||
export function applyEntityFilterToQuery(options: {
|
||||
filter: EntityFilter;
|
||||
filter?: EntityFilter;
|
||||
query?: FilterPredicate;
|
||||
targetQuery: Knex.QueryBuilder;
|
||||
onEntityIdField: string;
|
||||
knex: Knex;
|
||||
strategy?: 'in' | 'join';
|
||||
}): Knex.QueryBuilder {
|
||||
const { filter, targetQuery, onEntityIdField, knex } = options;
|
||||
const { filter, query, targetQuery, onEntityIdField, knex } = options;
|
||||
|
||||
return applyInStrategy(filter, targetQuery, onEntityIdField, knex, false);
|
||||
let result = targetQuery;
|
||||
|
||||
if (filter) {
|
||||
result = applyInStrategy(filter, result, onEntityIdField, knex, false);
|
||||
}
|
||||
|
||||
if (query) {
|
||||
result = applyPredicateEntityFilterToQuery({
|
||||
filter: query,
|
||||
targetQuery: result,
|
||||
onEntityIdField,
|
||||
knex,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
+430
@@ -0,0 +1,430 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { TestDatabases } from '@backstage/backend-test-utils';
|
||||
import { applyEntityFilterToQuery } from './applyEntityFilterToQuery';
|
||||
import {
|
||||
DbFinalEntitiesRow,
|
||||
DbRefreshStateRow,
|
||||
DbSearchRow,
|
||||
} from '../../database/tables';
|
||||
import { Knex } from 'knex';
|
||||
import { applyDatabaseMigrations } from '../../database/migrations';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { buildEntitySearch } from '../../database/operations/stitcher/buildEntitySearch';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
describe.each(databases.eachSupportedId())(
|
||||
'applyEntityFilterToQuery with predicate queries, %p',
|
||||
databaseId => {
|
||||
let knex: Knex;
|
||||
|
||||
beforeAll(async () => {
|
||||
knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
await addEntity({
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: { name: 'service-a', namespace: 'default' },
|
||||
spec: { type: 'service', lifecycle: 'production', owner: 'team-a' },
|
||||
relations: [
|
||||
{ type: 'ownedBy', targetRef: 'group:default/team-a' },
|
||||
{ type: 'consumesApi', targetRef: 'api:default/api-d' },
|
||||
],
|
||||
});
|
||||
await addEntity({
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: { name: 'service-b', namespace: 'default' },
|
||||
spec: { type: 'service', lifecycle: 'experimental', owner: 'team-b' },
|
||||
relations: [{ type: 'ownedBy', targetRef: 'group:default/team-b' }],
|
||||
});
|
||||
await addEntity({
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: { name: 'website-c', namespace: 'default' },
|
||||
spec: { type: 'website', lifecycle: 'production', owner: 'team-a' },
|
||||
relations: [{ type: 'ownedBy', targetRef: 'group:default/team-a' }],
|
||||
});
|
||||
await addEntity({
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'API',
|
||||
metadata: { name: 'api-d', namespace: 'default' },
|
||||
spec: { type: 'openapi', lifecycle: 'production' },
|
||||
});
|
||||
await addEntity({
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'bare-e',
|
||||
namespace: 'default',
|
||||
tags: ['java', 'backend'],
|
||||
},
|
||||
spec: {},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
knex.destroy();
|
||||
});
|
||||
|
||||
async function addEntity(entity: Entity) {
|
||||
const id = uuid();
|
||||
const entityRef = stringifyEntityRef(entity);
|
||||
const entityJson = JSON.stringify(entity);
|
||||
|
||||
await knex<DbRefreshStateRow>('refresh_state').insert({
|
||||
entity_id: id,
|
||||
entity_ref: entityRef,
|
||||
unprocessed_entity: entityJson,
|
||||
errors: '[]',
|
||||
next_update_at: '2031-01-01 23:00:00',
|
||||
last_discovery_at: '2021-04-01 13:37:00',
|
||||
});
|
||||
|
||||
await knex<DbFinalEntitiesRow>('final_entities').insert({
|
||||
entity_id: id,
|
||||
entity_ref: entityRef,
|
||||
final_entity: entityJson,
|
||||
hash: 'h',
|
||||
stitch_ticket: '',
|
||||
});
|
||||
|
||||
const search = await buildEntitySearch(id, entity);
|
||||
await knex<DbSearchRow>('search').insert(search);
|
||||
}
|
||||
|
||||
async function query(predicate: FilterPredicate): Promise<string[]> {
|
||||
const q =
|
||||
knex<DbFinalEntitiesRow>('final_entities').whereNotNull('final_entity');
|
||||
applyEntityFilterToQuery({
|
||||
query: predicate,
|
||||
targetQuery: q,
|
||||
onEntityIdField: 'final_entities.entity_id',
|
||||
knex,
|
||||
});
|
||||
return await q.then(rows =>
|
||||
rows.map(row => JSON.parse(row.final_entity!).metadata.name).toSorted(),
|
||||
);
|
||||
}
|
||||
|
||||
describe('field expressions', () => {
|
||||
it('matches everything for empty field expression', async () => {
|
||||
await expect(query({})).resolves.toEqual([
|
||||
'api-d',
|
||||
'bare-e',
|
||||
'service-a',
|
||||
'service-b',
|
||||
'website-c',
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters by direct field value', async () => {
|
||||
await expect(query({ kind: 'component' })).resolves.toEqual([
|
||||
'bare-e',
|
||||
'service-a',
|
||||
'service-b',
|
||||
'website-c',
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters by exact spec field value', async () => {
|
||||
await expect(query({ 'spec.type': 'service' })).resolves.toEqual([
|
||||
'service-a',
|
||||
'service-b',
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws on unsupported field operator', async () => {
|
||||
await expect(query({ kind: { $bad: 'value' } as any })).rejects.toThrow(
|
||||
/\$contains operator, but got/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on top-level primitive', async () => {
|
||||
await expect(query('bad-value' as any)).rejects.toThrow(
|
||||
/top-level primitive values are not supported/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('$all', () => {
|
||||
it('filters with $all', async () => {
|
||||
await expect(
|
||||
query({
|
||||
$all: [{ kind: 'component' }, { 'spec.type': 'service' }],
|
||||
}),
|
||||
).resolves.toEqual(['service-a', 'service-b']);
|
||||
});
|
||||
|
||||
it('matches everything for empty $all', async () => {
|
||||
await expect(query({ $all: [] })).resolves.toEqual([
|
||||
'api-d',
|
||||
'bare-e',
|
||||
'service-a',
|
||||
'service-b',
|
||||
'website-c',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('$any', () => {
|
||||
it('filters with $any', async () => {
|
||||
await expect(
|
||||
query({
|
||||
$any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }],
|
||||
}),
|
||||
).resolves.toEqual(['service-a', 'service-b', 'website-c']);
|
||||
});
|
||||
|
||||
it('returns nothing for empty $any', async () => {
|
||||
await expect(query({ $any: [] })).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('$not', () => {
|
||||
it('filters with $not', async () => {
|
||||
await expect(
|
||||
query({
|
||||
$all: [
|
||||
{ kind: 'component' },
|
||||
{ $not: { 'spec.lifecycle': 'experimental' } },
|
||||
],
|
||||
}),
|
||||
).resolves.toEqual(['bare-e', 'service-a', 'website-c']);
|
||||
});
|
||||
|
||||
it('matches nothing for {$not: {}}', async () => {
|
||||
await expect(query({ $not: {} })).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('$in', () => {
|
||||
it('filters with $in', async () => {
|
||||
await expect(
|
||||
query({ 'spec.type': { $in: ['service', 'openapi'] } }),
|
||||
).resolves.toEqual(['api-d', 'service-a', 'service-b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('$exists', () => {
|
||||
it('filters with $exists true', async () => {
|
||||
await expect(
|
||||
query({
|
||||
$all: [{ kind: 'component' }, { 'spec.owner': { $exists: true } }],
|
||||
}),
|
||||
).resolves.toEqual(['service-a', 'service-b', 'website-c']);
|
||||
});
|
||||
|
||||
it('filters with $exists false', async () => {
|
||||
await expect(
|
||||
query({
|
||||
$all: [{ kind: 'component' }, { 'spec.owner': { $exists: false } }],
|
||||
}),
|
||||
).resolves.toEqual(['bare-e']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('$hasPrefix', () => {
|
||||
it('filters with $hasPrefix', async () => {
|
||||
await expect(
|
||||
query({ 'metadata.name': { $hasPrefix: 'service' } }),
|
||||
).resolves.toEqual(['service-a', 'service-b']);
|
||||
});
|
||||
|
||||
it('filters with $hasPrefix case-insensitively', async () => {
|
||||
await expect(
|
||||
query({ 'metadata.name': { $hasPrefix: 'Service' } }),
|
||||
).resolves.toEqual(['service-a', 'service-b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('$contains', () => {
|
||||
it('filters with primitive $contains on array fields', async () => {
|
||||
await expect(
|
||||
query({ 'metadata.tags': { $contains: 'java' } }),
|
||||
).resolves.toEqual(['bare-e']);
|
||||
});
|
||||
|
||||
it('matches relations by type only (existence)', async () => {
|
||||
await expect(
|
||||
query({ relations: { $contains: { type: 'consumesApi' } } }),
|
||||
).resolves.toEqual(['service-a']);
|
||||
});
|
||||
|
||||
it('matches relations by type and exact targetRef', async () => {
|
||||
await expect(
|
||||
query({
|
||||
relations: {
|
||||
$contains: {
|
||||
type: 'ownedBy',
|
||||
targetRef: 'group:default/team-a',
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(['service-a', 'website-c']);
|
||||
});
|
||||
|
||||
it('matches relations by type and targetRef case-insensitively', async () => {
|
||||
await expect(
|
||||
query({
|
||||
relations: {
|
||||
$contains: {
|
||||
type: 'OwnedBy',
|
||||
targetRef: 'Group:Default/Team-A',
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(['service-a', 'website-c']);
|
||||
});
|
||||
|
||||
it('handles mixed-case keys in $contains object', async () => {
|
||||
await expect(
|
||||
query({
|
||||
relations: {
|
||||
$contains: {
|
||||
TyPe: 'ownedBy',
|
||||
TargetRef: 'group:default/team-a',
|
||||
},
|
||||
},
|
||||
} as any),
|
||||
).resolves.toEqual(['service-a', 'website-c']);
|
||||
});
|
||||
|
||||
it('matches relations by type and targetRef with $in', async () => {
|
||||
await expect(
|
||||
query({
|
||||
relations: {
|
||||
$contains: {
|
||||
type: 'ownedBy',
|
||||
targetRef: {
|
||||
$in: ['group:default/team-a', 'group:default/team-b'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(['service-a', 'service-b', 'website-c']);
|
||||
});
|
||||
|
||||
it('throws on unsupported keys in $contains object', async () => {
|
||||
await expect(
|
||||
query({
|
||||
relations: {
|
||||
$contains: { type: 'ownedBy', badKey: 'value' },
|
||||
} as any,
|
||||
}),
|
||||
).rejects.toThrow(/Unsupported key "badKey" in \$contains/);
|
||||
});
|
||||
|
||||
it('throws on duplicate keys in $contains object', async () => {
|
||||
await expect(
|
||||
query({
|
||||
relations: {
|
||||
// JSON.parse allows duplicate keys, last one wins, but we
|
||||
// simulate via an object that has both casings
|
||||
$contains: JSON.parse('{"type": "ownedBy", "Type": "ownedBy"}'),
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/Duplicate key "Type" in \$contains/);
|
||||
});
|
||||
|
||||
it('throws on $contains object without type', async () => {
|
||||
await expect(
|
||||
query({
|
||||
relations: {
|
||||
$contains: { targetRef: 'group:default/team-a' },
|
||||
} as any,
|
||||
}),
|
||||
).rejects.toThrow(/requires a "type" string property/);
|
||||
});
|
||||
|
||||
it('throws on empty $in array in targetRef', async () => {
|
||||
await expect(
|
||||
query({
|
||||
relations: {
|
||||
$contains: { type: 'ownedBy', targetRef: { $in: [] } },
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/Empty "\$in" array for \$contains on "relations"/);
|
||||
});
|
||||
|
||||
it('throws on unsupported targetRef value', async () => {
|
||||
await expect(
|
||||
query({
|
||||
relations: {
|
||||
$contains: { type: 'ownedBy', targetRef: ['bad'] },
|
||||
} as any,
|
||||
}),
|
||||
).rejects.toThrow(/Unsupported value in \$contains for "relations"/);
|
||||
});
|
||||
|
||||
it('throws on object $contains for non-relations fields', async () => {
|
||||
await expect(
|
||||
query({
|
||||
'metadata.tags': {
|
||||
$contains: { type: 'something' },
|
||||
} as any,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
/Object form of \$contains is not supported for field/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nested operators', () => {
|
||||
it('handles nested logical operators', async () => {
|
||||
await expect(
|
||||
query({
|
||||
$all: [
|
||||
{ kind: 'component' },
|
||||
{
|
||||
$any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }],
|
||||
},
|
||||
{ $not: { 'spec.lifecycle': 'experimental' } },
|
||||
],
|
||||
}),
|
||||
).resolves.toEqual(['service-a', 'website-c']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined filter and query', () => {
|
||||
it('combines filter and query independently', async () => {
|
||||
const q =
|
||||
knex<DbFinalEntitiesRow>('final_entities').whereNotNull(
|
||||
'final_entity',
|
||||
);
|
||||
applyEntityFilterToQuery({
|
||||
filter: { key: 'kind', values: ['component'] },
|
||||
query: { 'spec.type': 'service' },
|
||||
targetQuery: q,
|
||||
onEntityIdField: 'final_entities.entity_id',
|
||||
knex,
|
||||
});
|
||||
const result = await q.then(rows =>
|
||||
rows
|
||||
.map(row => JSON.parse(row.final_entity!).metadata.name)
|
||||
.toSorted(),
|
||||
);
|
||||
expect(result).toEqual(['service-a', 'service-b']);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,329 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
FilterPredicate,
|
||||
FilterPredicatePrimitive,
|
||||
FilterPredicateValue,
|
||||
} from '@backstage/filter-predicates';
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { Knex } from 'knex';
|
||||
import { DbSearchRow } from '../../database/tables';
|
||||
|
||||
function isPrimitive(value: unknown): value is FilterPredicatePrimitive {
|
||||
return (
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean'
|
||||
);
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function applyPredicateEntityFilterToQuery(options: {
|
||||
filter: FilterPredicate;
|
||||
targetQuery: Knex.QueryBuilder;
|
||||
onEntityIdField: string;
|
||||
knex: Knex;
|
||||
}): Knex.QueryBuilder {
|
||||
const { filter, targetQuery, onEntityIdField, knex } = options;
|
||||
|
||||
// We do not support top-level primitives; all matching happens through objects
|
||||
if (!isObject(filter)) {
|
||||
const actual = JSON.stringify(filter);
|
||||
throw new InputError(
|
||||
`Invalid filter predicate: top-level primitive values are not supported. Wrap the value in a field expression, e.g. { "kind": ${actual} }`,
|
||||
);
|
||||
}
|
||||
|
||||
if ('$not' in filter) {
|
||||
return targetQuery.andWhereNot(inner =>
|
||||
applyPredicateEntityFilterToQuery({
|
||||
filter: filter.$not,
|
||||
targetQuery: inner,
|
||||
onEntityIdField,
|
||||
knex,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if ('$all' in filter) {
|
||||
if (filter.$all.length === 0) {
|
||||
return targetQuery.andWhereRaw('1 = 1');
|
||||
}
|
||||
return targetQuery.andWhere(outer => {
|
||||
for (const subFilter of filter.$all) {
|
||||
outer.andWhere(inner =>
|
||||
applyPredicateEntityFilterToQuery({
|
||||
filter: subFilter,
|
||||
targetQuery: inner,
|
||||
onEntityIdField,
|
||||
knex,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if ('$any' in filter) {
|
||||
if (filter.$any.length === 0) {
|
||||
return targetQuery.andWhereRaw('1 = 0');
|
||||
}
|
||||
return targetQuery.andWhere(outer => {
|
||||
for (const subFilter of filter.$any) {
|
||||
outer.orWhere(inner =>
|
||||
applyPredicateEntityFilterToQuery({
|
||||
filter: subFilter,
|
||||
targetQuery: inner,
|
||||
onEntityIdField,
|
||||
knex,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Treat the filter as a field expression like { "kind": "component" } or { "spec.type": { "$in": ["service", "website"] } }
|
||||
if (Object.keys(filter).length === 0) {
|
||||
return targetQuery.andWhereRaw('1 = 1');
|
||||
}
|
||||
return targetQuery.andWhere(inner => {
|
||||
for (const [keyAnyCase, value] of Object.entries(filter)) {
|
||||
applyFieldCondition({
|
||||
key: keyAnyCase.toLocaleLowerCase('en-US'),
|
||||
value,
|
||||
targetQuery: inner,
|
||||
onEntityIdField,
|
||||
knex,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a single { key: value } filter to the target query.
|
||||
*/
|
||||
function applyFieldCondition(options: {
|
||||
key: string;
|
||||
value: FilterPredicateValue;
|
||||
targetQuery: Knex.QueryBuilder;
|
||||
onEntityIdField: string;
|
||||
knex: Knex;
|
||||
}): Knex.QueryBuilder {
|
||||
const { key, value, targetQuery, onEntityIdField, knex } = options;
|
||||
|
||||
if (isPrimitive(value)) {
|
||||
const matchQuery = knex<DbSearchRow>('search')
|
||||
.select('search.entity_id')
|
||||
.where({
|
||||
key,
|
||||
value: String(value).toLocaleLowerCase('en-US'),
|
||||
});
|
||||
return targetQuery.andWhere(onEntityIdField, 'in', matchQuery);
|
||||
}
|
||||
|
||||
if (isObject(value)) {
|
||||
if ('$exists' in value) {
|
||||
const existsQuery = knex<DbSearchRow>('search')
|
||||
.select('search.entity_id')
|
||||
.where({ key });
|
||||
return targetQuery.andWhere(
|
||||
onEntityIdField,
|
||||
value.$exists ? 'in' : 'not in',
|
||||
existsQuery,
|
||||
);
|
||||
}
|
||||
|
||||
if ('$in' in value) {
|
||||
const values = value.$in.map(v => String(v).toLocaleLowerCase('en-US'));
|
||||
const matchQuery = knex<DbSearchRow>('search')
|
||||
.select('search.entity_id')
|
||||
.where({ key })
|
||||
.whereIn('value', values);
|
||||
return targetQuery.andWhere(onEntityIdField, 'in', matchQuery);
|
||||
}
|
||||
|
||||
if ('$hasPrefix' in value) {
|
||||
const prefix = value.$hasPrefix.toLocaleLowerCase('en-US');
|
||||
const escaped = prefix.replace(/[%_\\]/g, c => `\\${c}`);
|
||||
const matchQuery = knex<DbSearchRow>('search')
|
||||
.select('search.entity_id')
|
||||
.where({ key })
|
||||
.andWhereRaw('?? like ? escape ?', ['value', `${escaped}%`, '\\']);
|
||||
return targetQuery.andWhere(onEntityIdField, 'in', matchQuery);
|
||||
}
|
||||
|
||||
if ('$contains' in value) {
|
||||
const target = value.$contains;
|
||||
|
||||
// If the target is a primitive, match on the special array syntax.
|
||||
//
|
||||
// FROM: `{ "a": { "$contains": "b" } }`
|
||||
//
|
||||
// TO: `{ "a": "b" }`
|
||||
//
|
||||
// The search table does not actually show us that "a" was an array to
|
||||
// begin with, so this can mistakenly also match on an object that had a
|
||||
// "b" key with a primitive value. We'll consider that an acceptable
|
||||
// tradeoff though.
|
||||
if (isPrimitive(target)) {
|
||||
const matchQuery = knex<DbSearchRow>('search')
|
||||
.select('search.entity_id')
|
||||
.where({
|
||||
key,
|
||||
value: String(target).toLocaleLowerCase('en-US'),
|
||||
});
|
||||
return targetQuery.andWhere(onEntityIdField, 'in', matchQuery);
|
||||
}
|
||||
|
||||
// Object form of $contains - currently only supports relation-style
|
||||
// objects with "type" and optional "targetRef" keys.
|
||||
//
|
||||
// FROM: `{ "relations": { "$contains": { "type": "ownedBy", "targetRef": "group:default/team-a" } } }`
|
||||
//
|
||||
// TO: search for key = "relations.ownedby" AND value = "group:default/team-a"
|
||||
if (isObject(target)) {
|
||||
if (key === 'relations') {
|
||||
return applyContainsRelation({
|
||||
target,
|
||||
targetQuery,
|
||||
onEntityIdField,
|
||||
knex,
|
||||
});
|
||||
}
|
||||
|
||||
throw new InputError(
|
||||
`Object form of $contains is not supported for field "${key}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const actual = JSON.stringify(target);
|
||||
throw new InputError(
|
||||
`Unsupported $contains target for field "${key}": ${actual}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const actual = JSON.stringify(value);
|
||||
throw new InputError(
|
||||
`Invalid filter predicate value for field "${key}": expected a primitive value, $exists, $in, $hasPrefix, or $contains operator, but got ${actual}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles expressions on the form
|
||||
*
|
||||
* ```
|
||||
* {
|
||||
* "relations": {
|
||||
* "$contains": {
|
||||
* "type": "ownedBy",
|
||||
* "targetRef": "group:default/team-a"
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* which map onto the search table's special `relation.<type>: <targetRef>`
|
||||
* syntax.
|
||||
*
|
||||
* Only the keys "type" and "targetRef" are supported. The "type" key is
|
||||
* required. If "targetRef" is omitted, it becomes an existence check for any
|
||||
* relation of that type. The "targetRef" value can be a string or an `$in`
|
||||
* array.
|
||||
*/
|
||||
function applyContainsRelation(options: {
|
||||
target: Record<string, unknown>;
|
||||
targetQuery: Knex.QueryBuilder;
|
||||
onEntityIdField: string;
|
||||
knex: Knex;
|
||||
}): Knex.QueryBuilder {
|
||||
const { target: rawTarget, targetQuery, onEntityIdField, knex } = options;
|
||||
|
||||
function parseStringOrIn(value: unknown): string[] {
|
||||
if (typeof value === 'string') {
|
||||
return [value.toLocaleLowerCase('en-US')];
|
||||
}
|
||||
if (
|
||||
isObject(value) &&
|
||||
Object.keys(value).length === 1 &&
|
||||
'$in' in value &&
|
||||
Array.isArray(value.$in) &&
|
||||
value.$in.every((v): v is string => typeof v === 'string')
|
||||
) {
|
||||
if (value.$in.length === 0) {
|
||||
throw new InputError(
|
||||
`Empty "$in" array for $contains on "relations" is not allowed`,
|
||||
);
|
||||
}
|
||||
return value.$in.map(v => v.toLocaleLowerCase('en-US'));
|
||||
}
|
||||
const actual = JSON.stringify(value);
|
||||
throw new InputError(
|
||||
`Unsupported value in $contains for "relations": expected a string or { "$in": [strings] }, but got ${actual}`,
|
||||
);
|
||||
}
|
||||
|
||||
let type: string | undefined;
|
||||
let targetRef: string[] | undefined;
|
||||
|
||||
for (const [rawKey, value] of Object.entries(rawTarget)) {
|
||||
const key = rawKey.toLocaleLowerCase('en-US');
|
||||
|
||||
if (key === 'type') {
|
||||
if (type !== undefined) {
|
||||
throw new InputError(
|
||||
`Duplicate key "${rawKey}" in $contains for "relations"`,
|
||||
);
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new InputError(
|
||||
`The $contains operator for "relations" requires a "type" string property`,
|
||||
);
|
||||
}
|
||||
type = value;
|
||||
} else if (key === 'targetref') {
|
||||
if (targetRef !== undefined) {
|
||||
throw new InputError(
|
||||
`Duplicate key "${rawKey}" in $contains for "relations"`,
|
||||
);
|
||||
}
|
||||
targetRef = parseStringOrIn(value);
|
||||
} else {
|
||||
throw new InputError(
|
||||
`Unsupported key "${rawKey}" in $contains for "relations". Only "type" and "targetRef" are supported`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!type) {
|
||||
throw new InputError(
|
||||
`The $contains operator for "relations" requires a "type" string property`,
|
||||
);
|
||||
}
|
||||
|
||||
const matchQuery = knex<DbSearchRow>('search')
|
||||
.select('search.entity_id')
|
||||
.where({ key: `relations.${type.toLocaleLowerCase('en-US')}` });
|
||||
|
||||
if (targetRef) {
|
||||
matchQuery.whereIn('value', targetRef);
|
||||
}
|
||||
|
||||
return targetQuery.andWhere(onEntityIdField, 'in', matchQuery);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { parseEntityQuery } from './parseEntityQuery';
|
||||
import { encodeCursor } from '../util';
|
||||
import { Cursor } from '../../catalog/types';
|
||||
|
||||
describe('parseEntityQuery', () => {
|
||||
describe('initial request', () => {
|
||||
it('returns empty result for empty request', () => {
|
||||
const result = parseEntityQuery({});
|
||||
expect(result).toEqual({
|
||||
query: undefined,
|
||||
orderFields: undefined,
|
||||
fullTextFilter: undefined,
|
||||
fields: undefined,
|
||||
limit: undefined,
|
||||
offset: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a simple query predicate', () => {
|
||||
const query = { kind: 'component' };
|
||||
const result = parseEntityQuery({ query });
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ query: { kind: 'component' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('parses a complex query with $all, $any, $not', () => {
|
||||
const query = {
|
||||
$all: [
|
||||
{ kind: 'component' },
|
||||
{ $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }] },
|
||||
{ $not: { 'spec.lifecycle': 'experimental' } },
|
||||
],
|
||||
};
|
||||
const result = parseEntityQuery({ query });
|
||||
expect(result).toEqual(expect.objectContaining({ query }));
|
||||
});
|
||||
|
||||
it('parses query with $exists operator', () => {
|
||||
const query = { 'metadata.labels.team': { $exists: true } };
|
||||
const result = parseEntityQuery({ query });
|
||||
expect(result).toEqual(expect.objectContaining({ query }));
|
||||
});
|
||||
|
||||
it('parses query with $in operator', () => {
|
||||
const query = { kind: { $in: ['component', 'api'] } };
|
||||
const result = parseEntityQuery({ query });
|
||||
expect(result).toEqual(expect.objectContaining({ query }));
|
||||
});
|
||||
|
||||
it('passes through limit', () => {
|
||||
const result = parseEntityQuery({ limit: 50 });
|
||||
expect(result).toEqual(expect.objectContaining({ limit: 50 }));
|
||||
});
|
||||
|
||||
it('passes through offset', () => {
|
||||
const result = parseEntityQuery({ offset: 100 });
|
||||
expect(result).toEqual(expect.objectContaining({ offset: 100 }));
|
||||
});
|
||||
|
||||
it('passes through limit and offset together', () => {
|
||||
const result = parseEntityQuery({ limit: 50, offset: 100 });
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ limit: 50, offset: 100 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes through fields', () => {
|
||||
const result = parseEntityQuery({
|
||||
fields: ['metadata.name', 'kind'],
|
||||
});
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ fields: ['metadata.name', 'kind'] }),
|
||||
);
|
||||
});
|
||||
|
||||
it('parses orderBy into orderFields', () => {
|
||||
const result = parseEntityQuery({
|
||||
orderBy: [
|
||||
{ field: 'metadata.name', order: 'asc' },
|
||||
{ field: 'metadata.namespace', order: 'desc' },
|
||||
],
|
||||
});
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
orderFields: [
|
||||
{ field: 'metadata.name', order: 'asc' },
|
||||
{ field: 'metadata.namespace', order: 'desc' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('parses fullTextFilter', () => {
|
||||
const result = parseEntityQuery({
|
||||
fullTextFilter: { term: 'search term', fields: ['metadata.name'] },
|
||||
});
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
fullTextFilter: {
|
||||
term: 'search term',
|
||||
fields: ['metadata.name'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults fullTextFilter term to empty string when missing', () => {
|
||||
const result = parseEntityQuery({
|
||||
fullTextFilter: {} as any,
|
||||
});
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
fullTextFilter: { term: '', fields: undefined },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on invalid query predicate', () => {
|
||||
expect(() =>
|
||||
parseEntityQuery({ query: { $invalid: true } as any }),
|
||||
).toThrow(/Invalid query/);
|
||||
});
|
||||
|
||||
it('throws when query root is not an object', () => {
|
||||
expect(() => parseEntityQuery({ query: 'bad' as any })).toThrow(
|
||||
/Query must be an object/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on invalid orderBy order value', () => {
|
||||
expect(() =>
|
||||
parseEntityQuery({
|
||||
// @ts-expect-error - invalid order value
|
||||
orderBy: [{ field: 'metadata.name', order: 'sideways' }],
|
||||
}),
|
||||
).toThrow(/Invalid order field order/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cursor request', () => {
|
||||
function makeCursor(partial: Partial<Cursor>): string {
|
||||
const full: Cursor = {
|
||||
orderFields: [],
|
||||
orderFieldValues: [],
|
||||
isPrevious: false,
|
||||
...partial,
|
||||
};
|
||||
return encodeCursor(full);
|
||||
}
|
||||
|
||||
it('decodes a valid cursor', () => {
|
||||
const cursor = makeCursor({
|
||||
orderFields: [{ field: 'metadata.name', order: 'asc' }],
|
||||
orderFieldValues: ['test'],
|
||||
isPrevious: false,
|
||||
});
|
||||
const result = parseEntityQuery({ cursor });
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
cursor: expect.objectContaining({
|
||||
orderFields: [{ field: 'metadata.name', order: 'asc' }],
|
||||
orderFieldValues: ['test'],
|
||||
isPrevious: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes through limit and fields with cursor', () => {
|
||||
const cursor = makeCursor({});
|
||||
const result = parseEntityQuery({
|
||||
cursor,
|
||||
limit: 25,
|
||||
fields: ['metadata.name'],
|
||||
});
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
limit: 25,
|
||||
fields: ['metadata.name'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on empty cursor string', () => {
|
||||
expect(() => parseEntityQuery({ cursor: '' })).toThrow(
|
||||
/Cursor cannot be empty/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on invalid base64 cursor', () => {
|
||||
expect(() => parseEntityQuery({ cursor: '!!not-valid!!' })).toThrow(
|
||||
/Malformed cursor/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on invalid JSON in cursor', () => {
|
||||
const cursor = Buffer.from('not json', 'utf8').toString('base64');
|
||||
expect(() => parseEntityQuery({ cursor })).toThrow(/Malformed cursor/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { InputError } from '@backstage/errors';
|
||||
import {
|
||||
createZodV3FilterPredicateSchema,
|
||||
FilterPredicate,
|
||||
} from '@backstage/filter-predicates';
|
||||
import { z } from 'zod/v3';
|
||||
import { fromZodError } from 'zod-validation-error/v3';
|
||||
import { QueryEntitiesByPredicateRequest } from '../../schema/openapi/generated/models/QueryEntitiesByPredicateRequest.model';
|
||||
import { EntityOrder } from '../../catalog/types';
|
||||
import { Cursor } from '../../catalog/types';
|
||||
import { decodeCursor } from '../util';
|
||||
|
||||
const filterPredicateSchema = createZodV3FilterPredicateSchema(z);
|
||||
|
||||
function isSupportedFilterPredicateRoot(
|
||||
value: FilterPredicate | undefined,
|
||||
): boolean {
|
||||
if (value === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseOrderFields(
|
||||
orderField: Array<{ field: string; order: string }> | undefined,
|
||||
): EntityOrder[] | undefined {
|
||||
if (!orderField?.length) {
|
||||
return undefined;
|
||||
}
|
||||
return orderField.map(({ field, order }) => {
|
||||
if (order !== 'asc' && order !== 'desc') {
|
||||
throw new InputError('Invalid order field order, must be asc or desc');
|
||||
}
|
||||
return { field, order };
|
||||
});
|
||||
}
|
||||
|
||||
export type ParsedEntityQuery =
|
||||
| {
|
||||
cursor: Cursor;
|
||||
fields?: string[];
|
||||
limit?: number;
|
||||
}
|
||||
| {
|
||||
query?: FilterPredicate;
|
||||
orderFields?: EntityOrder[];
|
||||
fullTextFilter?: { term: string; fields?: string[] };
|
||||
fields?: string[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export function parseEntityQuery(
|
||||
request: Readonly<QueryEntitiesByPredicateRequest>,
|
||||
): ParsedEntityQuery {
|
||||
if (request.cursor !== undefined) {
|
||||
if (!request.cursor) {
|
||||
throw new InputError('Cursor cannot be empty');
|
||||
}
|
||||
|
||||
const cursor = decodeCursor(request.cursor);
|
||||
return {
|
||||
cursor,
|
||||
fields: request.fields,
|
||||
limit: request.limit,
|
||||
};
|
||||
}
|
||||
|
||||
let query: FilterPredicate | undefined;
|
||||
if (request.query !== undefined) {
|
||||
const result = filterPredicateSchema.safeParse(request.query);
|
||||
if (!result.success) {
|
||||
throw new InputError(`Invalid query: ${fromZodError(result.error)}`);
|
||||
}
|
||||
if (!isSupportedFilterPredicateRoot(result.data)) {
|
||||
throw new InputError('Query must be an object');
|
||||
}
|
||||
query = result.data;
|
||||
}
|
||||
|
||||
const orderFields = parseOrderFields(request.orderBy);
|
||||
|
||||
return {
|
||||
query,
|
||||
orderFields,
|
||||
fullTextFilter: request.fullTextFilter
|
||||
? {
|
||||
term: request.fullTextFilter.term ?? '',
|
||||
fields: request.fullTextFilter.fields,
|
||||
}
|
||||
: undefined,
|
||||
fields: request.fields,
|
||||
limit: request.limit,
|
||||
offset: request.offset,
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { InputError, NotAllowedError } from '@backstage/errors';
|
||||
import { createZodV3FilterPredicateSchema } from '@backstage/filter-predicates';
|
||||
import { Request } from 'express';
|
||||
import lodash from 'lodash';
|
||||
import { z } from 'zod';
|
||||
@@ -109,6 +110,8 @@ const entityFilterParser: z.ZodSchema<EntityFilter> = z.lazy(() =>
|
||||
.or(z.object({ allOf: z.array(entityFilterParser) })),
|
||||
);
|
||||
|
||||
const filterPredicateSchema = createZodV3FilterPredicateSchema(z);
|
||||
|
||||
export const cursorParser: z.ZodSchema<Cursor> = z.object({
|
||||
orderFields: z.array(
|
||||
z.object({ field: z.string(), order: z.enum(['asc', 'desc']) }),
|
||||
@@ -122,7 +125,7 @@ export const cursorParser: z.ZodSchema<Cursor> = z.object({
|
||||
orderFieldValues: z.array(z.string().or(z.null())),
|
||||
filter: entityFilterParser.optional(),
|
||||
isPrevious: z.boolean(),
|
||||
query: z.string().optional(),
|
||||
query: filterPredicateSchema.optional(),
|
||||
firstSortFieldValues: z.array(z.string().or(z.null())).optional(),
|
||||
totalItems: z.number().optional(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user