Allow negation through nesting a "not" object.
Signed-off-by: Joon Park <joonp@spotify.com>
This commit is contained in:
@@ -25,6 +25,7 @@ import { Entity, EntityRelationSpec } from '@backstage/catalog-model';
|
||||
export type EntityFilter =
|
||||
| { allOf: EntityFilter[] }
|
||||
| { anyOf: EntityFilter[] }
|
||||
| { not: EntityFilter }
|
||||
| EntitiesSearchFilter;
|
||||
|
||||
/**
|
||||
@@ -39,7 +40,7 @@ export type EntityPagination = {
|
||||
/**
|
||||
* Matches rows in the entities_search table.
|
||||
*/
|
||||
export type EntitiesValuesFilter = {
|
||||
export type EntitiesSearchFilter = {
|
||||
/**
|
||||
* The key to match on.
|
||||
*
|
||||
@@ -50,28 +51,12 @@ export type EntitiesValuesFilter = {
|
||||
/**
|
||||
* Match on plain equality of values.
|
||||
*
|
||||
* Match on
|
||||
* values that are equal to any of the given array items. Matches are always
|
||||
* case insensitive.
|
||||
* Match on values that are equal to any of the given array items. Matches are
|
||||
* always case insensitive.
|
||||
*/
|
||||
values: string[];
|
||||
|
||||
negate?: boolean;
|
||||
values?: string[];
|
||||
};
|
||||
|
||||
export type EntitiesKeyFilter = {
|
||||
/**
|
||||
* The key to match on.
|
||||
*
|
||||
* Matches are always case insensitive.
|
||||
*/
|
||||
key: string;
|
||||
|
||||
negate?: boolean;
|
||||
};
|
||||
|
||||
export type EntitiesSearchFilter = EntitiesValuesFilter | EntitiesKeyFilter;
|
||||
|
||||
export type PageInfo =
|
||||
| {
|
||||
hasNextPage: false;
|
||||
|
||||
@@ -282,7 +282,6 @@ describe('NextEntitiesCatalog', () => {
|
||||
|
||||
const testFilter = {
|
||||
key: 'spec.test',
|
||||
matchValueExists: true,
|
||||
};
|
||||
const request = { filter: testFilter };
|
||||
const { entities } = await catalog.entities(request);
|
||||
@@ -292,6 +291,41 @@ describe('NextEntitiesCatalog', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should return correct entity for negation filter',
|
||||
async databaseId => {
|
||||
const { knex } = await createDatabase(databaseId);
|
||||
const entity1: Entity = {
|
||||
apiVersion: 'a',
|
||||
kind: 'k',
|
||||
metadata: { name: 'one' },
|
||||
spec: {},
|
||||
};
|
||||
const entity2: Entity = {
|
||||
apiVersion: 'a',
|
||||
kind: 'k',
|
||||
metadata: { name: 'two' },
|
||||
spec: {
|
||||
test: 'test value',
|
||||
},
|
||||
};
|
||||
await addEntityToSearch(knex, entity1);
|
||||
await addEntityToSearch(knex, entity2);
|
||||
const catalog = new NextEntitiesCatalog(knex);
|
||||
|
||||
const testFilter = {
|
||||
not: {
|
||||
key: 'spec.test',
|
||||
},
|
||||
};
|
||||
const request = { filter: testFilter };
|
||||
const { entities } = await catalog.entities(request);
|
||||
|
||||
expect(entities.length).toBe(1);
|
||||
expect(entities[0]).toEqual(entity1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'should return correct entity for nested filter',
|
||||
async databaseId => {
|
||||
@@ -328,24 +362,27 @@ describe('NextEntitiesCatalog', () => {
|
||||
|
||||
const testFilter1 = {
|
||||
key: 'metadata.org',
|
||||
matchValueExists: true,
|
||||
matchValueIn: ['b'],
|
||||
values: ['b'],
|
||||
};
|
||||
const testFilter2 = {
|
||||
key: 'metadata.desc',
|
||||
matchValueExists: true,
|
||||
};
|
||||
const testFilter3 = {
|
||||
key: 'metadata.color',
|
||||
matchValueExists: true,
|
||||
matchValueIn: ['blue'],
|
||||
values: ['blue'],
|
||||
};
|
||||
const testFilter4 = {
|
||||
not: {
|
||||
key: 'metadata.color',
|
||||
values: ['red'],
|
||||
},
|
||||
};
|
||||
const request = {
|
||||
filter: {
|
||||
allOf: [
|
||||
testFilter1,
|
||||
{
|
||||
anyOf: [testFilter2, testFilter3],
|
||||
anyOf: [testFilter2, testFilter3, testFilter4],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -78,33 +78,30 @@ function stringifyPagination(input: { limit: number; offset: number }) {
|
||||
function addCondition(
|
||||
queryBuilder: Knex.QueryBuilder,
|
||||
db: Knex,
|
||||
{ key, matchValueIn, matchValueExists }: EntitiesSearchFilter,
|
||||
filter: EntitiesSearchFilter,
|
||||
negate: boolean = false,
|
||||
) {
|
||||
// NOTE(freben): This used to be a set of OUTER JOIN, which may seem to
|
||||
// make a lot of sense. However, it had abysmal performance on sqlite
|
||||
// when datasets grew large, so we're using IN instead.
|
||||
const matchQuery = db<DbSearchRow>('search')
|
||||
.select('entity_id')
|
||||
.where(function keyFilter() {
|
||||
this.andWhere({ key: key.toLowerCase() });
|
||||
if (matchValueExists !== false && matchValueIn) {
|
||||
if (matchValueIn.length === 1) {
|
||||
this.andWhere({ value: matchValueIn[0].toLowerCase() });
|
||||
} else if (matchValueIn.length > 1) {
|
||||
.where({ key: filter.key.toLowerCase() })
|
||||
.andWhere(function keyFilter() {
|
||||
if (filter.values) {
|
||||
if (filter.values.length === 1) {
|
||||
this.where({ value: filter.values[0].toLowerCase() });
|
||||
} else if (filter.values.length > 1) {
|
||||
this.andWhere(
|
||||
'value',
|
||||
'in',
|
||||
matchValueIn.map(v => v.toLowerCase()),
|
||||
filter.values.map(v => v.toLowerCase()),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Explicitly evaluate matchValueExists as a boolean since it may be undefined
|
||||
queryBuilder.andWhere(
|
||||
'entity_id',
|
||||
matchValueExists === false ? 'not in' : 'in',
|
||||
matchQuery,
|
||||
);
|
||||
queryBuilder.andWhere('entity_id', negate ? 'not in' : 'in', matchQuery);
|
||||
}
|
||||
|
||||
function isEntitiesSearchFilter(
|
||||
@@ -125,21 +122,32 @@ function isOrEntityFilter(
|
||||
return filter.hasOwnProperty('anyOf');
|
||||
}
|
||||
|
||||
function isNegationEntityFilter(
|
||||
filter: { not: EntityFilter } | EntityFilter,
|
||||
): filter is { not: EntityFilter } {
|
||||
return filter.hasOwnProperty('not');
|
||||
}
|
||||
|
||||
function parseFilter(
|
||||
filter: EntityFilter,
|
||||
query: Knex.QueryBuilder,
|
||||
db: Knex,
|
||||
negate: boolean = false,
|
||||
): Knex.QueryBuilder {
|
||||
if (isEntitiesSearchFilter(filter)) {
|
||||
return query.andWhere(function filterFunction() {
|
||||
addCondition(this, db, filter);
|
||||
addCondition(this, db, filter, negate);
|
||||
});
|
||||
}
|
||||
|
||||
if (isNegationEntityFilter(filter)) {
|
||||
return parseFilter(filter.not, query, db, true);
|
||||
}
|
||||
|
||||
if (isOrEntityFilter(filter)) {
|
||||
return query.andWhere(function filterFunction() {
|
||||
for (const subFilter of filter.anyOf ?? []) {
|
||||
this.orWhere(subQuery => parseFilter(subFilter, subQuery, db));
|
||||
this.orWhere(subQuery => parseFilter(subFilter, subQuery, db, negate));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -147,7 +155,7 @@ function parseFilter(
|
||||
if (isAndEntityFilter(filter)) {
|
||||
return query.andWhere(function filterFunction() {
|
||||
for (const subFilter of filter.allOf ?? []) {
|
||||
this.andWhere(subQuery => parseFilter(subFilter, subQuery, db));
|
||||
this.andWhere(subQuery => parseFilter(subFilter, subQuery, db, negate));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user