From c216b1a6f556e55ea08477da2ef398d8846de516 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 20 Feb 2025 11:51:37 +0100 Subject: [PATCH 01/16] catalog-react: add initial MongoDB-based entity predicates Signed-off-by: Patrik Oldsberg --- plugins/catalog-react/package.json | 3 +- .../alpha/blueprints/EntityCardBlueprint.ts | 18 +- .../blueprints/EntityContentBlueprint.ts | 18 +- .../EntityContentLayoutBlueprint.tsx | 18 +- .../blueprints/resolveEntityFilterData.ts | 54 +++++ plugins/catalog-react/src/alpha/index.ts | 1 + .../createEntityPredicateSchema.test.ts | 100 ++++++++ .../predicates/createEntityPredicateSchema.ts | 47 ++++ .../evaluateEntityPredicate.test.ts | 214 ++++++++++++++++++ .../predicates/evaluateEntityPredicate.ts | 134 +++++++++++ .../src/alpha/predicates/index.ts | 26 +++ .../src/alpha/predicates/types.ts | 44 ++++ .../src/alpha/predicates/valueAtPath.test.ts | 63 ++++++ .../src/alpha/predicates/valueAtPath.ts | 69 ++++++ yarn.lock | 1 + 15 files changed, 776 insertions(+), 34 deletions(-) create mode 100644 plugins/catalog-react/src/alpha/blueprints/resolveEntityFilterData.ts create mode 100644 plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts create mode 100644 plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts create mode 100644 plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts create mode 100644 plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts create mode 100644 plugins/catalog-react/src/alpha/predicates/index.ts create mode 100644 plugins/catalog-react/src/alpha/predicates/types.ts create mode 100644 plugins/catalog-react/src/alpha/predicates/valueAtPath.test.ts create mode 100644 plugins/catalog-react/src/alpha/predicates/valueAtPath.ts diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index 8a3166961d..33893deefc 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -102,7 +102,8 @@ "react": "^18.0.2", "react-dom": "^18.0.2", "react-router-dom": "^6.3.0", - "react-test-renderer": "^16.13.1" + "react-test-renderer": "^16.13.1", + "zod": "^3.22.4" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0", diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts index 5ef6106f40..4639a8863f 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts +++ b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts @@ -26,6 +26,9 @@ import { entityCardTypes, EntityCardType, } from './extensionData'; +import { createEntityPredicateSchema } from '../predicates/createEntityPredicateSchema'; +import { EntityPredicate } from '../predicates'; +import { resolveEntityFilterData } from './resolveEntityFilterData'; /** * @alpha @@ -47,7 +50,8 @@ export const EntityCardBlueprint = createExtensionBlueprint({ }, config: { schema: { - filter: z => z.string().optional(), + filter: z => + z.union([z.string(), createEntityPredicateSchema(z)]).optional(), type: z => z.enum(entityCardTypes).optional(), }, }, @@ -58,22 +62,14 @@ export const EntityCardBlueprint = createExtensionBlueprint({ type, }: { loader: () => Promise; - filter?: - | typeof entityFilterFunctionDataRef.T - | typeof entityFilterExpressionDataRef.T; + filter?: EntityPredicate | typeof entityFilterFunctionDataRef.T; type?: EntityCardType; }, { node, config }, ) { yield coreExtensionData.reactElement(ExtensionBoundary.lazy(node, loader)); - if (config.filter) { - yield entityFilterExpressionDataRef(config.filter); - } else if (typeof filter === 'string') { - yield entityFilterExpressionDataRef(filter); - } else if (typeof filter === 'function') { - yield entityFilterFunctionDataRef(filter); - } + yield* resolveEntityFilterData(filter, config, node); const finalType = config.type ?? type; if (finalType) { diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts index aa3608c299..9c67f3c0bc 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts @@ -27,6 +27,9 @@ import { entityContentGroupDataRef, defaultEntityContentGroups, } from './extensionData'; +import { EntityPredicate } from '../predicates'; +import { resolveEntityFilterData } from './resolveEntityFilterData'; +import { createEntityPredicateSchema } from '../predicates/createEntityPredicateSchema'; /** * @alpha @@ -54,7 +57,8 @@ export const EntityContentBlueprint = createExtensionBlueprint({ schema: { path: z => z.string().optional(), title: z => z.string().optional(), - filter: z => z.string().optional(), + filter: z => + z.union([z.string(), createEntityPredicateSchema(z)]).optional(), group: z => z.literal(false).or(z.string()).optional(), }, }, @@ -72,9 +76,7 @@ export const EntityContentBlueprint = createExtensionBlueprint({ defaultTitle: string; defaultGroup?: keyof typeof defaultEntityContentGroups | (string & {}); routeRef?: RouteRef; - filter?: - | typeof entityFilterFunctionDataRef.T - | typeof entityFilterExpressionDataRef.T; + filter?: string | EntityPredicate | typeof entityFilterFunctionDataRef.T; }, { node, config }, ) { @@ -92,13 +94,7 @@ export const EntityContentBlueprint = createExtensionBlueprint({ yield coreExtensionData.routeRef(routeRef); } - if (config.filter) { - yield entityFilterExpressionDataRef(config.filter); - } else if (typeof filter === 'string') { - yield entityFilterExpressionDataRef(filter); - } else if (typeof filter === 'function') { - yield entityFilterFunctionDataRef(filter); - } + yield* resolveEntityFilterData(filter, config, node); if (group) { yield entityContentGroupDataRef(group); diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContentLayoutBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContentLayoutBlueprint.tsx index ab15a3d791..a6a90eee06 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContentLayoutBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentLayoutBlueprint.tsx @@ -25,6 +25,9 @@ import { EntityCardType, } from './extensionData'; import React from 'react'; +import { EntityPredicate } from '../predicates'; +import { resolveEntityFilterData } from './resolveEntityFilterData'; +import { createEntityPredicateSchema } from '../predicates/createEntityPredicateSchema'; /** @alpha */ export interface EntityContentLayoutProps { @@ -57,7 +60,8 @@ export const EntityContentLayoutBlueprint = createExtensionBlueprint({ config: { schema: { type: z => z.string().optional(), - filter: z => z.string().optional(), + filter: z => + z.union([z.string(), createEntityPredicateSchema(z)]).optional(), }, }, *factory( @@ -65,22 +69,14 @@ export const EntityContentLayoutBlueprint = createExtensionBlueprint({ loader, filter, }: { - filter?: - | typeof entityFilterFunctionDataRef.T - | typeof entityFilterExpressionDataRef.T; + filter?: string | EntityPredicate | typeof entityFilterFunctionDataRef.T; loader: () => Promise< (props: EntityContentLayoutProps) => React.JSX.Element >; }, { node, config }, ) { - if (config.filter) { - yield entityFilterExpressionDataRef(config.filter); - } else if (typeof filter === 'string') { - yield entityFilterExpressionDataRef(filter); - } else if (typeof filter === 'function') { - yield entityFilterFunctionDataRef(filter); - } + yield* resolveEntityFilterData(filter, config, node); yield entityCardLayoutComponentDataRef( ExtensionBoundary.lazyComponent(node, loader), diff --git a/plugins/catalog-react/src/alpha/blueprints/resolveEntityFilterData.ts b/plugins/catalog-react/src/alpha/blueprints/resolveEntityFilterData.ts new file mode 100644 index 0000000000..75b7784c18 --- /dev/null +++ b/plugins/catalog-react/src/alpha/blueprints/resolveEntityFilterData.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2025 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 { + entityFilterExpressionDataRef, + entityFilterFunctionDataRef, +} from './extensionData'; +import { + EntityPredicate, + entityPredicateToFilterFunction, +} from '../predicates'; +import { Entity } from '@backstage/catalog-model'; +import { AppNode } from '@backstage/frontend-plugin-api'; + +export function* resolveEntityFilterData( + filter: ((entity: Entity) => boolean) | EntityPredicate | string | undefined, + config: { filter?: EntityPredicate | string }, + node: AppNode, +) { + if (typeof config.filter === 'string') { + // eslint-disable-next-line no-console + console.warn( + `DEPRECATION WARNING: Using a string-based filter in the configuration for '${node.spec.id}' is deprecated. Use an entity predicate object instead.`, + ); + yield entityFilterExpressionDataRef(config.filter); + } else if (config.filter) { + yield entityFilterFunctionDataRef( + entityPredicateToFilterFunction(config.filter), + ); + } else if (typeof filter === 'function') { + yield entityFilterFunctionDataRef(filter); + } else if (typeof filter === 'string') { + // eslint-disable-next-line no-console + console.warn( + `DEPRECATION WARNING: Using a string as the default filter for '${node.spec.id}' is deprecated. Use an entity predicate object instead.`, + ); + yield entityFilterExpressionDataRef(filter); + } else if (filter) { + yield entityFilterFunctionDataRef(entityPredicateToFilterFunction(filter)); + } +} diff --git a/plugins/catalog-react/src/alpha/index.ts b/plugins/catalog-react/src/alpha/index.ts index a46fec0615..4ff4dbf0dd 100644 --- a/plugins/catalog-react/src/alpha/index.ts +++ b/plugins/catalog-react/src/alpha/index.ts @@ -16,6 +16,7 @@ export * from './blueprints'; export * from './converters'; +export * from './predicates'; export { catalogReactTranslationRef } from '../translation'; export { isOwnerOf } from '../utils/isOwnerOf'; export { useEntityPermission } from '../hooks/useEntityPermission'; diff --git a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts new file mode 100644 index 0000000000..986eca5841 --- /dev/null +++ b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts @@ -0,0 +1,100 @@ +/* + * Copyright 2025 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 { z } from 'zod'; +import { createEntityPredicateSchema } from './createEntityPredicateSchema'; + +describe('createEntityPredicateSchema', () => { + const schema = createEntityPredicateSchema(z); + + it.each([ + { kind: 'component', 'spec.type': 'service' }, + { 'metadata.tags': { $all: ['java'] } }, + { 'metadata.tags': { $all: ['java', 'spring'] } }, + { 'metadata.tags': ['java', 'spring'] }, + { 'metadata.tags': { $all: ['go'] } }, + { 'metadata.tags.0': 'java' }, + { $not: { 'metadata.tags': { $all: ['java'] } } }, + { + $or: [{ kind: 'component', 'spec.type': 'service' }, { kind: 'group' }], + }, + { + $nor: [{ kind: 'component', 'spec.type': 'service' }, { kind: 'group' }], + }, + { + relations: { + $elemMatch: { type: 'ownedBy', targetRef: 'group:default/g' }, + }, + }, + { + metadata: { $elemMatch: { name: 'a' } }, + }, + { kind: 'component', 'spec.type': { $in: ['service', 'website'] } }, + { + $or: [ + { + $and: [ + { + kind: 'component', + 'spec.type': { $in: ['service', 'website'] }, + }, + ], + }, + { $and: [{ kind: 'api', 'spec.type': 'grpc' }] }, + ], + }, + { kind: 'component', 'spec.type': { $in: ['service'] } }, + { kind: 'component', 'spec.type': { $nin: ['service'] } }, + { 'spec.owner': { $exists: true } }, + { 'spec.owner': { $exists: false } }, + { 'spec.type': { $eq: 'service' } }, + { 'spec.type': { $ne: 'service' } }, + { + kind: 'component', + 'metadata.annotations.github.com/repo': { $exists: true }, + }, + { $and: [{ x: { $exists: true } }] }, + { $or: [{ x: { $exists: true } }] }, + { $nor: [{ x: { $exists: true } }] }, + { $not: { x: { $exists: true } } }, + { $not: { $and: [{ x: { $exists: true } }] } }, + ])('should accept valid predicate %j', predicate => { + expect(schema.parse(predicate)).toEqual(predicate); + }); + + it.each([ + { kind: { 1: 'foo' } }, + { kind: { foo: 'bar' } }, + { kind: { $unknown: 'foo' } }, + { kind: { $in: 'foo' } }, + { kind: { $in: [{ x: 'foo' }] } }, + { kind: { $in: [{ x: 'foo' }] } }, + { 'spec.type': null }, + 'string', + '', + [], + 1, + { $and: [{ x: { $unknown: true } }] }, + { $or: [{ x: { $unknown: true } }] }, + { $nor: [{ x: { $unknown: true } }] }, + { $not: { x: { $unknown: true } } }, + { $not: { $and: [{ x: { $unknown: true } }] } }, + { $unknown: 'foo' }, + ])('should reject invalid predicate %j', predicate => { + const result = schema.safeParse(predicate); + expect(result.success).toBe(false); + }); +}); diff --git a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts new file mode 100644 index 0000000000..42e180736c --- /dev/null +++ b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2025 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, EntityPredicateValue } from '.'; +import type { z as zImpl, ZodType } from 'zod'; + +/** @internal */ +export function createEntityPredicateSchema(z: typeof zImpl) { + const primitiveSchema = z.union([z.string(), z.number(), z.boolean()]); + + const filterValueSchema = z.union([ + primitiveSchema, + z.array(primitiveSchema), + z.object({ $exists: z.boolean() }), + z.object({ $eq: z.union([primitiveSchema, z.array(primitiveSchema)]) }), + z.object({ $ne: z.union([primitiveSchema, z.array(primitiveSchema)]) }), + z.object({ $in: z.array(primitiveSchema) }), + z.object({ $nin: z.array(primitiveSchema) }), + z.object({ $all: z.array(primitiveSchema) }), + z.object({ $elemMatch: z.lazy(() => z.record(filterValueSchema)) }), + ]) as ZodType; + + const filterSchema = z.lazy(() => + z.union([ + z.object({ $and: z.array(filterSchema) }), + z.object({ $or: z.array(filterSchema) }), + z.object({ $nor: z.array(filterSchema) }), + z.object({ $not: filterSchema }), + z.record(z.string().regex(/^(?!\$).*$/), filterValueSchema), + ]), + ) as ZodType; + + return filterSchema; +} diff --git a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts new file mode 100644 index 0000000000..b717ee8c5e --- /dev/null +++ b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts @@ -0,0 +1,214 @@ +/* + * Copyright 2025 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 { evaluateEntityPredicate } from './evaluateEntityPredicate'; +import { EntityPredicate } from './types'; + +describe('evaluateEntityPredicate', () => { + const entities = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 's', + namespace: 'default', + annotations: { + 'backstage.io/managed-by-location': 'url:service', + 'github.com/repo': 'service', + }, + tags: ['java', 'spring'], + }, + spec: { + type: 'service', + owner: 'g', + }, + relations: [ + { + type: 'ownedBy', + targetRef: 'group:default/g', + }, + { + type: 'providesApi', + targetRef: 'api:default/a', + }, + ], + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'w', + namespace: 'default', + annotations: { + 'backstage.io/managed-by-location': 'url:website', + 'github.com/repo': 'website', + }, + }, + spec: { + type: 'website', + owner: 'g', + }, + relations: [ + { + type: 'ownedBy', + targetRef: 'group:default/g', + }, + { + type: 'dependsOn', + targetRef: 'api:default/a', + }, + ], + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'g', + namespace: 'default', + }, + spec: { + type: 'squad', + }, + relations: [ + { + type: 'ownerOf', + targetRef: 'component:default/s', + }, + { + type: 'ownerOf', + targetRef: 'component:default/w', + }, + { + type: 'ownerOf', + targetRef: 'component:default/a', + }, + ], + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'API', + metadata: { + name: 'a', + namespace: 'default', + }, + spec: { + type: 'grpc', + owner: 'g', + definition: 'mock', + }, + relations: [ + { + type: 'ownedBy', + targetRef: 'group:default/g', + }, + { + type: 'apiProvidedBy', + targetRef: 'component:default/c', + }, + { + type: 'dependencyOf', + targetRef: 'component:default/w', + }, + ], + }, + ]; + + it.each([ + ['s', { kind: 'component', 'spec.type': 'service' }], + ['s', { 'metadata.tags': { $all: ['java'] } }], + ['s', { 'metadata.tags': { $all: ['java', 'spring'] } }], + ['s', { 'metadata.tags': ['java', 'spring'] }], + ['', { 1: 'foo' }], + ['s,w,g,a', {}], + ['', { kind: { $unknown: 'foo' } }], + ['', { '': 'component' }], + ['s,w,g,a', Object.create({ kind: 'component' })], + ['', { 'metadata.tags': { $all: ['go'] } }], + ['', { 'metadata.tags.0': 'java' }], + ['w,g,a', { $not: { 'metadata.tags': { $all: ['java'] } } }], + [ + 's,g', + { + $or: [{ kind: 'component', 'spec.type': 'service' }, { kind: 'group' }], + }, + ], + [ + 'w,a', + { + $nor: [ + { kind: 'component', 'spec.type': 'service' }, + { kind: 'group' }, + ], + }, + ], + [ + 's,w,a', + { + relations: { + $elemMatch: { type: 'ownedBy', targetRef: 'group:default/g' }, + }, + }, + ], + [ + '', + { + metadata: { $elemMatch: { name: 'a' } }, + }, + ], + ['', { $unknown: 'ignored' } as unknown as EntityPredicate], + [ + 's,w', + { kind: 'component', 'spec.type': { $in: ['service', 'website'] } }, + ], + [ + 's,w,a', + { + $or: [ + { + $and: [ + { + kind: 'component', + 'spec.type': { $in: ['service', 'website'] }, + }, + ], + }, + { $and: [{ kind: 'api', 'spec.type': 'grpc' }] }, + ], + }, + ], + ['s', { kind: 'component', 'spec.type': { $in: ['service'] } }], + ['w', { kind: 'component', 'spec.type': { $nin: ['service'] } }], + ['s,w,a', { 'spec.owner': { $exists: true } }], + ['g', { 'spec.owner': { $exists: false } }], + ['s', { 'spec.type': { $eq: 'service' } }], + ['w,g,a', { 'spec.type': { $ne: 'service' } }], + ['', { 'spec.type': null }], + [ + 's,w', + { + kind: 'component', + 'metadata.annotations.github.com/repo': { $exists: true }, + }, + ], + ])('filter entry %s', (expected, filter) => { + const filtered = entities.filter(entity => + evaluateEntityPredicate(filter, entity), + ); + expect(filtered.map(e => e.metadata.name).sort()).toEqual( + expected.split(',').filter(Boolean).sort(), + ); + }); +}); diff --git a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts new file mode 100644 index 0000000000..497f3bc1b1 --- /dev/null +++ b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts @@ -0,0 +1,134 @@ +/* + * Copyright 2025 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 { JsonValue } from '@backstage/types'; +import { + EntityPredicate, + EntityPredicatePrimitive, + EntityPredicateValue, +} from './types'; +import { valueAtPath } from './valueAtPath'; + +/** + * Convert an entity predicate to a filter function that can be used to filter entities. + */ +export function entityPredicateToFilterFunction( + entityPredicate: EntityPredicate, +): (value: T) => boolean { + return value => evaluateEntityPredicate(entityPredicate, value); +} + +/** + * Evaluate a entity predicate against a value, typically an entity. + * + * @alpha + */ +export function evaluateEntityPredicate( + filter: EntityPredicate, + value: JsonValue, +): boolean { + if ('$and' in filter) { + return filter.$and.every(f => evaluateEntityPredicate(f, value)); + } + if ('$or' in filter) { + return filter.$or.some(f => evaluateEntityPredicate(f, value)); + } + if ('$nor' in filter) { + return !filter.$nor.some(f => evaluateEntityPredicate(f, value)); + } + if ('$not' in filter) { + return !evaluateEntityPredicate(filter.$not, value); + } + + for (const filterKey in filter) { + if (!Object.hasOwn(filter, filterKey)) { + continue; + } + if (filterKey.startsWith('$')) { + return false; + } + if ( + !evaluatePredicateValue(filter[filterKey], valueAtPath(value, filterKey)) + ) { + return false; + } + } + + return true; +} + +/** + * Evaluate a single value against a predicate value. + * + * @internal + */ +function evaluatePredicateValue( + filter: EntityPredicateValue, + value: JsonValue | undefined, +): boolean { + if (typeof filter !== 'object' || filter === null || Array.isArray(filter)) { + return valuesAreEqual(value, filter); + } + + if ('$elemMatch' in filter) { + if (!Array.isArray(value)) { + return false; + } + return value.some(v => evaluateEntityPredicate(filter.$elemMatch, v)); + } + if ('$all' in filter) { + if (!Array.isArray(value)) { + return false; + } + return filter.$all.every(v => value.includes(v)); + } + if ('$in' in filter) { + return filter.$in.includes(value as EntityPredicatePrimitive); + } + if ('$nin' in filter) { + return !filter.$nin.includes(value as EntityPredicatePrimitive); + } + if ('$exists' in filter) { + if (filter.$exists === true) { + return value !== undefined; + } + return value === undefined; + } + if ('$eq' in filter) { + return valuesAreEqual(value, filter.$eq); + } + if ('$ne' in filter) { + return !valuesAreEqual(value, filter.$ne); + } + + return false; +} + +function valuesAreEqual( + a: JsonValue | undefined, + b: JsonValue | undefined, +): boolean { + if (a === b) { + return true; + } + if (typeof a === 'string' && typeof b === 'string') { + return a.toLocaleUpperCase('en-US') === b.toLocaleUpperCase('en-US'); + } + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((v, i) => valuesAreEqual(v, b[i])); + } + return false; +} diff --git a/plugins/catalog-react/src/alpha/predicates/index.ts b/plugins/catalog-react/src/alpha/predicates/index.ts new file mode 100644 index 0000000000..5a9a747138 --- /dev/null +++ b/plugins/catalog-react/src/alpha/predicates/index.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2025 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. + */ + +export type { + EntityPredicate, + EntityPredicateExpression, + EntityPredicatePrimitive, + EntityPredicateValue, +} from './types'; +export { + evaluateEntityPredicate, + entityPredicateToFilterFunction, +} from './evaluateEntityPredicate'; diff --git a/plugins/catalog-react/src/alpha/predicates/types.ts b/plugins/catalog-react/src/alpha/predicates/types.ts new file mode 100644 index 0000000000..86a6aa8db7 --- /dev/null +++ b/plugins/catalog-react/src/alpha/predicates/types.ts @@ -0,0 +1,44 @@ +/* + * Copyright 2025 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. + */ + +/** @alpha */ +export type EntityPredicate = + | EntityPredicateExpression + | { $and: EntityPredicate[] } + | { $or: EntityPredicate[] } + | { $nor: EntityPredicate[] } + | { $not: EntityPredicate }; + +/** @alpha */ +export type EntityPredicateExpression = { + [KPath in string]: EntityPredicateValue; +} & { + [KPath in `$${string}`]: never; +}; + +/** @alpha */ +export type EntityPredicateValue = + | EntityPredicatePrimitive + | { $exists: boolean } + | { $eq: EntityPredicatePrimitive } + | { $ne: EntityPredicatePrimitive } + | { $in: EntityPredicatePrimitive[] } + | { $nin: EntityPredicatePrimitive[] } + | { $all: EntityPredicatePrimitive[] } + | { $elemMatch: EntityPredicateExpression }; + +/** @alpha */ +export type EntityPredicatePrimitive = string | number | boolean; diff --git a/plugins/catalog-react/src/alpha/predicates/valueAtPath.test.ts b/plugins/catalog-react/src/alpha/predicates/valueAtPath.test.ts new file mode 100644 index 0000000000..7ea563e4a1 --- /dev/null +++ b/plugins/catalog-react/src/alpha/predicates/valueAtPath.test.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2025 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 { valueAtPath } from './valueAtPath'; + +describe('valueAtPath', () => { + const subject = { + name: 'Test', + fields: { + value: 123, + tags: ['production', 'beta'], + array: [1, 2, { nested: 'value' }], + nested: { + level: 1, + deeper: { + level: 2, + }, + }, + }, + mixed: { + 'foo.bar.baz': 1, + foo: { 'bar.baz': 3, bar: { baz: 4, qux: 4 } }, + 'foo.bar': { baz: 2, qux: 2, quux: 2 }, + annotations: { + 'example.com/description': 'A test subject', + 'long.domain.example.com/custom': 'long', + }, + }, + }; + + it.each([ + ['name', 'Test'], + ['unknown', undefined], + ['fields.value', 123], + ['fields.tags', ['production', 'beta']], + ['fields.array', [1, 2, { nested: 'value' }]], + ['fields.array.0', undefined], // Arrays are not traversed + ['fields.array.2', undefined], // Arrays are not traversed + ['fields.array.2.nested', undefined], // Arrays are not traversed + ['fields.nested.level', 1], + ['fields.nested.deeper.level', 2], + ['mixed.foo.bar.baz', 1], // First one wins + ['mixed.foo.bar.qux', 4], // First one wins + ['mixed.foo.bar.quux', 2], // Should not get stuck in earlier partial matches + ['mixed.annotations.example.com/description', 'A test subject'], + ['mixed.annotations.long.domain.example.com/custom', 'long'], + ])(`should find value at path %s`, (path, expected) => { + expect(valueAtPath(subject, path)).toEqual(expected); + }); +}); diff --git a/plugins/catalog-react/src/alpha/predicates/valueAtPath.ts b/plugins/catalog-react/src/alpha/predicates/valueAtPath.ts new file mode 100644 index 0000000000..acb46995d8 --- /dev/null +++ b/plugins/catalog-react/src/alpha/predicates/valueAtPath.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2025 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 { JsonValue } from '@backstage/types'; + +/** + * Looks up a value by path in a nested object structure. + * + * @remarks + * + * The path should be a dot-separated string of keys to traverse. The traversal + * will tolerate object keys containing dots, and will keep searching until a + * value has been found or all matching keys have been traversed. + * + * This lookup does not traverse into arrays, returning `undefined` instead. + * + * @internal + */ +export function valueAtPath( + value: JsonValue | undefined, + path: string, +): JsonValue | undefined { + if (!path) { + return undefined; + } + if ( + value === undefined || + value === null || + typeof value !== 'object' || + Array.isArray(value) + ) { + return undefined; + } + + for (const valueKey in value) { + if (!Object.hasOwn(value, valueKey)) { + continue; + } + if (valueKey === path) { + if (value[valueKey] !== undefined) { + return value[valueKey]; + } + } + if (path.startsWith(`${valueKey}.`)) { + const found = valueAtPath( + value[valueKey], + path.slice(valueKey.length + 1), + ); + if (found !== undefined) { + return found; + } + } + } + + return undefined; +} diff --git a/yarn.lock b/yarn.lock index 2e0955967b..40228e9251 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6344,6 +6344,7 @@ __metadata: react-use: ^17.2.4 yaml: ^2.0.0 zen-observable: ^0.10.0 + zod: ^3.22.4 peerDependencies: "@types/react": ^17.0.0 || ^18.0.0 react: ^17.0.0 || ^18.0.0 From 0171b0b4892828356689953ca740a6707f567949 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 23 Feb 2025 14:35:39 +0100 Subject: [PATCH 02/16] catalog-react: remove $nor predicate Signed-off-by: Patrik Oldsberg --- .../predicates/createEntityPredicateSchema.test.ts | 5 ----- .../alpha/predicates/createEntityPredicateSchema.ts | 1 - .../alpha/predicates/evaluateEntityPredicate.test.ts | 10 ++++++---- .../src/alpha/predicates/evaluateEntityPredicate.ts | 3 --- plugins/catalog-react/src/alpha/predicates/types.ts | 1 - 5 files changed, 6 insertions(+), 14 deletions(-) diff --git a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts index 986eca5841..2a96df97bb 100644 --- a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts +++ b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts @@ -31,9 +31,6 @@ describe('createEntityPredicateSchema', () => { { $or: [{ kind: 'component', 'spec.type': 'service' }, { kind: 'group' }], }, - { - $nor: [{ kind: 'component', 'spec.type': 'service' }, { kind: 'group' }], - }, { relations: { $elemMatch: { type: 'ownedBy', targetRef: 'group:default/g' }, @@ -68,7 +65,6 @@ describe('createEntityPredicateSchema', () => { }, { $and: [{ x: { $exists: true } }] }, { $or: [{ x: { $exists: true } }] }, - { $nor: [{ x: { $exists: true } }] }, { $not: { x: { $exists: true } } }, { $not: { $and: [{ x: { $exists: true } }] } }, ])('should accept valid predicate %j', predicate => { @@ -89,7 +85,6 @@ describe('createEntityPredicateSchema', () => { 1, { $and: [{ x: { $unknown: true } }] }, { $or: [{ x: { $unknown: true } }] }, - { $nor: [{ x: { $unknown: true } }] }, { $not: { x: { $unknown: true } } }, { $not: { $and: [{ x: { $unknown: true } }] } }, { $unknown: 'foo' }, diff --git a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts index 42e180736c..1d3bdfacb9 100644 --- a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts +++ b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts @@ -37,7 +37,6 @@ export function createEntityPredicateSchema(z: typeof zImpl) { z.union([ z.object({ $and: z.array(filterSchema) }), z.object({ $or: z.array(filterSchema) }), - z.object({ $nor: z.array(filterSchema) }), z.object({ $not: filterSchema }), z.record(z.string().regex(/^(?!\$).*$/), filterValueSchema), ]), diff --git a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts index b717ee8c5e..1c3165c4fa 100644 --- a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts +++ b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts @@ -148,10 +148,12 @@ describe('evaluateEntityPredicate', () => { [ 'w,a', { - $nor: [ - { kind: 'component', 'spec.type': 'service' }, - { kind: 'group' }, - ], + $not: { + $or: [ + { kind: 'component', 'spec.type': 'service' }, + { kind: 'group' }, + ], + }, }, ], [ diff --git a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts index 497f3bc1b1..ebdd9e5df9 100644 --- a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts +++ b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts @@ -46,9 +46,6 @@ export function evaluateEntityPredicate( if ('$or' in filter) { return filter.$or.some(f => evaluateEntityPredicate(f, value)); } - if ('$nor' in filter) { - return !filter.$nor.some(f => evaluateEntityPredicate(f, value)); - } if ('$not' in filter) { return !evaluateEntityPredicate(filter.$not, value); } diff --git a/plugins/catalog-react/src/alpha/predicates/types.ts b/plugins/catalog-react/src/alpha/predicates/types.ts index 86a6aa8db7..3a0b01aa2b 100644 --- a/plugins/catalog-react/src/alpha/predicates/types.ts +++ b/plugins/catalog-react/src/alpha/predicates/types.ts @@ -19,7 +19,6 @@ export type EntityPredicate = | EntityPredicateExpression | { $and: EntityPredicate[] } | { $or: EntityPredicate[] } - | { $nor: EntityPredicate[] } | { $not: EntityPredicate }; /** @alpha */ From 99306adc3cec612e49d51c3004a351f89d7d14f8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 23 Feb 2025 14:37:45 +0100 Subject: [PATCH 03/16] catalog-react: remove $nin predicate Signed-off-by: Patrik Oldsberg --- .../predicates/createEntityPredicateSchema.test.ts | 1 - .../alpha/predicates/createEntityPredicateSchema.ts | 1 - .../alpha/predicates/evaluateEntityPredicate.test.ts | 10 +++++++++- .../src/alpha/predicates/evaluateEntityPredicate.ts | 3 --- plugins/catalog-react/src/alpha/predicates/types.ts | 1 - 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts index 2a96df97bb..20824a5c2d 100644 --- a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts +++ b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts @@ -54,7 +54,6 @@ describe('createEntityPredicateSchema', () => { ], }, { kind: 'component', 'spec.type': { $in: ['service'] } }, - { kind: 'component', 'spec.type': { $nin: ['service'] } }, { 'spec.owner': { $exists: true } }, { 'spec.owner': { $exists: false } }, { 'spec.type': { $eq: 'service' } }, diff --git a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts index 1d3bdfacb9..9b7efeb274 100644 --- a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts +++ b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts @@ -28,7 +28,6 @@ export function createEntityPredicateSchema(z: typeof zImpl) { z.object({ $eq: z.union([primitiveSchema, z.array(primitiveSchema)]) }), z.object({ $ne: z.union([primitiveSchema, z.array(primitiveSchema)]) }), z.object({ $in: z.array(primitiveSchema) }), - z.object({ $nin: z.array(primitiveSchema) }), z.object({ $all: z.array(primitiveSchema) }), z.object({ $elemMatch: z.lazy(() => z.record(filterValueSchema)) }), ]) as ZodType; diff --git a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts index 1c3165c4fa..4d19a6234d 100644 --- a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts +++ b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts @@ -192,7 +192,15 @@ describe('evaluateEntityPredicate', () => { }, ], ['s', { kind: 'component', 'spec.type': { $in: ['service'] } }], - ['w', { kind: 'component', 'spec.type': { $nin: ['service'] } }], + [ + 'w', + { + $and: [ + { kind: 'component' }, + { $not: { 'spec.type': { $in: ['service'] } } }, + ], + }, + ], ['s,w,a', { 'spec.owner': { $exists: true } }], ['g', { 'spec.owner': { $exists: false } }], ['s', { 'spec.type': { $eq: 'service' } }], diff --git a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts index ebdd9e5df9..722442a8e1 100644 --- a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts +++ b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts @@ -95,9 +95,6 @@ function evaluatePredicateValue( if ('$in' in filter) { return filter.$in.includes(value as EntityPredicatePrimitive); } - if ('$nin' in filter) { - return !filter.$nin.includes(value as EntityPredicatePrimitive); - } if ('$exists' in filter) { if (filter.$exists === true) { return value !== undefined; diff --git a/plugins/catalog-react/src/alpha/predicates/types.ts b/plugins/catalog-react/src/alpha/predicates/types.ts index 3a0b01aa2b..7c6e805e17 100644 --- a/plugins/catalog-react/src/alpha/predicates/types.ts +++ b/plugins/catalog-react/src/alpha/predicates/types.ts @@ -35,7 +35,6 @@ export type EntityPredicateValue = | { $eq: EntityPredicatePrimitive } | { $ne: EntityPredicatePrimitive } | { $in: EntityPredicatePrimitive[] } - | { $nin: EntityPredicatePrimitive[] } | { $all: EntityPredicatePrimitive[] } | { $elemMatch: EntityPredicateExpression }; From 6047a3e1b330fb1d8824c0f4d2087861858d9bce Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 24 Feb 2025 00:38:11 +0100 Subject: [PATCH 04/16] catalog-react: merge $all and $elemMatch into $contains + allow primitives at root level Signed-off-by: Patrik Oldsberg --- .../createEntityPredicateSchema.test.ts | 25 ++++++++------ .../predicates/createEntityPredicateSchema.ts | 33 +++++++++++-------- .../evaluateEntityPredicate.test.ts | 22 +++++++++---- .../predicates/evaluateEntityPredicate.ts | 14 ++++---- .../src/alpha/predicates/types.ts | 4 +-- 5 files changed, 58 insertions(+), 40 deletions(-) diff --git a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts index 20824a5c2d..3df0b1a95f 100644 --- a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts +++ b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts @@ -21,23 +21,32 @@ describe('createEntityPredicateSchema', () => { const schema = createEntityPredicateSchema(z); it.each([ + 'string', + '', + [], + 1, { kind: 'component', 'spec.type': 'service' }, - { 'metadata.tags': { $all: ['java'] } }, - { 'metadata.tags': { $all: ['java', 'spring'] } }, + { 'metadata.tags': { $in: ['java'] } }, + { + $and: [ + { 'metadata.tags': { $contains: 'java' } }, + { 'metadata.tags': { $contains: 'spring' } }, + ], + }, { 'metadata.tags': ['java', 'spring'] }, - { 'metadata.tags': { $all: ['go'] } }, + { 'metadata.tags': { $in: ['go'] } }, { 'metadata.tags.0': 'java' }, - { $not: { 'metadata.tags': { $all: ['java'] } } }, + { $not: { 'metadata.tags': { $in: ['java'] } } }, { $or: [{ kind: 'component', 'spec.type': 'service' }, { kind: 'group' }], }, { relations: { - $elemMatch: { type: 'ownedBy', targetRef: 'group:default/g' }, + $contains: { type: 'ownedBy', targetRef: 'group:default/g' }, }, }, { - metadata: { $elemMatch: { name: 'a' } }, + metadata: { $contains: { name: 'a' } }, }, { kind: 'component', 'spec.type': { $in: ['service', 'website'] } }, { @@ -78,10 +87,6 @@ describe('createEntityPredicateSchema', () => { { kind: { $in: [{ x: 'foo' }] } }, { kind: { $in: [{ x: 'foo' }] } }, { 'spec.type': null }, - 'string', - '', - [], - 1, { $and: [{ x: { $unknown: true } }] }, { $or: [{ x: { $unknown: true } }] }, { $not: { x: { $unknown: true } } }, diff --git a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts index 9b7efeb274..c9c5c4815c 100644 --- a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts +++ b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts @@ -21,25 +21,32 @@ import type { z as zImpl, ZodType } from 'zod'; export function createEntityPredicateSchema(z: typeof zImpl) { const primitiveSchema = z.union([z.string(), z.number(), z.boolean()]); - const filterValueSchema = z.union([ + const comparableValueSchema = z.union([ primitiveSchema, z.array(primitiveSchema), + ]); + + // eslint-disable-next-line prefer-const + let valuePredicateSchema: ZodType; + + const predicateSchema = z.lazy(() => + z.union([ + comparableValueSchema, + z.object({ $and: z.array(predicateSchema) }), + z.object({ $or: z.array(predicateSchema) }), + z.object({ $not: predicateSchema }), + z.record(z.string().regex(/^(?!\$).*$/), valuePredicateSchema), + ]), + ) as ZodType; + + valuePredicateSchema = z.union([ + comparableValueSchema, z.object({ $exists: z.boolean() }), z.object({ $eq: z.union([primitiveSchema, z.array(primitiveSchema)]) }), z.object({ $ne: z.union([primitiveSchema, z.array(primitiveSchema)]) }), z.object({ $in: z.array(primitiveSchema) }), - z.object({ $all: z.array(primitiveSchema) }), - z.object({ $elemMatch: z.lazy(() => z.record(filterValueSchema)) }), + z.object({ $contains: predicateSchema }), ]) as ZodType; - const filterSchema = z.lazy(() => - z.union([ - z.object({ $and: z.array(filterSchema) }), - z.object({ $or: z.array(filterSchema) }), - z.object({ $not: filterSchema }), - z.record(z.string().regex(/^(?!\$).*$/), filterValueSchema), - ]), - ) as ZodType; - - return filterSchema; + return predicateSchema; } diff --git a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts index 4d19a6234d..e6a46d4a2d 100644 --- a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts +++ b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts @@ -128,17 +128,25 @@ describe('evaluateEntityPredicate', () => { it.each([ ['s', { kind: 'component', 'spec.type': 'service' }], - ['s', { 'metadata.tags': { $all: ['java'] } }], - ['s', { 'metadata.tags': { $all: ['java', 'spring'] } }], + ['s', { 'metadata.tags': { $contains: 'java' } }], + [ + 's', + { + $and: [ + { 'metadata.tags': { $contains: 'java' } }, + { 'metadata.tags': { $contains: 'spring' } }, + ], + }, + ], ['s', { 'metadata.tags': ['java', 'spring'] }], ['', { 1: 'foo' }], ['s,w,g,a', {}], ['', { kind: { $unknown: 'foo' } }], ['', { '': 'component' }], ['s,w,g,a', Object.create({ kind: 'component' })], - ['', { 'metadata.tags': { $all: ['go'] } }], + ['', { 'metadata.tags': { $contains: 'go' } }], ['', { 'metadata.tags.0': 'java' }], - ['w,g,a', { $not: { 'metadata.tags': { $all: ['java'] } } }], + ['w,g,a', { $not: { 'metadata.tags': { $contains: 'java' } } }], [ 's,g', { @@ -160,14 +168,14 @@ describe('evaluateEntityPredicate', () => { 's,w,a', { relations: { - $elemMatch: { type: 'ownedBy', targetRef: 'group:default/g' }, + $contains: { type: 'ownedBy', targetRef: 'group:default/g' }, }, }, ], [ '', { - metadata: { $elemMatch: { name: 'a' } }, + metadata: { $contains: { name: 'a' } }, }, ], ['', { $unknown: 'ignored' } as unknown as EntityPredicate], @@ -213,7 +221,7 @@ describe('evaluateEntityPredicate', () => { 'metadata.annotations.github.com/repo': { $exists: true }, }, ], - ])('filter entry %s', (expected, filter) => { + ])('filter entry %#', (expected, filter) => { const filtered = entities.filter(entity => evaluateEntityPredicate(filter, entity), ); diff --git a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts index 722442a8e1..b687773604 100644 --- a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts +++ b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts @@ -40,6 +40,10 @@ export function evaluateEntityPredicate( filter: EntityPredicate, value: JsonValue, ): boolean { + if (typeof filter !== 'object' || filter === null || Array.isArray(filter)) { + return valuesAreEqual(value, filter); + } + if ('$and' in filter) { return filter.$and.every(f => evaluateEntityPredicate(f, value)); } @@ -80,17 +84,11 @@ function evaluatePredicateValue( return valuesAreEqual(value, filter); } - if ('$elemMatch' in filter) { + if ('$contains' in filter) { if (!Array.isArray(value)) { return false; } - return value.some(v => evaluateEntityPredicate(filter.$elemMatch, v)); - } - if ('$all' in filter) { - if (!Array.isArray(value)) { - return false; - } - return filter.$all.every(v => value.includes(v)); + return value.some(v => evaluateEntityPredicate(filter.$contains, v)); } if ('$in' in filter) { return filter.$in.includes(value as EntityPredicatePrimitive); diff --git a/plugins/catalog-react/src/alpha/predicates/types.ts b/plugins/catalog-react/src/alpha/predicates/types.ts index 7c6e805e17..e6932c4006 100644 --- a/plugins/catalog-react/src/alpha/predicates/types.ts +++ b/plugins/catalog-react/src/alpha/predicates/types.ts @@ -17,6 +17,7 @@ /** @alpha */ export type EntityPredicate = | EntityPredicateExpression + | EntityPredicatePrimitive | { $and: EntityPredicate[] } | { $or: EntityPredicate[] } | { $not: EntityPredicate }; @@ -35,8 +36,7 @@ export type EntityPredicateValue = | { $eq: EntityPredicatePrimitive } | { $ne: EntityPredicatePrimitive } | { $in: EntityPredicatePrimitive[] } - | { $all: EntityPredicatePrimitive[] } - | { $elemMatch: EntityPredicateExpression }; + | { $contains: EntityPredicateExpression }; /** @alpha */ export type EntityPredicatePrimitive = string | number | boolean; From 6c047a5a3773ea7b3326cace5c2391d7f5fbe777 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 24 Feb 2025 00:40:10 +0100 Subject: [PATCH 05/16] catalog-react: remove $eq and $ne value predicate Signed-off-by: Patrik Oldsberg --- .../alpha/predicates/createEntityPredicateSchema.test.ts | 4 ++-- .../src/alpha/predicates/createEntityPredicateSchema.ts | 2 -- .../src/alpha/predicates/evaluateEntityPredicate.test.ts | 4 ++-- .../src/alpha/predicates/evaluateEntityPredicate.ts | 6 ------ plugins/catalog-react/src/alpha/predicates/types.ts | 2 -- 5 files changed, 4 insertions(+), 14 deletions(-) diff --git a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts index 3df0b1a95f..3334343e8b 100644 --- a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts +++ b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts @@ -65,8 +65,8 @@ describe('createEntityPredicateSchema', () => { { kind: 'component', 'spec.type': { $in: ['service'] } }, { 'spec.owner': { $exists: true } }, { 'spec.owner': { $exists: false } }, - { 'spec.type': { $eq: 'service' } }, - { 'spec.type': { $ne: 'service' } }, + { 'spec.type': 'service' }, + { $not: { 'spec.type': 'service' } }, { kind: 'component', 'metadata.annotations.github.com/repo': { $exists: true }, diff --git a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts index c9c5c4815c..147769c937 100644 --- a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts +++ b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts @@ -42,8 +42,6 @@ export function createEntityPredicateSchema(z: typeof zImpl) { valuePredicateSchema = z.union([ comparableValueSchema, z.object({ $exists: z.boolean() }), - z.object({ $eq: z.union([primitiveSchema, z.array(primitiveSchema)]) }), - z.object({ $ne: z.union([primitiveSchema, z.array(primitiveSchema)]) }), z.object({ $in: z.array(primitiveSchema) }), z.object({ $contains: predicateSchema }), ]) as ZodType; diff --git a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts index e6a46d4a2d..841056d01e 100644 --- a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts +++ b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts @@ -211,8 +211,8 @@ describe('evaluateEntityPredicate', () => { ], ['s,w,a', { 'spec.owner': { $exists: true } }], ['g', { 'spec.owner': { $exists: false } }], - ['s', { 'spec.type': { $eq: 'service' } }], - ['w,g,a', { 'spec.type': { $ne: 'service' } }], + ['s', { 'spec.type': 'service' }], + ['w,g,a', { $not: { 'spec.type': 'service' } }], ['', { 'spec.type': null }], [ 's,w', diff --git a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts index b687773604..7bd301a889 100644 --- a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts +++ b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts @@ -99,12 +99,6 @@ function evaluatePredicateValue( } return value === undefined; } - if ('$eq' in filter) { - return valuesAreEqual(value, filter.$eq); - } - if ('$ne' in filter) { - return !valuesAreEqual(value, filter.$ne); - } return false; } diff --git a/plugins/catalog-react/src/alpha/predicates/types.ts b/plugins/catalog-react/src/alpha/predicates/types.ts index e6932c4006..b20d90c4e5 100644 --- a/plugins/catalog-react/src/alpha/predicates/types.ts +++ b/plugins/catalog-react/src/alpha/predicates/types.ts @@ -33,8 +33,6 @@ export type EntityPredicateExpression = { export type EntityPredicateValue = | EntityPredicatePrimitive | { $exists: boolean } - | { $eq: EntityPredicatePrimitive } - | { $ne: EntityPredicatePrimitive } | { $in: EntityPredicatePrimitive[] } | { $contains: EntityPredicateExpression }; From 9eb1102f985a569c97093f654c784bf5334964da Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 24 Feb 2025 00:42:38 +0100 Subject: [PATCH 06/16] catalog-react: rename $and to $all and $or to $any Signed-off-by: Patrik Oldsberg --- .../createEntityPredicateSchema.test.ts | 22 +++++++++---------- .../predicates/createEntityPredicateSchema.ts | 4 ++-- .../evaluateEntityPredicate.test.ts | 17 ++++++++------ .../predicates/evaluateEntityPredicate.ts | 8 +++---- .../src/alpha/predicates/types.ts | 4 ++-- 5 files changed, 29 insertions(+), 26 deletions(-) diff --git a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts index 3334343e8b..4fdf921c1a 100644 --- a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts +++ b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts @@ -28,7 +28,7 @@ describe('createEntityPredicateSchema', () => { { kind: 'component', 'spec.type': 'service' }, { 'metadata.tags': { $in: ['java'] } }, { - $and: [ + $all: [ { 'metadata.tags': { $contains: 'java' } }, { 'metadata.tags': { $contains: 'spring' } }, ], @@ -38,7 +38,7 @@ describe('createEntityPredicateSchema', () => { { 'metadata.tags.0': 'java' }, { $not: { 'metadata.tags': { $in: ['java'] } } }, { - $or: [{ kind: 'component', 'spec.type': 'service' }, { kind: 'group' }], + $any: [{ kind: 'component', 'spec.type': 'service' }, { kind: 'group' }], }, { relations: { @@ -50,16 +50,16 @@ describe('createEntityPredicateSchema', () => { }, { kind: 'component', 'spec.type': { $in: ['service', 'website'] } }, { - $or: [ + $any: [ { - $and: [ + $all: [ { kind: 'component', 'spec.type': { $in: ['service', 'website'] }, }, ], }, - { $and: [{ kind: 'api', 'spec.type': 'grpc' }] }, + { $all: [{ kind: 'api', 'spec.type': 'grpc' }] }, ], }, { kind: 'component', 'spec.type': { $in: ['service'] } }, @@ -71,10 +71,10 @@ describe('createEntityPredicateSchema', () => { kind: 'component', 'metadata.annotations.github.com/repo': { $exists: true }, }, - { $and: [{ x: { $exists: true } }] }, - { $or: [{ x: { $exists: true } }] }, + { $all: [{ x: { $exists: true } }] }, + { $any: [{ x: { $exists: true } }] }, { $not: { x: { $exists: true } } }, - { $not: { $and: [{ x: { $exists: true } }] } }, + { $not: { $all: [{ x: { $exists: true } }] } }, ])('should accept valid predicate %j', predicate => { expect(schema.parse(predicate)).toEqual(predicate); }); @@ -87,10 +87,10 @@ describe('createEntityPredicateSchema', () => { { kind: { $in: [{ x: 'foo' }] } }, { kind: { $in: [{ x: 'foo' }] } }, { 'spec.type': null }, - { $and: [{ x: { $unknown: true } }] }, - { $or: [{ x: { $unknown: true } }] }, + { $all: [{ x: { $unknown: true } }] }, + { $any: [{ x: { $unknown: true } }] }, { $not: { x: { $unknown: true } } }, - { $not: { $and: [{ x: { $unknown: true } }] } }, + { $not: { $all: [{ x: { $unknown: true } }] } }, { $unknown: 'foo' }, ])('should reject invalid predicate %j', predicate => { const result = schema.safeParse(predicate); diff --git a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts index 147769c937..9e5f44b327 100644 --- a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts +++ b/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts @@ -32,8 +32,8 @@ export function createEntityPredicateSchema(z: typeof zImpl) { const predicateSchema = z.lazy(() => z.union([ comparableValueSchema, - z.object({ $and: z.array(predicateSchema) }), - z.object({ $or: z.array(predicateSchema) }), + z.object({ $all: z.array(predicateSchema) }), + z.object({ $any: z.array(predicateSchema) }), z.object({ $not: predicateSchema }), z.record(z.string().regex(/^(?!\$).*$/), valuePredicateSchema), ]), diff --git a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts index 841056d01e..6ce23b1c74 100644 --- a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts +++ b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts @@ -132,7 +132,7 @@ describe('evaluateEntityPredicate', () => { [ 's', { - $and: [ + $all: [ { 'metadata.tags': { $contains: 'java' } }, { 'metadata.tags': { $contains: 'spring' } }, ], @@ -150,14 +150,17 @@ describe('evaluateEntityPredicate', () => { [ 's,g', { - $or: [{ kind: 'component', 'spec.type': 'service' }, { kind: 'group' }], + $any: [ + { kind: 'component', 'spec.type': 'service' }, + { kind: 'group' }, + ], }, ], [ 'w,a', { $not: { - $or: [ + $any: [ { kind: 'component', 'spec.type': 'service' }, { kind: 'group' }, ], @@ -186,16 +189,16 @@ describe('evaluateEntityPredicate', () => { [ 's,w,a', { - $or: [ + $any: [ { - $and: [ + $all: [ { kind: 'component', 'spec.type': { $in: ['service', 'website'] }, }, ], }, - { $and: [{ kind: 'api', 'spec.type': 'grpc' }] }, + { $all: [{ kind: 'api', 'spec.type': 'grpc' }] }, ], }, ], @@ -203,7 +206,7 @@ describe('evaluateEntityPredicate', () => { [ 'w', { - $and: [ + $all: [ { kind: 'component' }, { $not: { 'spec.type': { $in: ['service'] } } }, ], diff --git a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts index 7bd301a889..cf33457376 100644 --- a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts +++ b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts @@ -44,11 +44,11 @@ export function evaluateEntityPredicate( return valuesAreEqual(value, filter); } - if ('$and' in filter) { - return filter.$and.every(f => evaluateEntityPredicate(f, value)); + if ('$all' in filter) { + return filter.$all.every(f => evaluateEntityPredicate(f, value)); } - if ('$or' in filter) { - return filter.$or.some(f => evaluateEntityPredicate(f, value)); + if ('$any' in filter) { + return filter.$any.some(f => evaluateEntityPredicate(f, value)); } if ('$not' in filter) { return !evaluateEntityPredicate(filter.$not, value); diff --git a/plugins/catalog-react/src/alpha/predicates/types.ts b/plugins/catalog-react/src/alpha/predicates/types.ts index b20d90c4e5..a4a2e9199f 100644 --- a/plugins/catalog-react/src/alpha/predicates/types.ts +++ b/plugins/catalog-react/src/alpha/predicates/types.ts @@ -18,8 +18,8 @@ export type EntityPredicate = | EntityPredicateExpression | EntityPredicatePrimitive - | { $and: EntityPredicate[] } - | { $or: EntityPredicate[] } + | { $all: EntityPredicate[] } + | { $any: EntityPredicate[] } | { $not: EntityPredicate }; /** @alpha */ From 770981908a557670264768fc565dfa1bdf54217b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 24 Feb 2025 12:34:58 +0100 Subject: [PATCH 07/16] docs/software-catalog: add entity predicate docs Signed-off-by: Patrik Oldsberg --- .../software-catalog/catalog-customization.md | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) diff --git a/docs/features/software-catalog/catalog-customization.md b/docs/features/software-catalog/catalog-customization.md index 5c786a6f48..0a4fcc8090 100644 --- a/docs/features/software-catalog/catalog-customization.md +++ b/docs/features/software-catalog/catalog-customization.md @@ -502,3 +502,201 @@ const routes = ( ); ``` + +## New Frontend System + +This section of the documentation explains how to create and configure catalog extensions in the [new frontend system](../../frontend-system/index.md). + +:::warning Warning + +This section is a work in progress. + +::: + +### Entity filters + +Many extensions that attach within the catalog entity pages accept a `filter` configuration. The purpose of the `filter` configuration is to select what entities the extension should be applied to or be present on. Many of these extension will have a default filter defined, but you can override it by providing your own. When defining filters in code you can use either a predicate function or a entity predicate query, while in configuration you can only use an entity predicate query. + +### Entity predicate queries + +The entity predicate syntax is a minimal JSON-based query language for filtering catalog entities. It is loosely inspired by the [MongoDB query syntax](https://www.mongodb.com/docs/manual/tutorial/query-documents/), behaving roughly the same way but with a different set of operators. + +The most simple entity predicate is an object expression with key-value mappings where the key is the full dot-separated path to the value in the entity, and the value is the value to do a case insensitive match against. Each entry in this object is evaluated separately, but all of them must match for the overall predicate to result in a match. For example, the following will match any component entities of the type `service`: + +```json +{ + "filter": { + "kind": "component", + "spec.type": "service" + } +} +``` + +Or when utilizing YAML syntax: + +```yaml +filter: + kind: component + spec.type: service +``` + +In addition to this basic syntax, entity predicates support logical operators that can be nested and applied around these object expressions. For example, the following will match all components entities that are of type `service` or `website`: + +```json +{ + "filter": { + "$all": [ + { + "kind": "component" + }, + { + "$any": [{ "spec.type": "service" }, { "spec.type": "website" }] + } + ] + } +} +``` + +Or when utilizing YAML syntax: + +```yaml +filter: + $all: + - kind: component + - $any: + - spec.type: service + - spec.type: website +``` + +Finally, entity predicates also support value operators that can be used in place of the values in the object expression. For example, the following is a simpler way to express the previous example: + +```json +{ + "filter": { + { + "kind": "component", + "spec.type": { "$in": ["service", "website"] } + }, + } +} +``` + +Or when utilizing YAML syntax: + +```yaml +filter: + kind: component + spec.type: + $in: [service, website] +``` + +### Entity predicate logical operators + +The following section lists all logical operators for entity predicates. + +#### `$all` + +The `$all` operator has the following syntax: + +```json +{ $all: [ { }, { }, ...] } +``` + +The `$all` operator evaluates to `true` if all expressions within the provided array evaluate to `true`. This includes an empty array, which means that `{ "$all": [] }` always evaluates to `true`. + +```yaml title="Example usage of $all" +filter: + $all: + - kind: component + - $not: + spec.type: service +``` + +#### `$any` + +The `$any` operator has the following syntax: + +```json +{ $any: [ { }, { }, ...] } +``` + +The `$any` operator evaluates to `true` if at least one of the expressions within the provided array evaluate to `true`. This includes an empty array, which means that `{ "$any": [] }` always evaluates to `false`. + +```yaml title="Example usage of $any" +filter: + $any: + - kind: component + - metadata.annotations.github.com/project-slug: { $exists: true } +``` + +#### `$not` + +The `$not` operator has the following syntax: + +```json +{ $not: { } } +``` + +The `$not` operator inverts the result of the provided express. If the expression evalutes to `true` then `$not` will evaluate to false, and the other way around. + +```yaml title="Example usage of $not" +filter: + $not: + kind: template +``` + +### Entity predicate value operators + +The following section lists all value operators for entity predicates. + +#### `$exists` + +The `$exists` operator has the following syntax: + +```json +{ field: { $exists: } } +``` + +The `$exists` operator will evaluate to `true` if the existence of the value it matches against matches the provided boolean. That is `{ $exists: true }` will evaluate to `true` if and only if the value is defined, and `{ $exists: false }` will evalute to `true` if and only if the value is `undefined`. + +```yaml title="Example usage of $exists" +filter: + metadata.annotations.github.com/project-slug: { $exists: true } +``` + +#### `$in` + +The `$in` operator has the following syntax: + +```json +{ field: { $in: [ , , ... ] } } +``` + +The `$in` operator will evaluate to `true` if the value it is matched against is exists within the array of primitives. The comparison is case insensitive and can only be done across primitive values. If the value matched against is an object or array, the operator will always evaluate to `false`. + +```yaml title="Example usage of $in" +filter: + kind: + $in: [component, api] +``` + +#### `$contains` + +The `$contains` operator has the following syntax: + +```json +{ field: { $contains: { } } } +``` + +The `$contains` operator will evaluate to `true` if the value it is matched against is an array, and at least one of the elements in the array fully matches the provided expression. If the value matched against is not an array, or if the array is empty, the operator will always evaluate to `false`. + +The expression used to match against the array can be any valid entity predicate expression, including logical operators and value operators. + +```yaml title="Example usage of $contains" +filter: + relations: + $contains: + type: ownedBy + target: + $in: [group:default/admins, group:default/viewers] +``` From 2fd73aa5f8cb048cbf8caa243c67c57f006bfc1a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 24 Feb 2025 15:32:26 +0100 Subject: [PATCH 08/16] config-loader: only apply include transform to known keys Signed-off-by: Patrik Oldsberg --- .changeset/many-wolves-own.md | 5 +++++ packages/config-loader/src/sources/transform/include.ts | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 .changeset/many-wolves-own.md diff --git a/.changeset/many-wolves-own.md b/.changeset/many-wolves-own.md new file mode 100644 index 0000000000..672af86d62 --- /dev/null +++ b/.changeset/many-wolves-own.md @@ -0,0 +1,5 @@ +--- +'@backstage/config-loader': minor +--- + +The include transforms applied during config loading will now only apply to the known keys `$file`, `$env`, and `$include`. Any other key that begins with a `$` will now be passed through as is. diff --git a/packages/config-loader/src/sources/transform/include.ts b/packages/config-loader/src/sources/transform/include.ts index fb2b22ef46..c476b0434f 100644 --- a/packages/config-loader/src/sources/transform/include.ts +++ b/packages/config-loader/src/sources/transform/include.ts @@ -21,6 +21,8 @@ import { isObject } from './utils'; import { TransformFunc, ReadFileFunc } from './types'; import { SubstitutionFunc } from '../types'; +const INCLUDE_KEYS = ['$file', '$env', '$include']; + // Parsers for each type of included file const includeFileParser: { [ext in string]: (content: string) => Promise; @@ -48,7 +50,9 @@ export function createIncludeTransform( } // Check if there's any key that starts with a '$', in that case we treat // this entire object as an include description. - const [includeKey] = Object.keys(input).filter(key => key.startsWith('$')); + const [includeKey] = Object.keys(input).filter(key => + INCLUDE_KEYS.includes(key), + ); if (includeKey) { if (Object.keys(input).length !== 1) { throw new Error( From 917e1776347f53b5b998444c90ca2b4a6331446e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 24 Feb 2025 15:32:46 +0100 Subject: [PATCH 09/16] app-next: switch example to use entity predicate in config Signed-off-by: Patrik Oldsberg --- packages/app-next/app-config.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/app-next/app-config.yaml b/packages/app-next/app-config.yaml index fdda5958de..8129ab87f7 100644 --- a/packages/app-next/app-config.yaml +++ b/packages/app-next/app-config.yaml @@ -37,7 +37,11 @@ app: - entity-card:catalog/labels - entity-card:catalog/links: config: - filter: kind:component has:links + filter: + kind: component + metadata.links: + $exists: true + # filter: kind:component has:links type: info # - entity-card:linguist/languages - entity-card:catalog-graph/relations: From 7f573652359c9f4307fb800496d3b1ee8529eb67 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 24 Feb 2025 15:51:04 +0100 Subject: [PATCH 10/16] changesets: add changeset for entity predicates Signed-off-by: Patrik Oldsberg --- .changeset/fair-pianos-add.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fair-pianos-add.md diff --git a/.changeset/fair-pianos-add.md b/.changeset/fair-pianos-add.md new file mode 100644 index 0000000000..fda4112c57 --- /dev/null +++ b/.changeset/fair-pianos-add.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': minor +--- + +Add support for a new entity predicate syntax when defining `filter`s related to the blueprints exported via `/alpha` for the new frontend system. For more information, see the [entity filters documentation](https://backstage.io/docs/features/software-catalog/catalog-customization#advanced-customization#entity-filters). From 465749d2bd95499cf8f2c3848c6663c3cfe5069b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 24 Feb 2025 16:31:16 +0100 Subject: [PATCH 11/16] catalog-react: update API reports for entity predicates + fixes Signed-off-by: Patrik Oldsberg --- plugins/api-docs/report-alpha.api.md | 49 ++++++------ plugins/catalog-graph/report-alpha.api.md | 7 +- plugins/catalog-react/report-alpha.api.md | 75 +++++++++++++++---- .../alpha/blueprints/EntityCardBlueprint.ts | 3 +- .../blueprints/EntityContentBlueprint.ts | 3 +- .../EntityContentLayoutBlueprint.tsx | 3 +- .../convertLegacyEntityCardExtension.tsx | 6 +- .../convertLegacyEntityContentExtension.tsx | 6 +- .../predicates/evaluateEntityPredicate.ts | 1 + plugins/catalog/report-alpha.api.md | 67 +++++++++-------- plugins/kubernetes/report-alpha.api.md | 7 +- plugins/org/report-alpha.api.md | 25 ++++--- plugins/techdocs/report-alpha.api.md | 7 +- 13 files changed, 157 insertions(+), 102 deletions(-) diff --git a/plugins/api-docs/report-alpha.api.md b/plugins/api-docs/report-alpha.api.md index 78e8b9442b..73c1fc3ae9 100644 --- a/plugins/api-docs/report-alpha.api.md +++ b/plugins/api-docs/report-alpha.api.md @@ -9,6 +9,7 @@ import { AnyRouteRefParams } from '@backstage/frontend-plugin-api'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { Entity } from '@backstage/catalog-model'; import { EntityCardType } from '@backstage/plugin-catalog-react/alpha'; +import { EntityPredicate } from '@backstage/plugin-catalog-react/alpha'; import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { ExtensionInput } from '@backstage/frontend-plugin-api'; import { ExternalRouteRef } from '@backstage/frontend-plugin-api'; @@ -45,11 +46,11 @@ const _default: FrontendPlugin< kind: 'entity-card'; name: 'consumed-apis'; config: { - filter: string | undefined; + filter: EntityPredicate | undefined; type: 'full' | 'info' | 'peek' | undefined; }; configInput: { - filter?: string | undefined; + filter?: EntityPredicate | undefined; type?: 'full' | 'info' | 'peek' | undefined; }; output: @@ -82,7 +83,7 @@ const _default: FrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; type?: EntityCardType | undefined; }; }>; @@ -90,11 +91,11 @@ const _default: FrontendPlugin< kind: 'entity-card'; name: 'consuming-components'; config: { - filter: string | undefined; + filter: EntityPredicate | undefined; type: 'full' | 'info' | 'peek' | undefined; }; configInput: { - filter?: string | undefined; + filter?: EntityPredicate | undefined; type?: 'full' | 'info' | 'peek' | undefined; }; output: @@ -127,7 +128,7 @@ const _default: FrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; type?: EntityCardType | undefined; }; }>; @@ -135,11 +136,11 @@ const _default: FrontendPlugin< kind: 'entity-card'; name: 'definition'; config: { - filter: string | undefined; + filter: EntityPredicate | undefined; type: 'full' | 'info' | 'peek' | undefined; }; configInput: { - filter?: string | undefined; + filter?: EntityPredicate | undefined; type?: 'full' | 'info' | 'peek' | undefined; }; output: @@ -172,7 +173,7 @@ const _default: FrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; type?: EntityCardType | undefined; }; }>; @@ -180,11 +181,11 @@ const _default: FrontendPlugin< kind: 'entity-card'; name: 'has-apis'; config: { - filter: string | undefined; + filter: EntityPredicate | undefined; type: 'full' | 'info' | 'peek' | undefined; }; configInput: { - filter?: string | undefined; + filter?: EntityPredicate | undefined; type?: 'full' | 'info' | 'peek' | undefined; }; output: @@ -217,7 +218,7 @@ const _default: FrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; type?: EntityCardType | undefined; }; }>; @@ -225,11 +226,11 @@ const _default: FrontendPlugin< kind: 'entity-card'; name: 'provided-apis'; config: { - filter: string | undefined; + filter: EntityPredicate | undefined; type: 'full' | 'info' | 'peek' | undefined; }; configInput: { - filter?: string | undefined; + filter?: EntityPredicate | undefined; type?: 'full' | 'info' | 'peek' | undefined; }; output: @@ -262,7 +263,7 @@ const _default: FrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; type?: EntityCardType | undefined; }; }>; @@ -270,11 +271,11 @@ const _default: FrontendPlugin< kind: 'entity-card'; name: 'providing-components'; config: { - filter: string | undefined; + filter: EntityPredicate | undefined; type: 'full' | 'info' | 'peek' | undefined; }; configInput: { - filter?: string | undefined; + filter?: EntityPredicate | undefined; type?: 'full' | 'info' | 'peek' | undefined; }; output: @@ -307,7 +308,7 @@ const _default: FrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; type?: EntityCardType | undefined; }; }>; @@ -317,11 +318,11 @@ const _default: FrontendPlugin< config: { path: string | undefined; title: string | undefined; - filter: string | undefined; + filter: EntityPredicate | undefined; group: string | false | undefined; }; configInput: { - filter?: string | undefined; + filter?: EntityPredicate | undefined; title?: string | undefined; path?: string | undefined; group?: string | false | undefined; @@ -379,7 +380,7 @@ const _default: FrontendPlugin< | 'observability' | undefined; routeRef?: RouteRef | undefined; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; }; }>; 'entity-content:api-docs/definition': ExtensionDefinition<{ @@ -388,11 +389,11 @@ const _default: FrontendPlugin< config: { path: string | undefined; title: string | undefined; - filter: string | undefined; + filter: EntityPredicate | undefined; group: string | false | undefined; }; configInput: { - filter?: string | undefined; + filter?: EntityPredicate | undefined; title?: string | undefined; path?: string | undefined; group?: string | false | undefined; @@ -450,7 +451,7 @@ const _default: FrontendPlugin< | 'observability' | undefined; routeRef?: RouteRef | undefined; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; }; }>; 'nav-item:api-docs': ExtensionDefinition<{ diff --git a/plugins/catalog-graph/report-alpha.api.md b/plugins/catalog-graph/report-alpha.api.md index 9cdd5c3f01..fcc9265cb4 100644 --- a/plugins/catalog-graph/report-alpha.api.md +++ b/plugins/catalog-graph/report-alpha.api.md @@ -9,6 +9,7 @@ import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { Direction } from '@backstage/plugin-catalog-graph'; import { Entity } from '@backstage/catalog-model'; import { EntityCardType } from '@backstage/plugin-catalog-react/alpha'; +import { EntityPredicate } from '@backstage/plugin-catalog-react/alpha'; import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { ExtensionInput } from '@backstage/frontend-plugin-api'; import { ExternalRouteRef } from '@backstage/frontend-plugin-api'; @@ -43,7 +44,7 @@ const _default: FrontendPlugin< title: string | undefined; height: number | undefined; } & { - filter: string | undefined; + filter: EntityPredicate | undefined; type: 'full' | 'info' | 'peek' | undefined; }; configInput: { @@ -59,7 +60,7 @@ const _default: FrontendPlugin< mergeRelations?: boolean | undefined; relationPairs?: [string, string][] | undefined; } & { - filter?: string | undefined; + filter?: EntityPredicate | undefined; type?: 'full' | 'info' | 'peek' | undefined; }; output: @@ -102,7 +103,7 @@ const _default: FrontendPlugin< name: 'relations'; params: { loader: () => Promise; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; type?: EntityCardType | undefined; }; }>; diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index 02fac5f75a..1619bc6a3d 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -11,6 +11,7 @@ import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { Entity } from '@backstage/catalog-model'; import { ExtensionBlueprint } from '@backstage/frontend-plugin-api'; import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; +import { JsonValue } from '@backstage/types'; import { JSX as JSX_2 } from 'react'; import { default as React_2 } from 'react'; import { ResourcePermission } from '@backstage/plugin-permission-common'; @@ -80,9 +81,7 @@ export function convertLegacyEntityCardExtension( LegacyExtension: ComponentType<{}>, overrides?: { name?: string; - filter?: - | typeof EntityCardBlueprint.dataRefs.filterFunction.T - | typeof EntityCardBlueprint.dataRefs.filterExpression.T; + filter?: string | EntityPredicate | ((entity: Entity) => boolean); }, ): ExtensionDefinition; @@ -91,9 +90,7 @@ export function convertLegacyEntityContentExtension( LegacyExtension: ComponentType<{}>, overrides?: { name?: string; - filter?: - | typeof EntityContentBlueprint.dataRefs.filterFunction.T - | typeof EntityContentBlueprint.dataRefs.filterExpression.T; + filter?: string | EntityPredicate | ((entity: Entity) => boolean); defaultPath?: string; defaultTitle?: string; }, @@ -113,7 +110,7 @@ export const EntityCardBlueprint: ExtensionBlueprint<{ name: undefined; params: { loader: () => Promise; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; type?: EntityCardType | undefined; }; output: @@ -141,11 +138,11 @@ export const EntityCardBlueprint: ExtensionBlueprint<{ >; inputs: {}; config: { - filter: string | undefined; + filter: EntityPredicate | undefined; type: 'full' | 'info' | 'peek' | undefined; }; configInput: { - filter?: string | undefined; + filter?: EntityPredicate | undefined; type?: 'full' | 'info' | 'peek' | undefined; }; dataRefs: { @@ -186,7 +183,7 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ | 'observability' | undefined; routeRef?: RouteRef | undefined; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; }; output: | ConfigurableExtensionDataRef @@ -224,11 +221,11 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ config: { path: string | undefined; title: string | undefined; - filter: string | undefined; + filter: EntityPredicate | undefined; group: string | false | undefined; }; configInput: { - filter?: string | undefined; + filter?: EntityPredicate | undefined; title?: string | undefined; path?: string | undefined; group?: string | false | undefined; @@ -262,7 +259,7 @@ export const EntityContentLayoutBlueprint: ExtensionBlueprint<{ kind: 'entity-content-layout'; name: undefined; params: { - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; loader: () => Promise< (props: EntityContentLayoutProps) => React_2.JSX.Element >; @@ -290,10 +287,10 @@ export const EntityContentLayoutBlueprint: ExtensionBlueprint<{ inputs: {}; config: { type: string | undefined; - filter: string | undefined; + filter: EntityPredicate | undefined; }; configInput: { - filter?: string | undefined; + filter?: EntityPredicate | undefined; type?: string | undefined; }; dataRefs: { @@ -324,6 +321,54 @@ export interface EntityContentLayoutProps { }>; } +// @alpha (undocumented) +export type EntityPredicate = + | EntityPredicateExpression + | EntityPredicatePrimitive + | { + $all: EntityPredicate[]; + } + | { + $any: EntityPredicate[]; + } + | { + $not: EntityPredicate; + }; + +// @alpha (undocumented) +export type EntityPredicateExpression = { + [KPath in string]: EntityPredicateValue; +} & { + [KPath in `$${string}`]: never; +}; + +// @alpha (undocumented) +export type EntityPredicatePrimitive = string | number | boolean; + +// @alpha +export function entityPredicateToFilterFunction( + entityPredicate: EntityPredicate, +): (value: T) => boolean; + +// @alpha (undocumented) +export type EntityPredicateValue = + | EntityPredicatePrimitive + | { + $exists: boolean; + } + | { + $in: EntityPredicatePrimitive[]; + } + | { + $contains: EntityPredicateExpression; + }; + +// @alpha +export function evaluateEntityPredicate( + filter: EntityPredicate, + value: JsonValue, +): boolean; + // @alpha export function isOwnerOf(owner: Entity, entity: Entity): boolean; diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts index 4639a8863f..b2f64c19e6 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts +++ b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts @@ -29,6 +29,7 @@ import { import { createEntityPredicateSchema } from '../predicates/createEntityPredicateSchema'; import { EntityPredicate } from '../predicates'; import { resolveEntityFilterData } from './resolveEntityFilterData'; +import { Entity } from '@backstage/catalog-model'; /** * @alpha @@ -62,7 +63,7 @@ export const EntityCardBlueprint = createExtensionBlueprint({ type, }: { loader: () => Promise; - filter?: EntityPredicate | typeof entityFilterFunctionDataRef.T; + filter?: string | EntityPredicate | ((entity: Entity) => boolean); type?: EntityCardType; }, { node, config }, diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts index 9c67f3c0bc..dbfb11092a 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts @@ -30,6 +30,7 @@ import { import { EntityPredicate } from '../predicates'; import { resolveEntityFilterData } from './resolveEntityFilterData'; import { createEntityPredicateSchema } from '../predicates/createEntityPredicateSchema'; +import { Entity } from '@backstage/catalog-model'; /** * @alpha @@ -76,7 +77,7 @@ export const EntityContentBlueprint = createExtensionBlueprint({ defaultTitle: string; defaultGroup?: keyof typeof defaultEntityContentGroups | (string & {}); routeRef?: RouteRef; - filter?: string | EntityPredicate | typeof entityFilterFunctionDataRef.T; + filter?: string | EntityPredicate | ((entity: Entity) => boolean); }, { node, config }, ) { diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContentLayoutBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContentLayoutBlueprint.tsx index a6a90eee06..033b0f2187 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContentLayoutBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentLayoutBlueprint.tsx @@ -28,6 +28,7 @@ import React from 'react'; import { EntityPredicate } from '../predicates'; import { resolveEntityFilterData } from './resolveEntityFilterData'; import { createEntityPredicateSchema } from '../predicates/createEntityPredicateSchema'; +import { Entity } from '@backstage/catalog-model'; /** @alpha */ export interface EntityContentLayoutProps { @@ -69,7 +70,7 @@ export const EntityContentLayoutBlueprint = createExtensionBlueprint({ loader, filter, }: { - filter?: string | EntityPredicate | typeof entityFilterFunctionDataRef.T; + filter?: string | EntityPredicate | ((entity: Entity) => boolean); loader: () => Promise< (props: EntityContentLayoutProps) => React.JSX.Element >; diff --git a/plugins/catalog-react/src/alpha/converters/convertLegacyEntityCardExtension.tsx b/plugins/catalog-react/src/alpha/converters/convertLegacyEntityCardExtension.tsx index ed92461736..3c76e6b8d6 100644 --- a/plugins/catalog-react/src/alpha/converters/convertLegacyEntityCardExtension.tsx +++ b/plugins/catalog-react/src/alpha/converters/convertLegacyEntityCardExtension.tsx @@ -20,15 +20,15 @@ import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import React, { ComponentType } from 'react'; import { EntityCardBlueprint } from '../blueprints'; import kebabCase from 'lodash/kebabCase'; +import { EntityPredicate } from '../predicates'; +import { Entity } from '@backstage/catalog-model'; /** @alpha */ export function convertLegacyEntityCardExtension( LegacyExtension: ComponentType<{}>, overrides?: { name?: string; - filter?: - | typeof EntityCardBlueprint.dataRefs.filterFunction.T - | typeof EntityCardBlueprint.dataRefs.filterExpression.T; + filter?: string | EntityPredicate | ((entity: Entity) => boolean); }, ): ExtensionDefinition { const element = ; diff --git a/plugins/catalog-react/src/alpha/converters/convertLegacyEntityContentExtension.tsx b/plugins/catalog-react/src/alpha/converters/convertLegacyEntityContentExtension.tsx index 02f5d104bc..2f106a20e1 100644 --- a/plugins/catalog-react/src/alpha/converters/convertLegacyEntityContentExtension.tsx +++ b/plugins/catalog-react/src/alpha/converters/convertLegacyEntityContentExtension.tsx @@ -28,15 +28,15 @@ import kebabCase from 'lodash/kebabCase'; import startCase from 'lodash/startCase'; import React, { ComponentType } from 'react'; import { EntityContentBlueprint } from '../blueprints'; +import { EntityPredicate } from '../predicates'; +import { Entity } from '@backstage/catalog-model'; /** @alpha */ export function convertLegacyEntityContentExtension( LegacyExtension: ComponentType<{}>, overrides?: { name?: string; - filter?: - | typeof EntityContentBlueprint.dataRefs.filterFunction.T - | typeof EntityContentBlueprint.dataRefs.filterExpression.T; + filter?: string | EntityPredicate | ((entity: Entity) => boolean); defaultPath?: string; defaultTitle?: string; }, diff --git a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts index cf33457376..84fbfa3499 100644 --- a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts +++ b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts @@ -24,6 +24,7 @@ import { valueAtPath } from './valueAtPath'; /** * Convert an entity predicate to a filter function that can be used to filter entities. + * @alpha */ export function entityPredicateToFilterFunction( entityPredicate: EntityPredicate, diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index 4961e2e9fb..3732f3355a 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -12,6 +12,7 @@ import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { Entity } from '@backstage/catalog-model'; import { EntityCardType } from '@backstage/plugin-catalog-react/alpha'; import { EntityContentLayoutProps } from '@backstage/plugin-catalog-react/alpha'; +import { EntityPredicate } from '@backstage/plugin-catalog-react/alpha'; import { ExtensionBlueprint } from '@backstage/frontend-plugin-api'; import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { ExtensionInput } from '@backstage/frontend-plugin-api'; @@ -353,11 +354,11 @@ const _default: FrontendPlugin< kind: 'entity-card'; name: 'about'; config: { - filter: string | undefined; + filter: EntityPredicate | undefined; type: 'full' | 'info' | 'peek' | undefined; }; configInput: { - filter?: string | undefined; + filter?: EntityPredicate | undefined; type?: 'full' | 'info' | 'peek' | undefined; }; output: @@ -386,7 +387,7 @@ const _default: FrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; type?: EntityCardType | undefined; }; }>; @@ -394,11 +395,11 @@ const _default: FrontendPlugin< kind: 'entity-card'; name: 'depends-on-components'; config: { - filter: string | undefined; + filter: EntityPredicate | undefined; type: 'full' | 'info' | 'peek' | undefined; }; configInput: { - filter?: string | undefined; + filter?: EntityPredicate | undefined; type?: 'full' | 'info' | 'peek' | undefined; }; output: @@ -427,7 +428,7 @@ const _default: FrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; type?: EntityCardType | undefined; }; }>; @@ -435,11 +436,11 @@ const _default: FrontendPlugin< kind: 'entity-card'; name: 'depends-on-resources'; config: { - filter: string | undefined; + filter: EntityPredicate | undefined; type: 'full' | 'info' | 'peek' | undefined; }; configInput: { - filter?: string | undefined; + filter?: EntityPredicate | undefined; type?: 'full' | 'info' | 'peek' | undefined; }; output: @@ -468,7 +469,7 @@ const _default: FrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; type?: EntityCardType | undefined; }; }>; @@ -476,11 +477,11 @@ const _default: FrontendPlugin< kind: 'entity-card'; name: 'has-components'; config: { - filter: string | undefined; + filter: EntityPredicate | undefined; type: 'full' | 'info' | 'peek' | undefined; }; configInput: { - filter?: string | undefined; + filter?: EntityPredicate | undefined; type?: 'full' | 'info' | 'peek' | undefined; }; output: @@ -509,7 +510,7 @@ const _default: FrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; type?: EntityCardType | undefined; }; }>; @@ -517,11 +518,11 @@ const _default: FrontendPlugin< kind: 'entity-card'; name: 'has-resources'; config: { - filter: string | undefined; + filter: EntityPredicate | undefined; type: 'full' | 'info' | 'peek' | undefined; }; configInput: { - filter?: string | undefined; + filter?: EntityPredicate | undefined; type?: 'full' | 'info' | 'peek' | undefined; }; output: @@ -550,7 +551,7 @@ const _default: FrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; type?: EntityCardType | undefined; }; }>; @@ -558,11 +559,11 @@ const _default: FrontendPlugin< kind: 'entity-card'; name: 'has-subcomponents'; config: { - filter: string | undefined; + filter: EntityPredicate | undefined; type: 'full' | 'info' | 'peek' | undefined; }; configInput: { - filter?: string | undefined; + filter?: EntityPredicate | undefined; type?: 'full' | 'info' | 'peek' | undefined; }; output: @@ -591,7 +592,7 @@ const _default: FrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; type?: EntityCardType | undefined; }; }>; @@ -599,11 +600,11 @@ const _default: FrontendPlugin< kind: 'entity-card'; name: 'has-subdomains'; config: { - filter: string | undefined; + filter: EntityPredicate | undefined; type: 'full' | 'info' | 'peek' | undefined; }; configInput: { - filter?: string | undefined; + filter?: EntityPredicate | undefined; type?: 'full' | 'info' | 'peek' | undefined; }; output: @@ -632,7 +633,7 @@ const _default: FrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; type?: EntityCardType | undefined; }; }>; @@ -640,11 +641,11 @@ const _default: FrontendPlugin< kind: 'entity-card'; name: 'has-systems'; config: { - filter: string | undefined; + filter: EntityPredicate | undefined; type: 'full' | 'info' | 'peek' | undefined; }; configInput: { - filter?: string | undefined; + filter?: EntityPredicate | undefined; type?: 'full' | 'info' | 'peek' | undefined; }; output: @@ -673,7 +674,7 @@ const _default: FrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; type?: EntityCardType | undefined; }; }>; @@ -681,11 +682,11 @@ const _default: FrontendPlugin< kind: 'entity-card'; name: 'labels'; config: { - filter: string | undefined; + filter: EntityPredicate | undefined; type: 'full' | 'info' | 'peek' | undefined; }; configInput: { - filter?: string | undefined; + filter?: EntityPredicate | undefined; type?: 'full' | 'info' | 'peek' | undefined; }; output: @@ -714,7 +715,7 @@ const _default: FrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; type?: EntityCardType | undefined; }; }>; @@ -722,11 +723,11 @@ const _default: FrontendPlugin< kind: 'entity-card'; name: 'links'; config: { - filter: string | undefined; + filter: EntityPredicate | undefined; type: 'full' | 'info' | 'peek' | undefined; }; configInput: { - filter?: string | undefined; + filter?: EntityPredicate | undefined; type?: 'full' | 'info' | 'peek' | undefined; }; output: @@ -755,7 +756,7 @@ const _default: FrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; type?: EntityCardType | undefined; }; }>; @@ -763,11 +764,11 @@ const _default: FrontendPlugin< config: { path: string | undefined; title: string | undefined; - filter: string | undefined; + filter: EntityPredicate | undefined; group: string | false | undefined; }; configInput: { - filter?: string | undefined; + filter?: EntityPredicate | undefined; title?: string | undefined; path?: string | undefined; group?: string | false | undefined; @@ -877,7 +878,7 @@ const _default: FrontendPlugin< | 'observability' | undefined; routeRef?: RouteRef | undefined; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; }; }>; 'nav-item:catalog': ExtensionDefinition<{ diff --git a/plugins/kubernetes/report-alpha.api.md b/plugins/kubernetes/report-alpha.api.md index ef824bc435..0cca232bf6 100644 --- a/plugins/kubernetes/report-alpha.api.md +++ b/plugins/kubernetes/report-alpha.api.md @@ -9,6 +9,7 @@ import { AnyApiFactory } from '@backstage/frontend-plugin-api'; import { AnyRouteRefParams } from '@backstage/frontend-plugin-api'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { Entity } from '@backstage/catalog-model'; +import { EntityPredicate } from '@backstage/plugin-catalog-react/alpha'; import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { FrontendPlugin } from '@backstage/frontend-plugin-api'; import { JSX as JSX_2 } from 'react'; @@ -88,11 +89,11 @@ const _default: FrontendPlugin< config: { path: string | undefined; title: string | undefined; - filter: string | undefined; + filter: EntityPredicate | undefined; group: string | false | undefined; }; configInput: { - filter?: string | undefined; + filter?: EntityPredicate | undefined; title?: string | undefined; path?: string | undefined; group?: string | false | undefined; @@ -146,7 +147,7 @@ const _default: FrontendPlugin< | 'observability' | undefined; routeRef?: RouteRef | undefined; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; }; }>; 'page:kubernetes': ExtensionDefinition<{ diff --git a/plugins/org/report-alpha.api.md b/plugins/org/report-alpha.api.md index eb5c41faa1..508c0ed04e 100644 --- a/plugins/org/report-alpha.api.md +++ b/plugins/org/report-alpha.api.md @@ -6,6 +6,7 @@ import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { Entity } from '@backstage/catalog-model'; import { EntityCardType } from '@backstage/plugin-catalog-react/alpha'; +import { EntityPredicate } from '@backstage/plugin-catalog-react/alpha'; import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { ExternalRouteRef } from '@backstage/frontend-plugin-api'; import { FrontendPlugin } from '@backstage/frontend-plugin-api'; @@ -22,11 +23,11 @@ const _default: FrontendPlugin< kind: 'entity-card'; name: 'group-profile'; config: { - filter: string | undefined; + filter: EntityPredicate | undefined; type: 'full' | 'info' | 'peek' | undefined; }; configInput: { - filter?: string | undefined; + filter?: EntityPredicate | undefined; type?: 'full' | 'info' | 'peek' | undefined; }; output: @@ -59,7 +60,7 @@ const _default: FrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; type?: EntityCardType | undefined; }; }>; @@ -67,11 +68,11 @@ const _default: FrontendPlugin< kind: 'entity-card'; name: 'members-list'; config: { - filter: string | undefined; + filter: EntityPredicate | undefined; type: 'full' | 'info' | 'peek' | undefined; }; configInput: { - filter?: string | undefined; + filter?: EntityPredicate | undefined; type?: 'full' | 'info' | 'peek' | undefined; }; output: @@ -104,7 +105,7 @@ const _default: FrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; type?: EntityCardType | undefined; }; }>; @@ -112,11 +113,11 @@ const _default: FrontendPlugin< kind: 'entity-card'; name: 'ownership'; config: { - filter: string | undefined; + filter: EntityPredicate | undefined; type: 'full' | 'info' | 'peek' | undefined; }; configInput: { - filter?: string | undefined; + filter?: EntityPredicate | undefined; type?: 'full' | 'info' | 'peek' | undefined; }; output: @@ -149,7 +150,7 @@ const _default: FrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; type?: EntityCardType | undefined; }; }>; @@ -157,11 +158,11 @@ const _default: FrontendPlugin< kind: 'entity-card'; name: 'user-profile'; config: { - filter: string | undefined; + filter: EntityPredicate | undefined; type: 'full' | 'info' | 'peek' | undefined; }; configInput: { - filter?: string | undefined; + filter?: EntityPredicate | undefined; type?: 'full' | 'info' | 'peek' | undefined; }; output: @@ -194,7 +195,7 @@ const _default: FrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; type?: EntityCardType | undefined; }; }>; diff --git a/plugins/techdocs/report-alpha.api.md b/plugins/techdocs/report-alpha.api.md index 681961a33e..0dcca55c73 100644 --- a/plugins/techdocs/report-alpha.api.md +++ b/plugins/techdocs/report-alpha.api.md @@ -8,6 +8,7 @@ import { AnyExtensionDataRef } from '@backstage/frontend-plugin-api'; import { AnyRouteRefParams } from '@backstage/frontend-plugin-api'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { Entity } from '@backstage/catalog-model'; +import { EntityPredicate } from '@backstage/plugin-catalog-react/alpha'; import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { ExtensionInput } from '@backstage/frontend-plugin-api'; import { FrontendPlugin } from '@backstage/frontend-plugin-api'; @@ -89,11 +90,11 @@ const _default: FrontendPlugin< config: { path: string | undefined; title: string | undefined; - filter: string | undefined; + filter: EntityPredicate | undefined; group: string | false | undefined; }; configInput: { - filter?: string | undefined; + filter?: EntityPredicate | undefined; title?: string | undefined; path?: string | undefined; group?: string | false | undefined; @@ -178,7 +179,7 @@ const _default: FrontendPlugin< | 'observability' | undefined; routeRef?: RouteRef | undefined; - filter?: string | ((entity: Entity) => boolean) | undefined; + filter?: EntityPredicate | ((entity: Entity) => boolean) | undefined; }; }>; 'nav-item:techdocs': ExtensionDefinition<{ From a1f4d4828541607c2acf24b2fc3f1ed89ae711e1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 24 Feb 2025 17:10:48 +0100 Subject: [PATCH 12/16] catalog-react: update test snapshots Signed-off-by: Patrik Oldsberg --- .../blueprints/EntityCardBlueprint.test.tsx | 121 +++++++++++++++++- .../EntityContentBlueprint.test.tsx | 121 +++++++++++++++++- 2 files changed, 240 insertions(+), 2 deletions(-) diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx index 563bb15ebd..f4e518799e 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.test.tsx @@ -52,7 +52,126 @@ describe('EntityCardBlueprint', () => { "additionalProperties": false, "properties": { "filter": { - "type": "string", + "anyOf": [ + { + "type": "string", + }, + { + "anyOf": [ + { + "anyOf": [ + { + "type": [ + "string", + "number", + "boolean", + ], + }, + { + "items": { + "$ref": "#/properties/filter/anyOf/1/anyOf/0/anyOf/0", + }, + "type": "array", + }, + ], + }, + { + "additionalProperties": false, + "properties": { + "$all": { + "items": { + "$ref": "#/properties/filter/anyOf/1", + }, + "type": "array", + }, + }, + "required": [ + "$all", + ], + "type": "object", + }, + { + "additionalProperties": false, + "properties": { + "$any": { + "items": { + "$ref": "#/properties/filter/anyOf/1", + }, + "type": "array", + }, + }, + "required": [ + "$any", + ], + "type": "object", + }, + { + "additionalProperties": false, + "properties": { + "$not": { + "$ref": "#/properties/filter/anyOf/1", + }, + }, + "required": [ + "$not", + ], + "type": "object", + }, + { + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/properties/filter/anyOf/1/anyOf/0", + }, + { + "additionalProperties": false, + "properties": { + "$exists": { + "type": "boolean", + }, + }, + "required": [ + "$exists", + ], + "type": "object", + }, + { + "additionalProperties": false, + "properties": { + "$in": { + "items": { + "$ref": "#/properties/filter/anyOf/1/anyOf/0/anyOf/0", + }, + "type": "array", + }, + }, + "required": [ + "$in", + ], + "type": "object", + }, + { + "additionalProperties": false, + "properties": { + "$contains": { + "$ref": "#/properties/filter/anyOf/1", + }, + }, + "required": [ + "$contains", + ], + "type": "object", + }, + ], + }, + "propertyNames": { + "pattern": "^(?!\\$).*$", + }, + "type": "object", + }, + ], + }, + ], }, "type": { "enum": [ diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx index 15d0e3e1ab..9c118df26c 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx @@ -54,7 +54,126 @@ describe('EntityContentBlueprint', () => { "additionalProperties": false, "properties": { "filter": { - "type": "string", + "anyOf": [ + { + "type": "string", + }, + { + "anyOf": [ + { + "anyOf": [ + { + "type": [ + "string", + "number", + "boolean", + ], + }, + { + "items": { + "$ref": "#/properties/filter/anyOf/1/anyOf/0/anyOf/0", + }, + "type": "array", + }, + ], + }, + { + "additionalProperties": false, + "properties": { + "$all": { + "items": { + "$ref": "#/properties/filter/anyOf/1", + }, + "type": "array", + }, + }, + "required": [ + "$all", + ], + "type": "object", + }, + { + "additionalProperties": false, + "properties": { + "$any": { + "items": { + "$ref": "#/properties/filter/anyOf/1", + }, + "type": "array", + }, + }, + "required": [ + "$any", + ], + "type": "object", + }, + { + "additionalProperties": false, + "properties": { + "$not": { + "$ref": "#/properties/filter/anyOf/1", + }, + }, + "required": [ + "$not", + ], + "type": "object", + }, + { + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/properties/filter/anyOf/1/anyOf/0", + }, + { + "additionalProperties": false, + "properties": { + "$exists": { + "type": "boolean", + }, + }, + "required": [ + "$exists", + ], + "type": "object", + }, + { + "additionalProperties": false, + "properties": { + "$in": { + "items": { + "$ref": "#/properties/filter/anyOf/1/anyOf/0/anyOf/0", + }, + "type": "array", + }, + }, + "required": [ + "$in", + ], + "type": "object", + }, + { + "additionalProperties": false, + "properties": { + "$contains": { + "$ref": "#/properties/filter/anyOf/1", + }, + }, + "required": [ + "$contains", + ], + "type": "object", + }, + ], + }, + "propertyNames": { + "pattern": "^(?!\\$).*$", + }, + "type": "object", + }, + ], + }, + ], }, "group": { "anyOf": [ From 2b48ac655e1e790f79e315d2300c2e9abcd1152e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 5 Mar 2025 23:52:18 +0100 Subject: [PATCH 13/16] docs/software-catalog: review fixes for entity predicate docs Signed-off-by: Patrik Oldsberg --- docs/features/software-catalog/catalog-customization.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/features/software-catalog/catalog-customization.md b/docs/features/software-catalog/catalog-customization.md index 0a4fcc8090..38c4b2a951 100644 --- a/docs/features/software-catalog/catalog-customization.md +++ b/docs/features/software-catalog/catalog-customization.md @@ -609,7 +609,7 @@ filter: $all: - kind: component - $not: - spec.type: service + spec.type: service ``` #### `$any` @@ -637,7 +637,7 @@ The `$not` operator has the following syntax: { $not: { } } ``` -The `$not` operator inverts the result of the provided express. If the expression evalutes to `true` then `$not` will evaluate to false, and the other way around. +The `$not` operator inverts the result of the provided express. If the expression evaluates to `true` then `$not` will evaluate to false, and the other way around. ```yaml title="Example usage of $not" filter: @@ -657,7 +657,7 @@ The `$exists` operator has the following syntax: { field: { $exists: } } ``` -The `$exists` operator will evaluate to `true` if the existence of the value it matches against matches the provided boolean. That is `{ $exists: true }` will evaluate to `true` if and only if the value is defined, and `{ $exists: false }` will evalute to `true` if and only if the value is `undefined`. +The `$exists` operator will evaluate to `true` if the existence of the value it matches against matches the provided boolean. That is `{ $exists: true }` will evaluate to `true` if and only if the value is defined, and `{ $exists: false }` will evaluate to `true` if and only if the value is not defined. ```yaml title="Example usage of $exists" filter: From 7dfad8f3a23d081e642a8ba8f207397fabf0ae45 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 5 Mar 2025 23:52:48 +0100 Subject: [PATCH 14/16] catalog-react: never match null in entity predicates Signed-off-by: Patrik Oldsberg --- .../src/alpha/predicates/evaluateEntityPredicate.test.ts | 2 ++ .../src/alpha/predicates/evaluateEntityPredicate.ts | 3 +++ 2 files changed, 5 insertions(+) diff --git a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts index 6ce23b1c74..261beae5ba 100644 --- a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts +++ b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts @@ -108,6 +108,7 @@ describe('evaluateEntityPredicate', () => { type: 'grpc', owner: 'g', definition: 'mock', + nothing: null, }, relations: [ { @@ -215,6 +216,7 @@ describe('evaluateEntityPredicate', () => { ['s,w,a', { 'spec.owner': { $exists: true } }], ['g', { 'spec.owner': { $exists: false } }], ['s', { 'spec.type': 'service' }], + ['', { 'spec.nothing': null }], ['w,g,a', { $not: { 'spec.type': 'service' } }], ['', { 'spec.type': null }], [ diff --git a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts index 84fbfa3499..514f01f0ad 100644 --- a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts +++ b/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts @@ -108,6 +108,9 @@ function valuesAreEqual( a: JsonValue | undefined, b: JsonValue | undefined, ): boolean { + if (a === null || b === null) { + return false; + } if (a === b) { return true; } From 6d85ae83215a35be4389411f4b494e2bef7f85ab Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 5 Mar 2025 23:56:23 +0100 Subject: [PATCH 15/16] catalog-react: removed evaluateEntityPredicate Signed-off-by: Patrik Oldsberg --- plugins/catalog-react/report-alpha.api.md | 6 ------ ...cate.test.ts => entityPredicateToFilterFunction.test.ts} | 6 +++--- ...ntityPredicate.ts => entityPredicateToFilterFunction.ts} | 4 ++-- plugins/catalog-react/src/alpha/predicates/index.ts | 5 +---- 4 files changed, 6 insertions(+), 15 deletions(-) rename plugins/catalog-react/src/alpha/predicates/{evaluateEntityPredicate.test.ts => entityPredicateToFilterFunction.test.ts} (96%) rename plugins/catalog-react/src/alpha/predicates/{evaluateEntityPredicate.ts => entityPredicateToFilterFunction.ts} (98%) diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index 1619bc6a3d..f495a043cc 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -363,12 +363,6 @@ export type EntityPredicateValue = $contains: EntityPredicateExpression; }; -// @alpha -export function evaluateEntityPredicate( - filter: EntityPredicate, - value: JsonValue, -): boolean; - // @alpha export function isOwnerOf(owner: Entity, entity: Entity): boolean; diff --git a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts b/plugins/catalog-react/src/alpha/predicates/entityPredicateToFilterFunction.test.ts similarity index 96% rename from plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts rename to plugins/catalog-react/src/alpha/predicates/entityPredicateToFilterFunction.test.ts index 261beae5ba..3e55bb33bd 100644 --- a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.test.ts +++ b/plugins/catalog-react/src/alpha/predicates/entityPredicateToFilterFunction.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { evaluateEntityPredicate } from './evaluateEntityPredicate'; +import { entityPredicateToFilterFunction } from './entityPredicateToFilterFunction'; import { EntityPredicate } from './types'; -describe('evaluateEntityPredicate', () => { +describe('entityPredicateToFilterFunction', () => { const entities = [ { apiVersion: 'backstage.io/v1alpha1', @@ -228,7 +228,7 @@ describe('evaluateEntityPredicate', () => { ], ])('filter entry %#', (expected, filter) => { const filtered = entities.filter(entity => - evaluateEntityPredicate(filter, entity), + entityPredicateToFilterFunction(filter)(entity), ); expect(filtered.map(e => e.metadata.name).sort()).toEqual( expected.split(',').filter(Boolean).sort(), diff --git a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts b/plugins/catalog-react/src/alpha/predicates/entityPredicateToFilterFunction.ts similarity index 98% rename from plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts rename to plugins/catalog-react/src/alpha/predicates/entityPredicateToFilterFunction.ts index 514f01f0ad..56a6efe723 100644 --- a/plugins/catalog-react/src/alpha/predicates/evaluateEntityPredicate.ts +++ b/plugins/catalog-react/src/alpha/predicates/entityPredicateToFilterFunction.ts @@ -35,9 +35,9 @@ export function entityPredicateToFilterFunction( /** * Evaluate a entity predicate against a value, typically an entity. * - * @alpha + * @internal */ -export function evaluateEntityPredicate( +function evaluateEntityPredicate( filter: EntityPredicate, value: JsonValue, ): boolean { diff --git a/plugins/catalog-react/src/alpha/predicates/index.ts b/plugins/catalog-react/src/alpha/predicates/index.ts index 5a9a747138..71be7403de 100644 --- a/plugins/catalog-react/src/alpha/predicates/index.ts +++ b/plugins/catalog-react/src/alpha/predicates/index.ts @@ -20,7 +20,4 @@ export type { EntityPredicatePrimitive, EntityPredicateValue, } from './types'; -export { - evaluateEntityPredicate, - entityPredicateToFilterFunction, -} from './evaluateEntityPredicate'; +export { entityPredicateToFilterFunction } from './entityPredicateToFilterFunction'; From 91e4fef6a35a3f80b56ccd3cefe3feec7959ad37 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 6 Mar 2025 00:30:49 +0100 Subject: [PATCH 16/16] catalog-react: support string=number comparisons for entity predicates Signed-off-by: Patrik Oldsberg --- .../predicates/entityPredicateToFilterFunction.test.ts | 6 ++++++ .../src/alpha/predicates/entityPredicateToFilterFunction.ts | 3 +++ 2 files changed, 9 insertions(+) diff --git a/plugins/catalog-react/src/alpha/predicates/entityPredicateToFilterFunction.test.ts b/plugins/catalog-react/src/alpha/predicates/entityPredicateToFilterFunction.test.ts index 3e55bb33bd..c83b213970 100644 --- a/plugins/catalog-react/src/alpha/predicates/entityPredicateToFilterFunction.test.ts +++ b/plugins/catalog-react/src/alpha/predicates/entityPredicateToFilterFunction.test.ts @@ -109,6 +109,8 @@ describe('entityPredicateToFilterFunction', () => { owner: 'g', definition: 'mock', nothing: null, + oneNum: 1, + oneStr: '1', }, relations: [ { @@ -219,6 +221,10 @@ describe('entityPredicateToFilterFunction', () => { ['', { 'spec.nothing': null }], ['w,g,a', { $not: { 'spec.type': 'service' } }], ['', { 'spec.type': null }], + ['a', { 'spec.oneNum': 1 }], + ['a', { 'spec.oneStr': 1 }], + ['a', { 'spec.oneNum': '1' }], + ['a', { 'spec.oneStr': '1' }], [ 's,w', { diff --git a/plugins/catalog-react/src/alpha/predicates/entityPredicateToFilterFunction.ts b/plugins/catalog-react/src/alpha/predicates/entityPredicateToFilterFunction.ts index 56a6efe723..2fa220e9e2 100644 --- a/plugins/catalog-react/src/alpha/predicates/entityPredicateToFilterFunction.ts +++ b/plugins/catalog-react/src/alpha/predicates/entityPredicateToFilterFunction.ts @@ -117,6 +117,9 @@ function valuesAreEqual( if (typeof a === 'string' && typeof b === 'string') { return a.toLocaleUpperCase('en-US') === b.toLocaleUpperCase('en-US'); } + if (typeof a === 'number' || typeof b === 'number') { + return String(a) === String(b); + } if (Array.isArray(a) && Array.isArray(b)) { return a.length === b.length && a.every((v, i) => valuesAreEqual(v, b[i])); }