feat(catalog-backend): add predicate-based entity filtering

- Add EntityPredicate and EntityPredicateRequest types to catalog types
- Implement queryEntitiesByPredicate method in DefaultEntitiesCatalog for predicate-based filtering
- Create applyPredicateEntityFilterToQuery utility to convert predicates to database queries

Signed-off-by: Nilay1999 <nilayparmar19@gmail.com>
This commit is contained in:
Nilay1999
2026-01-12 11:30:26 +05:30
committed by benjdlambert
parent 8c7f4e1aa8
commit e5ee4d8da7
5 changed files with 415 additions and 1 deletions
+17 -1
View File
@@ -16,7 +16,7 @@
import { BackstageCredentials } from '@backstage/backend-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { EntityFilter } from '@backstage/plugin-catalog-node';
import { EntityFilter, EntityPredicate } from '@backstage/plugin-catalog-node';
/**
* A pagination rule for entities.
@@ -52,6 +52,13 @@ export type EntitiesRequest = {
credentials: BackstageCredentials;
};
export type EntityPredicateRequest = {
filter?: EntityPredicate;
order?: EntityOrder[];
pagination?: EntityPagination;
credentials: BackstageCredentials;
};
/**
* Encapsulates either a deserialized or serialized entities to be sent in a response.
* @internal
@@ -165,6 +172,15 @@ export interface EntitiesCatalog {
*/
queryEntities(request: QueryEntitiesRequest): Promise<QueryEntitiesResponse>;
/**
* Fetch entities using predicate-based filters.
*
* @param request - Request options with predicate filter
*/
queryEntitiesByPredicate(
request?: EntityPredicateRequest,
): Promise<EntitiesResponse>;
/**
* Removes a single entity.
*
@@ -31,6 +31,7 @@ import {
EntityFacetsResponse,
EntityOrder,
EntityPagination,
EntityPredicateRequest,
QueryEntitiesRequest,
QueryEntitiesResponse,
} from '../catalog/types';
@@ -52,6 +53,7 @@ import {
import { EntityFilter } from '@backstage/plugin-catalog-node';
import { LoggerService } from '@backstage/backend-plugin-api';
import { applyEntityFilterToQuery } from './request/applyEntityFilterToQuery';
import { applyPredicateEntityFilterToQuery } from './request/applyPredicateEntityFilterToQuery';
import { processRawEntitiesResult } from './response';
const DEFAULT_LIMIT = 200;
@@ -213,6 +215,93 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
};
}
async queryEntitiesByPredicate(
request?: EntityPredicateRequest,
): Promise<EntitiesResponse> {
const db = this.database;
const { limit, offset } = parsePagination(request?.pagination);
let entitiesQuery =
db<DbFinalEntitiesRow>('final_entities').select('final_entities.*');
request?.order?.forEach(({ field }, index) => {
const alias = `order_${index}`;
entitiesQuery = entitiesQuery.leftOuterJoin(
{ [alias]: 'search' },
function search(inner) {
inner
.on(`${alias}.entity_id`, 'final_entities.entity_id')
.andOn(`${alias}.key`, db.raw('?', [field]));
},
);
});
entitiesQuery = entitiesQuery.whereNotNull('final_entities.final_entity');
if (request?.filter) {
entitiesQuery = applyPredicateEntityFilterToQuery({
filter: request.filter,
targetQuery: entitiesQuery,
onEntityIdField: 'final_entities.entity_id',
knex: db,
});
}
request?.order?.forEach(({ order }, index) => {
if (db.client.config.client === 'pg') {
entitiesQuery = entitiesQuery.orderBy([
{ column: `order_${index}.value`, order, nulls: 'last' },
]);
} else {
entitiesQuery = entitiesQuery.orderBy([
{ column: `order_${index}.value`, order: undefined, nulls: 'last' },
{ column: `order_${index}.value`, order },
]);
}
});
if (!request?.order) {
entitiesQuery = entitiesQuery.orderBy('final_entities.entity_ref', 'asc');
} else {
entitiesQuery.orderBy('final_entities.entity_id', 'asc');
}
if (limit !== undefined) {
entitiesQuery = entitiesQuery.limit(limit + 1);
}
if (offset !== undefined) {
entitiesQuery = entitiesQuery.offset(offset);
}
let rows = await entitiesQuery;
let pageInfo: DbPageInfo;
if (limit === undefined || rows.length <= limit) {
pageInfo = { hasNextPage: false };
} else {
rows = rows.slice(0, -1);
pageInfo = {
hasNextPage: true,
endCursor: stringifyPagination({
limit,
offset: (offset ?? 0) + limit,
}),
};
}
return {
entities: processRawEntitiesResult(
rows.map(r => r.final_entity!),
this.enableRelationsCompatibility
? e => {
expandLegacyCompoundRelationsInEntity(e);
return e;
}
: undefined,
),
pageInfo,
};
}
async entitiesBatch(
request: EntitiesBatchRequest,
): Promise<EntitiesBatchResponse> {
@@ -0,0 +1,239 @@
/*
* Copyright 2024 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 {
EntityPredicate,
EntityPredicatePrimitive,
EntityPredicateValue,
} from '@backstage/plugin-catalog-node';
import { Knex } from 'knex';
import { DbSearchRow } from '../../database/tables';
function isAllPredicate(
filter: EntityPredicate,
): filter is { $all: EntityPredicate[] } {
return typeof filter === 'object' && filter !== null && '$all' in filter;
}
function isAnyPredicate(
filter: EntityPredicate,
): filter is { $any: EntityPredicate[] } {
return typeof filter === 'object' && filter !== null && '$any' in filter;
}
function isNotPredicate(
filter: EntityPredicate,
): filter is { $not: EntityPredicate } {
return typeof filter === 'object' && filter !== null && '$not' in filter;
}
function isPrimitive(value: unknown): value is EntityPredicatePrimitive {
return (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
);
}
function isExistsValue(
value: EntityPredicateValue,
): value is { $exists: boolean } {
return typeof value === 'object' && value !== null && '$exists' in value;
}
function isInValue(
value: EntityPredicateValue,
): value is { $in: EntityPredicatePrimitive[] } {
return typeof value === 'object' && value !== null && '$in' in value;
}
function isFieldExpression(filter: EntityPredicate): boolean {
if (typeof filter !== 'object' || filter === null) {
return false;
}
// Not a logical operator
return !('$all' in filter || '$any' in filter || '$not' in filter);
}
/**
* example generated query
* Selects all non-null final entities that:
*
* - Are of kind "component"
* - Have spec.type = "service"
* - Are owned by either:
* - backend-team
* - platform-team
* - Are NOT in experimental lifecycle
*
* Results are ordered by entity_ref in ascending order.
*
* SQL Reference:
* ```
* SELECT final_entities.*
* FROM final_entities
* WHERE final_entities.final_entity IS NOT NULL
* AND final_entities.entity_id IN (
* SELECT entity_id FROM search WHERE key = 'kind' AND value = 'component'
* )
* AND final_entities.entity_id IN (
* SELECT entity_id FROM search WHERE key = 'spec.type' AND value = 'service'
* )
* AND (
* final_entities.entity_id IN (
* SELECT entity_id FROM search WHERE key = 'spec.owner' AND value = 'backend-team'
* )
* OR
* final_entities.entity_id IN (
* SELECT entity_id FROM search WHERE key = 'spec.owner' AND value = 'platform-team'
* )
* )
* AND final_entities.entity_id NOT IN (
* SELECT entity_id FROM search WHERE key = 'spec.lifecycle' AND value = 'experimental'
* )
* ORDER BY final_entities.entity_ref ASC;
* ```
*/
function applyPredicateInStrategy(
filter: EntityPredicate,
targetQuery: Knex.QueryBuilder,
onEntityIdField: string,
knex: Knex,
negate: boolean,
): Knex.QueryBuilder {
// Handle $not
if (isNotPredicate(filter)) {
return applyPredicateInStrategy(
filter.$not,
targetQuery,
onEntityIdField,
knex,
!negate,
);
}
// Handle $all (AND)
if (isAllPredicate(filter)) {
return targetQuery[negate ? 'andWhereNot' : 'andWhere'](
function allFilter() {
for (const subFilter of filter.$all) {
this.andWhere(subQuery =>
applyPredicateInStrategy(
subFilter,
subQuery,
onEntityIdField,
knex,
false,
),
);
}
},
);
}
// Handle $any (OR)
if (isAnyPredicate(filter)) {
return targetQuery[negate ? 'andWhereNot' : 'andWhere'](
function anyFilter() {
for (const subFilter of filter.$any) {
this.orWhere(subQuery =>
applyPredicateInStrategy(
subFilter,
subQuery,
onEntityIdField,
knex,
false,
),
);
}
},
);
}
// Handle primitive value at top level (e.g., "component" shorthand)
if (isPrimitive(filter)) {
const matchQuery = knex<DbSearchRow>('search')
.select('search.entity_id')
.where({ value: String(filter).toLowerCase() });
return targetQuery.andWhere(
onEntityIdField,
negate ? 'not in' : 'in',
matchQuery,
);
}
// Handle field expressions like { "kind": "component" } or { "spec.type": { "$in": ["service", "website"] } }
if (isFieldExpression(filter)) {
return targetQuery[negate ? 'andWhereNot' : 'andWhere'](
function fieldFilter() {
for (const [key, value] of Object.entries(filter)) {
const normalizedKey = key.toLowerCase();
if (isExistsValue(value)) {
// Handle $exists
const existsQuery = knex<DbSearchRow>('search')
.select('search.entity_id')
.where({ key: normalizedKey });
if (value.$exists) {
this.andWhere(onEntityIdField, 'in', existsQuery);
} else {
this.andWhere(onEntityIdField, 'not in', existsQuery);
}
} else if (isInValue(value)) {
// Handle $in
const values = value.$in.map(v => String(v).toLowerCase());
const matchQuery = knex<DbSearchRow>('search')
.select('search.entity_id')
.where({ key: normalizedKey })
.whereIn('value', values);
this.andWhere(onEntityIdField, 'in', matchQuery);
} else if (isPrimitive(value)) {
// Handle direct value match
const matchQuery = knex<DbSearchRow>('search')
.select('search.entity_id')
.where({
key: normalizedKey,
value: String(value).toLowerCase(),
});
this.andWhere(onEntityIdField, 'in', matchQuery);
}
}
},
);
}
return targetQuery;
}
export function applyPredicateEntityFilterToQuery(options: {
filter: EntityPredicate;
targetQuery: Knex.QueryBuilder;
onEntityIdField: string;
knex: Knex;
strategy?: 'in' | 'join';
}): Knex.QueryBuilder {
const { filter, targetQuery, onEntityIdField, knex } = options;
return applyPredicateInStrategy(
filter,
targetQuery,
onEntityIdField,
knex,
false,
);
}
+66
View File
@@ -85,3 +85,69 @@ export type EntitiesSearchFilter = {
*/
values?: string[];
};
/**
* Defines a predicate used to filter entities using logical and comparison operators.
*
* This type supports:
* - Primitive equality matching
* - Field existence checks
* - Inclusion checks
* - Logical composition using $all, $any, and $not
*
* @public
*/
export type EntityPredicate =
| EntityPredicateExpression
| EntityPredicatePrimitive
| { $all: EntityPredicate[] }
| { $any: EntityPredicate[] }
| { $not: EntityPredicate };
/**
* Represents a field-based predicate expression.
*
* Each key is treated as a JSON path into the entity object.
* Keys starting with `$` are reserved for operators and are not allowed.
*
* Example:
* ```ts
* {
* "spec.type": "service",
* "metadata.name": { $in: ["payments", "orders"] }
* }
* ```
*
* @public
*/
export type EntityPredicateExpression = {
/** JSON path of the entity field */
[KPath in string]: EntityPredicateValue;
} & {
/** Operator keys are not allowed as field paths */
[KPath in `$${string}`]: never;
};
/**
* Represents a value condition for a predicate field.
*
* Supported forms:
* - Primitive equality match
* - Existence check using `$exists`
* - Inclusion check using `$in`
*
* @public
*/
export type EntityPredicateValue =
| EntityPredicatePrimitive
| { $exists: boolean }
| { $in: EntityPredicatePrimitive[] };
/**
* Represents a primitive predicate value.
*
* Used for direct equality comparison.
*
* @public
*/
export type EntityPredicatePrimitive = string | number | boolean;
+4
View File
@@ -20,6 +20,10 @@ export type {
LocationSpec,
EntitiesSearchFilter,
EntityFilter,
EntityPredicate,
EntityPredicateExpression,
EntityPredicateValue,
EntityPredicatePrimitive,
} from './common';
export type {
CatalogProcessor,