Merge pull request #32065 from karpfslp/extend-entity-predicate-value-type

fix: $contains may have string value in an entity filter.
This commit is contained in:
Fredrik Adelöw
2025-12-16 09:25:11 +01:00
committed by GitHub
8 changed files with 304 additions and 263 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---
$contains may have string value in an entity filter. Typescript type and Zod parser were not the same.
+1 -1
View File
@@ -549,7 +549,7 @@ export type EntityPredicateValue =
$in: EntityPredicatePrimitive[];
}
| {
$contains: EntityPredicateExpression;
$contains: EntityPredicate;
};
// @alpha (undocumented)
@@ -60,20 +60,75 @@ describe('EntityCardBlueprint', () => {
{
"anyOf": [
{
"type": [
"string",
"number",
"boolean",
],
"additionalProperties": {
"anyOf": [
{
"type": [
"string",
"number",
"boolean",
],
},
{
"additionalProperties": false,
"properties": {
"$exists": {
"type": "boolean",
},
},
"required": [
"$exists",
],
"type": "object",
},
{
"additionalProperties": false,
"properties": {
"$in": {
"items": {
"$ref": "#/properties/filter/anyOf/1/anyOf/0/anyOf/0/additionalProperties/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",
},
{
"items": {
"$ref": "#/properties/filter/anyOf/1/anyOf/0/anyOf/0",
"additionalProperties": {
"not": {},
},
"type": "array",
"propertyNames": {
"pattern": "^\\$",
},
"type": "object",
},
],
},
{
"$ref": "#/properties/filter/anyOf/1/anyOf/0/anyOf/0/additionalProperties/anyOf/0",
},
{
"additionalProperties": false,
"properties": {
@@ -116,58 +171,6 @@ describe('EntityCardBlueprint', () => {
],
"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",
},
],
},
],
@@ -62,20 +62,75 @@ describe('EntityContentBlueprint', () => {
{
"anyOf": [
{
"type": [
"string",
"number",
"boolean",
],
"additionalProperties": {
"anyOf": [
{
"type": [
"string",
"number",
"boolean",
],
},
{
"additionalProperties": false,
"properties": {
"$exists": {
"type": "boolean",
},
},
"required": [
"$exists",
],
"type": "object",
},
{
"additionalProperties": false,
"properties": {
"$in": {
"items": {
"$ref": "#/properties/filter/anyOf/1/anyOf/0/anyOf/0/additionalProperties/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",
},
{
"items": {
"$ref": "#/properties/filter/anyOf/1/anyOf/0/anyOf/0",
"additionalProperties": {
"not": {},
},
"type": "array",
"propertyNames": {
"pattern": "^\\$",
},
"type": "object",
},
],
},
{
"$ref": "#/properties/filter/anyOf/1/anyOf/0/anyOf/0/additionalProperties/anyOf/0",
},
{
"additionalProperties": false,
"properties": {
@@ -118,58 +173,6 @@ describe('EntityContentBlueprint', () => {
],
"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",
},
],
},
],
@@ -73,20 +73,75 @@ describe('EntityContextMenuItemBlueprint', () => {
{
"anyOf": [
{
"type": [
"string",
"number",
"boolean",
],
"additionalProperties": {
"anyOf": [
{
"type": [
"string",
"number",
"boolean",
],
},
{
"additionalProperties": false,
"properties": {
"$exists": {
"type": "boolean",
},
},
"required": [
"$exists",
],
"type": "object",
},
{
"additionalProperties": false,
"properties": {
"$in": {
"items": {
"$ref": "#/properties/filter/anyOf/0/anyOf/0/additionalProperties/anyOf/0",
},
"type": "array",
},
},
"required": [
"$in",
],
"type": "object",
},
{
"additionalProperties": false,
"properties": {
"$contains": {
"$ref": "#/properties/filter",
},
},
"required": [
"$contains",
],
"type": "object",
},
],
},
"propertyNames": {
"pattern": "^(?!\\$).*$",
},
"type": "object",
},
{
"items": {
"$ref": "#/properties/filter/anyOf/0/anyOf/0",
"additionalProperties": {
"not": {},
},
"type": "array",
"propertyNames": {
"pattern": "^\\$",
},
"type": "object",
},
],
},
{
"$ref": "#/properties/filter/anyOf/0/anyOf/0/additionalProperties/anyOf/0",
},
{
"additionalProperties": false,
"properties": {
@@ -129,58 +184,6 @@ describe('EntityContextMenuItemBlueprint', () => {
],
"type": "object",
},
{
"additionalProperties": {
"anyOf": [
{
"$ref": "#/properties/filter/anyOf/0",
},
{
"additionalProperties": false,
"properties": {
"$exists": {
"type": "boolean",
},
},
"required": [
"$exists",
],
"type": "object",
},
{
"additionalProperties": false,
"properties": {
"$in": {
"items": {
"$ref": "#/properties/filter/anyOf/0/anyOf/0",
},
"type": "array",
},
},
"required": [
"$in",
],
"type": "object",
},
{
"additionalProperties": false,
"properties": {
"$contains": {
"$ref": "#/properties/filter",
},
},
"required": [
"$contains",
],
"type": "object",
},
],
},
"propertyNames": {
"pattern": "^(?!\\$).*$",
},
"type": "object",
},
],
},
},
@@ -16,84 +16,100 @@
import { z } from 'zod';
import { createEntityPredicateSchema } from './createEntityPredicateSchema';
import { EntityPredicate } from './types';
describe('createEntityPredicateSchema', () => {
const schema = createEntityPredicateSchema(z);
it.each([
'string',
'',
[],
1,
{ kind: 'component', 'spec.type': 'service' },
{ 'metadata.tags': { $in: ['java'] } },
{
$all: [
{ 'metadata.tags': { $contains: 'java' } },
{ 'metadata.tags': { $contains: 'spring' } },
],
},
{ 'metadata.tags': ['java', 'spring'] },
{ 'metadata.tags': { $in: ['go'] } },
{ 'metadata.tags.0': 'java' },
{ $not: { 'metadata.tags': { $in: ['java'] } } },
{
$any: [{ kind: 'component', 'spec.type': 'service' }, { kind: 'group' }],
},
{
relations: {
$contains: { type: 'ownedBy', targetRef: 'group:default/g' },
describe('valid predicates', () => {
const predicates: EntityPredicate[] = [
'string',
'',
1,
{ kind: 'component', 'spec.type': 'service' },
{ 'metadata.tags': { $in: ['java'] } },
{ 'metadata.tags': { $contains: 'java' } },
{
$all: [
{ 'metadata.tags': { $contains: 'java' } },
{ 'metadata.tags': { $contains: 'spring' } },
],
},
},
{
metadata: { $contains: { name: 'a' } },
},
{ kind: 'component', 'spec.type': { $in: ['service', 'website'] } },
{
$any: [
{
$all: [
{
kind: 'component',
'spec.type': { $in: ['service', 'website'] },
},
],
{ 'metadata.tags': { $in: ['go'] } },
{ 'metadata.tags.0': 'java' },
{ $not: { 'metadata.tags': { $in: ['java'] } } },
{
$any: [
{ kind: 'component', 'spec.type': 'service' },
{ kind: 'group' },
],
},
{
relations: {
$contains: { type: 'ownedBy', targetRef: 'group:default/g' },
},
{ $all: [{ kind: 'api', 'spec.type': 'grpc' }] },
],
},
{ kind: 'component', 'spec.type': { $in: ['service'] } },
{ 'spec.owner': { $exists: true } },
{ 'spec.owner': { $exists: false } },
{ 'spec.type': 'service' },
{ $not: { 'spec.type': 'service' } },
{
kind: 'component',
'metadata.annotations.github.com/repo': { $exists: true },
},
{ $all: [{ x: { $exists: true } }] },
{ $any: [{ x: { $exists: true } }] },
{ $not: { x: { $exists: true } } },
{ $not: { $all: [{ x: { $exists: true } }] } },
])('should accept valid predicate %j', predicate => {
expect(schema.parse(predicate)).toEqual(predicate);
},
{
metadata: { $contains: { name: 'a' } },
},
{ kind: 'component', 'spec.type': { $in: ['service', 'website'] } },
{
$any: [
{
$all: [
{
kind: 'component',
'spec.type': { $in: ['service', 'website'] },
},
],
},
{ $all: [{ kind: 'api', 'spec.type': 'grpc' }] },
],
},
{ kind: 'component', 'spec.type': { $in: ['service'] } },
{ 'spec.owner': { $exists: true } },
{ 'spec.owner': { $exists: false } },
{ 'spec.type': 'service' },
{ $not: { 'spec.type': 'service' } },
{
kind: 'component',
'metadata.annotations.github.com/repo': { $exists: true },
},
{ $all: [{ x: { $exists: true } }] },
{ $any: [{ x: { $exists: true } }] },
{ $not: { x: { $exists: true } } },
{ $not: { $all: [{ x: { $exists: true } }] } },
];
it.each(predicates)('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 },
{ $all: [{ x: { $unknown: true } }] },
{ $any: [{ x: { $unknown: true } }] },
{ $not: { x: { $unknown: true } } },
{ $not: { $all: [{ x: { $unknown: true } }] } },
{ $unknown: 'foo' },
])('should reject invalid predicate %j', predicate => {
const result = schema.safeParse(predicate);
expect(result.success).toBe(false);
describe('invalid predicates', () => {
const predicates: Array<
Exclude<EntityPredicate | unknown, EntityPredicate>
> = [
[],
['foo', 'bar'],
{ kind: { 1: 'foo' } },
{ kind: { foo: 'bar' } },
{ kind: { $unknown: 'foo' } },
{ kind: { $in: 'foo' } },
{ kind: { $in: [{ x: 'foo' }] } },
{ kind: { $in: [{ x: 'foo' }] } },
{ 'spec.type': null },
{ $all: [{ x: { $unknown: true } }] },
{ $any: [{ x: { $unknown: true } }] },
{ $not: { x: { $unknown: true } } },
{ $not: { $all: [{ x: { $unknown: true } }] } },
{ $unknown: 'foo' },
{ 'metadata.tags': ['foo', 'bar'] },
];
it.each(predicates)('should reject invalid predicate %j', predicate => {
const result = schema.safeParse(predicate);
expect(result.success).toBe(false);
});
});
});
@@ -14,33 +14,44 @@
* limitations under the License.
*/
import { EntityPredicate, EntityPredicateValue } from './types';
import {
EntityPredicate,
EntityPredicateExpression,
EntityPredicatePrimitive,
EntityPredicateValue,
} from './types';
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 comparableValueSchema = z.union([
primitiveSchema,
z.array(primitiveSchema),
]);
const primitiveSchema = z.union([
z.string(),
z.number(),
z.boolean(),
]) as ZodType<EntityPredicatePrimitive>;
// eslint-disable-next-line prefer-const
let valuePredicateSchema: ZodType<EntityPredicateValue>;
const expressionSchema = z.lazy(() =>
z.union([
z.record(z.string().regex(/^(?!\$).*$/), valuePredicateSchema),
z.record(z.string().regex(/^\$/), z.never()),
]),
) as ZodType<EntityPredicateExpression>;
const predicateSchema = z.lazy(() =>
z.union([
comparableValueSchema,
expressionSchema,
primitiveSchema,
z.object({ $all: z.array(predicateSchema) }),
z.object({ $any: z.array(predicateSchema) }),
z.object({ $not: predicateSchema }),
z.record(z.string().regex(/^(?!\$).*$/), valuePredicateSchema),
]),
) as ZodType<EntityPredicate>;
valuePredicateSchema = z.union([
comparableValueSchema,
primitiveSchema,
z.object({ $exists: z.boolean() }),
z.object({ $in: z.array(primitiveSchema) }),
z.object({ $contains: predicateSchema }),
@@ -34,7 +34,7 @@ export type EntityPredicateValue =
| EntityPredicatePrimitive
| { $exists: boolean }
| { $in: EntityPredicatePrimitive[] }
| { $contains: EntityPredicateExpression };
| { $contains: EntityPredicate };
/** @alpha */
export type EntityPredicatePrimitive = string | number | boolean;