diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts index e48be8169a..7f07cad11e 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.test.ts @@ -67,7 +67,11 @@ describe.each(databases.eachSupportedId())( await addEntity({ apiVersion: 'backstage.io/v1alpha1', kind: 'Component', - metadata: { name: 'bare-e', namespace: 'default' }, + metadata: { + name: 'bare-e', + namespace: 'default', + tags: ['java', 'backend'], + }, spec: {}, }); }); @@ -116,151 +120,185 @@ describe.each(databases.eachSupportedId())( ); } - it('matches everything for empty field expression', async () => { - await expect(query({})).resolves.toEqual([ - 'api-d', - 'bare-e', - 'service-a', - 'service-b', - 'website-c', - ]); - }); - - it('matches nothing for {$not: {}}', async () => { - await expect(query({ $not: {} })).resolves.toEqual([]); - }); - - it('filters by direct field value', async () => { - await expect(query({ kind: 'component' })).resolves.toEqual([ - 'bare-e', - 'service-a', - 'service-b', - 'website-c', - ]); - }); - - it('filters by exact spec field value', async () => { - await expect(query({ 'spec.type': 'service' })).resolves.toEqual([ - 'service-a', - 'service-b', - ]); - }); - - it('filters with $all', async () => { - await expect( - query({ - $all: [{ kind: 'component' }, { 'spec.type': 'service' }], - }), - ).resolves.toEqual(['service-a', 'service-b']); - }); - - it('matches everything for empty $all', async () => { - await expect(query({ $all: [] })).resolves.toEqual([ - 'api-d', - 'bare-e', - 'service-a', - 'service-b', - 'website-c', - ]); - }); - - it('filters with $any', async () => { - await expect( - query({ - $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], - }), - ).resolves.toEqual(['service-a', 'service-b', 'website-c']); - }); - - it('returns nothing for empty $any', async () => { - await expect(query({ $any: [] })).resolves.toEqual([]); - }); - - it('filters with $not', async () => { - await expect( - query({ - $all: [ - { kind: 'component' }, - { $not: { 'spec.lifecycle': 'experimental' } }, - ], - }), - ).resolves.toEqual(['bare-e', 'service-a', 'website-c']); - }); - - it('filters with $in', async () => { - await expect( - query({ 'spec.type': { $in: ['service', 'openapi'] } }), - ).resolves.toEqual(['api-d', 'service-a', 'service-b']); - }); - - it('filters with $exists true', async () => { - await expect( - query({ - $all: [{ kind: 'component' }, { 'spec.owner': { $exists: true } }], - }), - ).resolves.toEqual(['service-a', 'service-b', 'website-c']); - }); - - it('filters with $exists false', async () => { - await expect( - query({ - $all: [{ kind: 'component' }, { 'spec.owner': { $exists: false } }], - }), - ).resolves.toEqual(['bare-e']); - }); - - it('filters with $hasPrefix', async () => { - await expect( - query({ 'metadata.name': { $hasPrefix: 'service' } }), - ).resolves.toEqual(['service-a', 'service-b']); - }); - - it('filters with $hasPrefix case-insensitively', async () => { - await expect( - query({ 'metadata.name': { $hasPrefix: 'Service' } }), - ).resolves.toEqual(['service-a', 'service-b']); - }); - - it('handles nested logical operators', async () => { - await expect( - query({ - $all: [ - { kind: 'component' }, - { - $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], - }, - { $not: { 'spec.lifecycle': 'experimental' } }, - ], - }), - ).resolves.toEqual(['service-a', 'website-c']); - }); - - it('throws on $contains operator', async () => { - await expect( - query({ 'metadata.name': { $contains: 'service' } as any }), - ).rejects.toThrow(/\$contains operator is not supported/); - }); - - it('throws on top-level primitive', async () => { - await expect(query('bad-value' as any)).rejects.toThrow( - /top-level primitive values are not supported/, - ); - }); - - it('combines filter and query independently', async () => { - const q = - knex('final_entities').whereNotNull('final_entity'); - applyEntityFilterToQuery({ - filter: { key: 'kind', values: ['component'] }, - query: { 'spec.type': 'service' }, - targetQuery: q, - onEntityIdField: 'final_entities.entity_id', - knex, + describe('field expressions', () => { + it('matches everything for empty field expression', async () => { + await expect(query({})).resolves.toEqual([ + 'api-d', + 'bare-e', + 'service-a', + 'service-b', + 'website-c', + ]); + }); + + it('filters by direct field value', async () => { + await expect(query({ kind: 'component' })).resolves.toEqual([ + 'bare-e', + 'service-a', + 'service-b', + 'website-c', + ]); + }); + + it('filters by exact spec field value', async () => { + await expect(query({ 'spec.type': 'service' })).resolves.toEqual([ + 'service-a', + 'service-b', + ]); + }); + + it('throws on top-level primitive', async () => { + await expect(query('bad-value' as any)).rejects.toThrow( + /top-level primitive values are not supported/, + ); + }); + }); + + describe('$all', () => { + it('filters with $all', async () => { + await expect( + query({ + $all: [{ kind: 'component' }, { 'spec.type': 'service' }], + }), + ).resolves.toEqual(['service-a', 'service-b']); + }); + + it('matches everything for empty $all', async () => { + await expect(query({ $all: [] })).resolves.toEqual([ + 'api-d', + 'bare-e', + 'service-a', + 'service-b', + 'website-c', + ]); + }); + }); + + describe('$any', () => { + it('filters with $any', async () => { + await expect( + query({ + $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], + }), + ).resolves.toEqual(['service-a', 'service-b', 'website-c']); + }); + + it('returns nothing for empty $any', async () => { + await expect(query({ $any: [] })).resolves.toEqual([]); + }); + }); + + describe('$not', () => { + it('filters with $not', async () => { + await expect( + query({ + $all: [ + { kind: 'component' }, + { $not: { 'spec.lifecycle': 'experimental' } }, + ], + }), + ).resolves.toEqual(['bare-e', 'service-a', 'website-c']); + }); + + it('matches nothing for {$not: {}}', async () => { + await expect(query({ $not: {} })).resolves.toEqual([]); + }); + }); + + describe('$in', () => { + it('filters with $in', async () => { + await expect( + query({ 'spec.type': { $in: ['service', 'openapi'] } }), + ).resolves.toEqual(['api-d', 'service-a', 'service-b']); + }); + }); + + describe('$exists', () => { + it('filters with $exists true', async () => { + await expect( + query({ + $all: [{ kind: 'component' }, { 'spec.owner': { $exists: true } }], + }), + ).resolves.toEqual(['service-a', 'service-b', 'website-c']); + }); + + it('filters with $exists false', async () => { + await expect( + query({ + $all: [{ kind: 'component' }, { 'spec.owner': { $exists: false } }], + }), + ).resolves.toEqual(['bare-e']); + }); + }); + + describe('$hasPrefix', () => { + it('filters with $hasPrefix', async () => { + await expect( + query({ 'metadata.name': { $hasPrefix: 'service' } }), + ).resolves.toEqual(['service-a', 'service-b']); + }); + + it('filters with $hasPrefix case-insensitively', async () => { + await expect( + query({ 'metadata.name': { $hasPrefix: 'Service' } }), + ).resolves.toEqual(['service-a', 'service-b']); + }); + }); + + describe('$contains', () => { + it('filters with primitive $contains on array fields', async () => { + await expect( + query({ 'metadata.tags': { $contains: 'java' } }), + ).resolves.toEqual(['bare-e']); + }); + + it('throws on non-primitive $contains', async () => { + await expect( + query({ + 'metadata.tags': { $contains: { nested: 'object' } } as any, + }), + ).rejects.toThrow( + /Non primitive forms of the \$contains operator is not supported/, + ); + }); + }); + + describe('nested operators', () => { + it('handles nested logical operators', async () => { + await expect( + query({ + $all: [ + { kind: 'component' }, + { + $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], + }, + { $not: { 'spec.lifecycle': 'experimental' } }, + ], + }), + ).resolves.toEqual(['service-a', 'website-c']); + }); + }); + + describe('combined filter and query', () => { + it('combines filter and query independently', async () => { + const q = + knex('final_entities').whereNotNull( + 'final_entity', + ); + applyEntityFilterToQuery({ + filter: { key: 'kind', values: ['component'] }, + query: { 'spec.type': 'service' }, + targetQuery: q, + onEntityIdField: 'final_entities.entity_id', + knex, + }); + const result = await q.then(rows => + rows + .map(row => JSON.parse(row.final_entity!).metadata.name) + .toSorted(), + ); + expect(result).toEqual(['service-a', 'service-b']); }); - const result = await q.then(rows => - rows.map(row => JSON.parse(row.final_entity!).metadata.name).toSorted(), - ); - expect(result).toEqual(['service-a', 'service-b']); }); }, ); diff --git a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts index e7cf152184..27701a90ef 100644 --- a/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts +++ b/plugins/catalog-backend/src/service/request/applyPredicateEntityFilterToQuery.ts @@ -45,11 +45,9 @@ export function applyPredicateEntityFilterToQuery(options: { // We do not support top-level primitives; all matching happens through objects if (!isObject(filter)) { + const actual = JSON.stringify(filter); throw new InputError( - `Invalid filter predicate: top-level primitive values are not supported. ` + - `Wrap the value in a field expression, e.g. { "kind": ${JSON.stringify( - filter, - )} }`, + `Invalid filter predicate: top-level primitive values are not supported. Wrap the value in a field expression, e.g. { "kind": ${actual} }`, ); } @@ -171,14 +169,39 @@ function applyFieldCondition(options: { } if ('$contains' in value) { - // TODO(freben): Implement this, AT LEAST for some special cases such as tags and relations. - throw new InputError('The $contains operator is not supported'); + const target = value.$contains; + + // If the target is a primitive, match on the special array syntax. + // + // FROM: `{ "a": { "$contains": "b" } }` + // + // TO: `{ "a.b": "true" }` + // + // The search table does not actually show us that "a" was an array to + // begin with, so this can mistakenly also match on an object that had a + // "b" key with a true value. We'll consider that an acceptable tradeoff + // though. + if (isPrimitive(target)) { + const matchQuery = knex('search') + .select('search.entity_id') + .where({ + key: `${key}.${String(target).toLocaleLowerCase('en-US')}`, + value: 'true', + }); + 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? + throw new InputError( + 'Non primitive forms of the $contains operator is not supported', + ); } } + 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 ${JSON.stringify( - value, - )}`, + `Invalid filter predicate value for field "${key}": expected a primitive value, $exists, $in, or $hasPrefix operator, but got ${actual}`, ); }