diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts index 7f07cad11e..cea9d961f7 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts @@ -45,18 +45,24 @@ describe.each(databases.eachSupportedId())( kind: 'Component', metadata: { name: 'service-a', namespace: 'default' }, spec: { type: 'service', lifecycle: 'production', owner: 'team-a' }, + relations: [ + { type: 'ownedBy', targetRef: 'group:default/team-a' }, + { type: 'consumesApi', targetRef: 'api:default/api-d' }, + ], }); await addEntity({ apiVersion: 'backstage.io/v1alpha1', kind: 'Component', metadata: { name: 'service-b', namespace: 'default' }, spec: { type: 'service', lifecycle: 'experimental', owner: 'team-b' }, + relations: [{ type: 'ownedBy', targetRef: 'group:default/team-b' }], }); await addEntity({ apiVersion: 'backstage.io/v1alpha1', kind: 'Component', metadata: { name: 'website-c', namespace: 'default' }, spec: { type: 'website', lifecycle: 'production', owner: 'team-a' }, + relations: [{ type: 'ownedBy', targetRef: 'group:default/team-a' }], }); await addEntity({ apiVersion: 'backstage.io/v1alpha1', @@ -147,6 +153,12 @@ describe.each(databases.eachSupportedId())( ]); }); + it('throws on unsupported field operator', async () => { + await expect(query({ kind: { $bad: 'value' } as any })).rejects.toThrow( + /\$contains operator, but got/, + ); + }); + it('throws on top-level primitive', async () => { await expect(query('bad-value' as any)).rejects.toThrow( /top-level primitive values are not supported/, @@ -252,13 +264,127 @@ describe.each(databases.eachSupportedId())( ).resolves.toEqual(['bare-e']); }); - it('throws on non-primitive $contains', async () => { + it('matches relations by type only (existence)', async () => { + await expect( + query({ relations: { $contains: { type: 'consumesApi' } } }), + ).resolves.toEqual(['service-a']); + }); + + it('matches relations by type and exact targetRef', async () => { await expect( query({ - 'metadata.tags': { $contains: { nested: 'object' } } as any, + relations: { + $contains: { + type: 'ownedBy', + targetRef: 'group:default/team-a', + }, + }, + }), + ).resolves.toEqual(['service-a', 'website-c']); + }); + + it('matches relations by type and targetRef case-insensitively', async () => { + await expect( + query({ + relations: { + $contains: { + type: 'OwnedBy', + targetRef: 'Group:Default/Team-A', + }, + }, + }), + ).resolves.toEqual(['service-a', 'website-c']); + }); + + it('handles mixed-case keys in $contains object', async () => { + await expect( + query({ + relations: { + $contains: { + TyPe: 'ownedBy', + TargetRef: 'group:default/team-a', + }, + }, + } as any), + ).resolves.toEqual(['service-a', 'website-c']); + }); + + it('matches relations by type and targetRef with $in', async () => { + await expect( + query({ + relations: { + $contains: { + type: 'ownedBy', + targetRef: { + $in: ['group:default/team-a', 'group:default/team-b'], + }, + }, + }, + }), + ).resolves.toEqual(['service-a', 'service-b', 'website-c']); + }); + + it('throws on unsupported keys in $contains object', async () => { + await expect( + query({ + relations: { + $contains: { type: 'ownedBy', badKey: 'value' }, + } as any, + }), + ).rejects.toThrow(/Unsupported key "badKey" in \$contains/); + }); + + it('throws on duplicate keys in $contains object', async () => { + await expect( + query({ + relations: { + // JSON.parse allows duplicate keys, last one wins, but we + // simulate via an object that has both casings + $contains: JSON.parse('{"type": "ownedBy", "Type": "ownedBy"}'), + }, + }), + ).rejects.toThrow(/Duplicate key "Type" in \$contains/); + }); + + it('throws on $contains object without type', async () => { + await expect( + query({ + relations: { + $contains: { targetRef: 'group:default/team-a' }, + } as any, + }), + ).rejects.toThrow(/requires a "type" string property/); + }); + + it('throws on empty $in array in targetRef', async () => { + await expect( + query({ + relations: { + $contains: { type: 'ownedBy', targetRef: { $in: [] } }, + }, + }), + ).rejects.toThrow(/Empty "\$in" array for \$contains on "relations"/); + }); + + it('throws on unsupported targetRef value', async () => { + await expect( + query({ + relations: { + $contains: { type: 'ownedBy', targetRef: ['bad'] }, + } as any, + }), + ).rejects.toThrow(/Unsupported value in \$contains for "relations"/); + }); + + it('throws on object $contains for non-relations fields', async () => { + await expect( + query({ + 'metadata.tags': { + $contains: { type: 'something' }, + } as any, }), ).rejects.toThrow( - /Non primitive forms of the \$contains operator is not supported/, + /Object form of \$contains is not supported for field/, ); }); }); diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index 27701a90ef..a44f878abd 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -191,17 +191,139 @@ function applyFieldCondition(options: { return targetQuery.andWhere(onEntityIdField, 'in', matchQuery); } - // TODO(freben): Implement this for more use cases - for example simple - // objects (resulting in a $and and appending key strings) and maybe - // a subset of relations? + // Object form of $contains - currently only supports relation-style + // objects with "type" and optional "targetRef" keys. + // + // FROM: `{ "relations": { "$contains": { "type": "ownedBy", "targetRef": "group:default/team-a" } } }` + // + // TO: search for key = "relations.ownedby" AND value = "group:default/team-a" + if (isObject(target)) { + if (key === 'relations') { + return applyContainsRelation({ + target, + targetQuery, + onEntityIdField, + knex, + }); + } + + throw new InputError( + `Object form of $contains is not supported for field "${key}"`, + ); + } + + const actual = JSON.stringify(target); throw new InputError( - 'Non primitive forms of the $contains operator is not supported', + `Unsupported $contains target for field "${key}": ${actual}`, ); } } const actual = JSON.stringify(value); throw new InputError( - `Invalid filter predicate value for field "${key}": expected a primitive value, $exists, $in, or $hasPrefix operator, but got ${actual}`, + `Invalid filter predicate value for field "${key}": expected a primitive value, $exists, $in, $hasPrefix, or $contains operator, but got ${actual}`, ); } + +/** + * Handles expressions on the form + * + * ``` + * { + * "relations": { + * "$contains": { + * "type": "ownedBy", + * "targetRef": "group:default/team-a" + * } + * } + * } + * ``` + * + * which map onto the search table's special `relation.: ` + * syntax. + * + * Only the keys "type" and "targetRef" are supported. The "type" key is + * required. If "targetRef" is omitted, it becomes an existence check for any + * relation of that type. The "targetRef" value can be a string or an `$in` + * array. + */ +function applyContainsRelation(options: { + target: Record; + targetQuery: Knex.QueryBuilder; + onEntityIdField: string; + knex: Knex; +}): Knex.QueryBuilder { + const { target: rawTarget, targetQuery, onEntityIdField, knex } = options; + + function parseStringOrIn(value: unknown): string[] { + if (typeof value === 'string') { + return [value.toLocaleLowerCase('en-US')]; + } + if ( + isObject(value) && + Object.keys(value).length === 1 && + '$in' in value && + Array.isArray(value.$in) && + value.$in.every((v): v is string => typeof v === 'string') + ) { + if (value.$in.length === 0) { + throw new InputError( + `Empty "$in" array for $contains on "relations" is not allowed`, + ); + } + return value.$in.map(v => v.toLocaleLowerCase('en-US')); + } + const actual = JSON.stringify(value); + throw new InputError( + `Unsupported value in $contains for "relations": expected a string or { "$in": [strings] }, but got ${actual}`, + ); + } + + let type: string | undefined; + let targetRef: string[] | undefined; + + for (const [rawKey, value] of Object.entries(rawTarget)) { + const key = rawKey.toLocaleLowerCase('en-US'); + + if (key === 'type') { + if (type !== undefined) { + throw new InputError( + `Duplicate key "${rawKey}" in $contains for "relations"`, + ); + } + if (typeof value !== 'string') { + throw new InputError( + `The $contains operator for "relations" requires a "type" string property`, + ); + } + type = value; + } else if (key === 'targetref') { + if (targetRef !== undefined) { + throw new InputError( + `Duplicate key "${rawKey}" in $contains for "relations"`, + ); + } + targetRef = parseStringOrIn(value); + } else { + throw new InputError( + `Unsupported key "${rawKey}" in $contains for "relations". Only "type" and "targetRef" are supported`, + ); + } + } + + if (!type) { + throw new InputError( + `The $contains operator for "relations" requires a "type" string property`, + ); + } + + const matchQuery = knex('search') + .select('search.entity_id') + .where({ key: `relations.${type.toLocaleLowerCase('en-US')}` }); + + if (targetRef) { + matchQuery.whereIn('value', targetRef); + } + + return targetQuery.andWhere(onEntityIdField, 'in', matchQuery); +}