From 875ceaee9a016b2af1dac949bf4735623fcfd535 Mon Sep 17 00:00:00 2001 From: Jordan Snow Date: Thu, 29 Aug 2024 17:16:36 +0200 Subject: [PATCH 1/5] Negation key word to entity filter Signed-off-by: Jordan Snow --- .../alpha/filter/matchers/createHasMatcher.ts | 5 +++- .../alpha/filter/matchers/createIsMatcher.ts | 4 ++- .../filter/matchers/createKindMatcher.ts | 5 +++- .../filter/matchers/createTypeMatcher.ts | 7 ++--- .../filter/parseFilterExpression.test.ts | 14 ++++++++++ .../src/alpha/filter/parseFilterExpression.ts | 27 ++++++++++++++++--- 6 files changed, 52 insertions(+), 10 deletions(-) diff --git a/plugins/catalog/src/alpha/filter/matchers/createHasMatcher.ts b/plugins/catalog/src/alpha/filter/matchers/createHasMatcher.ts index 9fd393a561..a34834e40d 100644 --- a/plugins/catalog/src/alpha/filter/matchers/createHasMatcher.ts +++ b/plugins/catalog/src/alpha/filter/matchers/createHasMatcher.ts @@ -16,6 +16,7 @@ import { InputError } from '@backstage/errors'; import { EntityMatcherFn } from './types'; +import { Entity } from '@backstage/catalog-model'; const allowedMatchers: Record = { labels: entity => { @@ -32,6 +33,7 @@ const allowedMatchers: Record = { export function createHasMatcher( parameters: string[], onParseError: (error: Error) => void, + negation?: boolean, ): EntityMatcherFn { const matchers = parameters.flatMap(parameter => { const matcher = allowedMatchers[parameter.toLocaleLowerCase('en-US')]; @@ -47,6 +49,7 @@ export function createHasMatcher( return [matcher]; }); - return entity => + const isMatch = (entity: Entity) => matchers.length ? matchers.some(matcher => matcher(entity)) : true; + return negation ? entity => !isMatch(entity) : isMatch; } diff --git a/plugins/catalog/src/alpha/filter/matchers/createIsMatcher.ts b/plugins/catalog/src/alpha/filter/matchers/createIsMatcher.ts index 49a54349d1..5df4f2ffe8 100644 --- a/plugins/catalog/src/alpha/filter/matchers/createIsMatcher.ts +++ b/plugins/catalog/src/alpha/filter/matchers/createIsMatcher.ts @@ -28,6 +28,7 @@ const allowedMatchers: Record = { export function createIsMatcher( parameters: string[], onParseError: (error: Error) => void, + negation?: boolean, ): EntityMatcherFn { const matchers = parameters.flatMap(parameter => { const matcher = allowedMatchers[parameter.toLocaleLowerCase('en-US')]; @@ -43,6 +44,7 @@ export function createIsMatcher( return [matcher]; }); - return entity => + const isMatch = (entity: any) => matchers.length ? matchers.some(matcher => matcher(entity)) : true; + return negation ? entity => !isMatch(entity) : isMatch; } diff --git a/plugins/catalog/src/alpha/filter/matchers/createKindMatcher.ts b/plugins/catalog/src/alpha/filter/matchers/createKindMatcher.ts index 4f7c475ebe..6d7a3ff162 100644 --- a/plugins/catalog/src/alpha/filter/matchers/createKindMatcher.ts +++ b/plugins/catalog/src/alpha/filter/matchers/createKindMatcher.ts @@ -22,7 +22,10 @@ import { EntityMatcherFn } from './types'; export function createKindMatcher( parameters: string[], _onParseError: (error: Error) => void, + negation?: boolean, ): EntityMatcherFn { const items = parameters.map(p => p.toLocaleLowerCase('en-US')); - return entity => items.includes(entity.kind.toLocaleLowerCase('en-US')); + const isMatch = (entity: any) => + items.includes(entity.kind.toLocaleLowerCase('en-US')); + return negation ? entity => !isMatch(entity) : isMatch; } diff --git a/plugins/catalog/src/alpha/filter/matchers/createTypeMatcher.ts b/plugins/catalog/src/alpha/filter/matchers/createTypeMatcher.ts index 31f7a5a461..7bd7e56dfe 100644 --- a/plugins/catalog/src/alpha/filter/matchers/createTypeMatcher.ts +++ b/plugins/catalog/src/alpha/filter/matchers/createTypeMatcher.ts @@ -22,13 +22,14 @@ import { EntityMatcherFn } from './types'; export function createTypeMatcher( parameters: string[], _onParseError: (error: Error) => void, + negation?: boolean, ): EntityMatcherFn { const items = parameters.map(p => p.toLocaleLowerCase('en-US')); return entity => { const value = entity.spec?.type; - return ( + const isMatch = typeof value === 'string' && - items.includes(value.toLocaleLowerCase('en-US')) - ); + items.includes(value.toLocaleLowerCase('en-US')); + return negation ? !isMatch : isMatch; }; } diff --git a/plugins/catalog/src/alpha/filter/parseFilterExpression.test.ts b/plugins/catalog/src/alpha/filter/parseFilterExpression.test.ts index 616091739e..7ebadde13a 100644 --- a/plugins/catalog/src/alpha/filter/parseFilterExpression.test.ts +++ b/plugins/catalog/src/alpha/filter/parseFilterExpression.test.ts @@ -93,6 +93,20 @@ describe('parseFilterExpression', () => { ); }); + it('recognizes negation key', () => { + const component = { kind: 'Component' } as unknown as Entity; + expect(run('not:kind:user')(component)).toBe(true); + }); + + it('supports negation and affirmative expressions', () => { + const component = { + kind: 'Component', + spec: { type: 'service' }, + } as unknown as Entity; + expect(run('not:kind:user type:service')(component)).toBe(true); + expect(run('type:service not:kind:user')(component)).toBe(true); + }); + it('rejects unknown keys', () => { expect(() => run('unknown:foo')).toThrowErrorMatchingInlineSnapshot( `"'unknown' is not a valid filter expression key, expected one of 'kind','type','is','has'"`, diff --git a/plugins/catalog/src/alpha/filter/parseFilterExpression.ts b/plugins/catalog/src/alpha/filter/parseFilterExpression.ts index 4f809690f7..906fa45367 100644 --- a/plugins/catalog/src/alpha/filter/parseFilterExpression.ts +++ b/plugins/catalog/src/alpha/filter/parseFilterExpression.ts @@ -27,6 +27,7 @@ const rootMatcherFactories: Record< ( parameters: string[], onParseError: (error: Error) => void, + negation?: boolean, ) => EntityMatcherFn > = { kind: createKindMatcher, @@ -60,10 +61,14 @@ export function parseFilterExpression(expression: string): { const parts = splitFilterExpression(expression, e => expressionParseErrors.push(e), ); - + let negation = false; const matchers = parts.flatMap(part => { const factory = rootMatcherFactories[part.key]; if (!factory) { + if (isNegation(part.key)) { + negation = true; + return []; + } const known = Object.keys(rootMatcherFactories).map(m => `'${m}'`); expressionParseErrors.push( new InputError( @@ -73,9 +78,12 @@ export function parseFilterExpression(expression: string): { return []; } - const matcher = factory(part.parameters, e => - expressionParseErrors.push(e), + const matcher = factory( + part.parameters, + e => expressionParseErrors.push(e), + negation, ); + negation = false; return [matcher]; }); @@ -106,7 +114,11 @@ export function splitFilterExpression( const result = new Array<{ key: string; parameters: string[] }>(); for (const word of words) { - const match = word.match(/^([^:]+):(.+)$/); + let match = word.match(/^([^:]+):(.+)$/); + if (match && isNegation(match[1])) { + result.push({ key: 'not', parameters: [] }); + match = match[2].match(/^([^:]+):(.+)$/) || null; + } if (!match) { onParseError( new InputError( @@ -124,3 +136,10 @@ export function splitFilterExpression( return result; } + +function isNegation(parameter: string): boolean { + if (parameter.toLocaleLowerCase('en-US') === 'not') { + return true; + } + return false; +} From 64b6492f9e049b3202ccda7afc139ca90ed3d86c Mon Sep 17 00:00:00 2001 From: Jordan Snow Date: Wed, 2 Oct 2024 17:00:29 -0400 Subject: [PATCH 2/5] Changing how negation key word is processed Signed-off-by: Jordan Snow --- .../alpha/filter/matchers/createHasMatcher.ts | 5 +- .../alpha/filter/matchers/createIsMatcher.ts | 4 +- .../filter/matchers/createKindMatcher.ts | 5 +- .../filter/matchers/createTypeMatcher.ts | 7 ++- .../filter/parseFilterExpression.test.ts | 14 ++++-- .../src/alpha/filter/parseFilterExpression.ts | 49 +++++++------------ 6 files changed, 34 insertions(+), 50 deletions(-) diff --git a/plugins/catalog/src/alpha/filter/matchers/createHasMatcher.ts b/plugins/catalog/src/alpha/filter/matchers/createHasMatcher.ts index a34834e40d..9fd393a561 100644 --- a/plugins/catalog/src/alpha/filter/matchers/createHasMatcher.ts +++ b/plugins/catalog/src/alpha/filter/matchers/createHasMatcher.ts @@ -16,7 +16,6 @@ import { InputError } from '@backstage/errors'; import { EntityMatcherFn } from './types'; -import { Entity } from '@backstage/catalog-model'; const allowedMatchers: Record = { labels: entity => { @@ -33,7 +32,6 @@ const allowedMatchers: Record = { export function createHasMatcher( parameters: string[], onParseError: (error: Error) => void, - negation?: boolean, ): EntityMatcherFn { const matchers = parameters.flatMap(parameter => { const matcher = allowedMatchers[parameter.toLocaleLowerCase('en-US')]; @@ -49,7 +47,6 @@ export function createHasMatcher( return [matcher]; }); - const isMatch = (entity: Entity) => + return entity => matchers.length ? matchers.some(matcher => matcher(entity)) : true; - return negation ? entity => !isMatch(entity) : isMatch; } diff --git a/plugins/catalog/src/alpha/filter/matchers/createIsMatcher.ts b/plugins/catalog/src/alpha/filter/matchers/createIsMatcher.ts index 5df4f2ffe8..49a54349d1 100644 --- a/plugins/catalog/src/alpha/filter/matchers/createIsMatcher.ts +++ b/plugins/catalog/src/alpha/filter/matchers/createIsMatcher.ts @@ -28,7 +28,6 @@ const allowedMatchers: Record = { export function createIsMatcher( parameters: string[], onParseError: (error: Error) => void, - negation?: boolean, ): EntityMatcherFn { const matchers = parameters.flatMap(parameter => { const matcher = allowedMatchers[parameter.toLocaleLowerCase('en-US')]; @@ -44,7 +43,6 @@ export function createIsMatcher( return [matcher]; }); - const isMatch = (entity: any) => + return entity => matchers.length ? matchers.some(matcher => matcher(entity)) : true; - return negation ? entity => !isMatch(entity) : isMatch; } diff --git a/plugins/catalog/src/alpha/filter/matchers/createKindMatcher.ts b/plugins/catalog/src/alpha/filter/matchers/createKindMatcher.ts index 6d7a3ff162..4f7c475ebe 100644 --- a/plugins/catalog/src/alpha/filter/matchers/createKindMatcher.ts +++ b/plugins/catalog/src/alpha/filter/matchers/createKindMatcher.ts @@ -22,10 +22,7 @@ import { EntityMatcherFn } from './types'; export function createKindMatcher( parameters: string[], _onParseError: (error: Error) => void, - negation?: boolean, ): EntityMatcherFn { const items = parameters.map(p => p.toLocaleLowerCase('en-US')); - const isMatch = (entity: any) => - items.includes(entity.kind.toLocaleLowerCase('en-US')); - return negation ? entity => !isMatch(entity) : isMatch; + return entity => items.includes(entity.kind.toLocaleLowerCase('en-US')); } diff --git a/plugins/catalog/src/alpha/filter/matchers/createTypeMatcher.ts b/plugins/catalog/src/alpha/filter/matchers/createTypeMatcher.ts index 7bd7e56dfe..31f7a5a461 100644 --- a/plugins/catalog/src/alpha/filter/matchers/createTypeMatcher.ts +++ b/plugins/catalog/src/alpha/filter/matchers/createTypeMatcher.ts @@ -22,14 +22,13 @@ import { EntityMatcherFn } from './types'; export function createTypeMatcher( parameters: string[], _onParseError: (error: Error) => void, - negation?: boolean, ): EntityMatcherFn { const items = parameters.map(p => p.toLocaleLowerCase('en-US')); return entity => { const value = entity.spec?.type; - const isMatch = + return ( typeof value === 'string' && - items.includes(value.toLocaleLowerCase('en-US')); - return negation ? !isMatch : isMatch; + items.includes(value.toLocaleLowerCase('en-US')) + ); }; } diff --git a/plugins/catalog/src/alpha/filter/parseFilterExpression.test.ts b/plugins/catalog/src/alpha/filter/parseFilterExpression.test.ts index 7ebadde13a..647a4ef824 100644 --- a/plugins/catalog/src/alpha/filter/parseFilterExpression.test.ts +++ b/plugins/catalog/src/alpha/filter/parseFilterExpression.test.ts @@ -137,17 +137,21 @@ describe('splitFilterExpression', () => { expect(run('')).toEqual([]); expect(run(' ')).toEqual([]); expect(run('kind:component')).toEqual([ - { key: 'kind', parameters: ['component'] }, + { key: 'kind', parameters: ['component'], negation: false }, ]); expect(run('kind:component,user')).toEqual([ - { key: 'kind', parameters: ['component', 'user'] }, + { key: 'kind', parameters: ['component', 'user'], negation: false }, + ]); + expect(run('kind:component,user not:type:foo')).toEqual([ + { key: 'kind', parameters: ['component', 'user'], negation: false }, + { key: 'type', parameters: ['foo'], negation: true }, ]); expect(run('kind:component,user type:foo')).toEqual([ - { key: 'kind', parameters: ['component', 'user'] }, - { key: 'type', parameters: ['foo'] }, + { key: 'kind', parameters: ['component', 'user'], negation: false }, + { key: 'type', parameters: ['foo'], negation: false }, ]); expect(run('with:multiple:colons')).toEqual([ - { key: 'with', parameters: ['multiple:colons'] }, + { key: 'with', parameters: ['multiple:colons'], negation: false }, ]); }); diff --git a/plugins/catalog/src/alpha/filter/parseFilterExpression.ts b/plugins/catalog/src/alpha/filter/parseFilterExpression.ts index 906fa45367..71729c6a24 100644 --- a/plugins/catalog/src/alpha/filter/parseFilterExpression.ts +++ b/plugins/catalog/src/alpha/filter/parseFilterExpression.ts @@ -61,14 +61,10 @@ export function parseFilterExpression(expression: string): { const parts = splitFilterExpression(expression, e => expressionParseErrors.push(e), ); - let negation = false; const matchers = parts.flatMap(part => { const factory = rootMatcherFactories[part.key]; + const negation = part.negation; if (!factory) { - if (isNegation(part.key)) { - negation = true; - return []; - } const known = Object.keys(rootMatcherFactories).map(m => `'${m}'`); expressionParseErrors.push( new InputError( @@ -78,13 +74,11 @@ export function parseFilterExpression(expression: string): { return []; } - const matcher = factory( - part.parameters, - e => expressionParseErrors.push(e), - negation, + const matcher = factory(part.parameters, e => + expressionParseErrors.push(e), ); - negation = false; - return [matcher]; + + return [negation ? (entity: Entity) => !matcher(entity) : matcher]; }); const filterFn = (entity: Entity) => @@ -105,20 +99,20 @@ export function parseFilterExpression(expression: string): { export function splitFilterExpression( expression: string, onParseError: (error: Error) => void, -): Array<{ key: string; parameters: string[] }> { +): Array<{ key: string; parameters: string[]; negation: boolean }> { const words = expression .split(' ') .map(w => w.trim()) .filter(Boolean); - const result = new Array<{ key: string; parameters: string[] }>(); + const result = new Array<{ + key: string; + parameters: string[]; + negation: boolean; + }>(); for (const word of words) { - let match = word.match(/^([^:]+):(.+)$/); - if (match && isNegation(match[1])) { - result.push({ key: 'not', parameters: [] }); - match = match[2].match(/^([^:]+):(.+)$/) || null; - } + const match = word.match(/^(not:)?([^:]+):(.+)$/); if (!match) { onParseError( new InputError( @@ -127,19 +121,14 @@ export function splitFilterExpression( ); continue; } - - const key = match[1]; - const parameters = match[2].split(',').filter(Boolean); // silently ignore double commas - - result.push({ key, parameters }); + const key = match[2]; + const parameters = match[3].split(',').filter(Boolean); // silently ignore double commas + if (match[1]) { + result.push({ key, parameters, negation: true }); + } else { + result.push({ key, parameters, negation: false }); + } } return result; } - -function isNegation(parameter: string): boolean { - if (parameter.toLocaleLowerCase('en-US') === 'not') { - return true; - } - return false; -} From cec8e8c94da88842ff11373d94bc792133ec60aa Mon Sep 17 00:00:00 2001 From: Jordan Snow Date: Wed, 2 Oct 2024 17:12:33 -0400 Subject: [PATCH 3/5] Changeset Signed-off-by: Jordan Snow --- .changeset/green-cooks-sort.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/green-cooks-sort.md diff --git a/.changeset/green-cooks-sort.md b/.changeset/green-cooks-sort.md new file mode 100644 index 0000000000..51dd86ac2e --- /dev/null +++ b/.changeset/green-cooks-sort.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': minor +--- + +Adding negation keyword for entity filtering From 333793687619cd402c922e98bdb98f5c37773623 Mon Sep 17 00:00:00 2001 From: Jordan Snow Date: Thu, 3 Oct 2024 10:57:30 -0400 Subject: [PATCH 4/5] Simplifying result packaging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Signed-off-by: Jordan Snow --- plugins/catalog/src/alpha/filter/parseFilterExpression.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/plugins/catalog/src/alpha/filter/parseFilterExpression.ts b/plugins/catalog/src/alpha/filter/parseFilterExpression.ts index 71729c6a24..9775f16e51 100644 --- a/plugins/catalog/src/alpha/filter/parseFilterExpression.ts +++ b/plugins/catalog/src/alpha/filter/parseFilterExpression.ts @@ -123,11 +123,8 @@ export function splitFilterExpression( } const key = match[2]; const parameters = match[3].split(',').filter(Boolean); // silently ignore double commas - if (match[1]) { - result.push({ key, parameters, negation: true }); - } else { - result.push({ key, parameters, negation: false }); - } + const negation = Boolean(match[1]); + result.push({ key, parameters, negation }); } return result; From d516cc69beb13f3ddf15a71dc67897c8c6a7385e Mon Sep 17 00:00:00 2001 From: Jordan Snow Date: Thu, 3 Oct 2024 13:19:35 -0400 Subject: [PATCH 5/5] More testing Signed-off-by: Jordan Snow --- .../catalog/src/alpha/filter/parseFilterExpression.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/catalog/src/alpha/filter/parseFilterExpression.test.ts b/plugins/catalog/src/alpha/filter/parseFilterExpression.test.ts index 647a4ef824..eda5def76e 100644 --- a/plugins/catalog/src/alpha/filter/parseFilterExpression.test.ts +++ b/plugins/catalog/src/alpha/filter/parseFilterExpression.test.ts @@ -146,6 +146,10 @@ describe('splitFilterExpression', () => { { key: 'kind', parameters: ['component', 'user'], negation: false }, { key: 'type', parameters: ['foo'], negation: true }, ]); + expect(run('not:type:foo kind:component,user')).toEqual([ + { key: 'type', parameters: ['foo'], negation: true }, + { key: 'kind', parameters: ['component', 'user'], negation: false }, + ]); expect(run('kind:component,user type:foo')).toEqual([ { key: 'kind', parameters: ['component', 'user'], negation: false }, { key: 'type', parameters: ['foo'], negation: false },