From c216b1a6f556e55ea08477da2ef398d8846de516 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 20 Feb 2025 11:51:37 +0100 Subject: [PATCH] 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