Allow negation through nesting a "not" object.

Signed-off-by: Joon Park <joonp@spotify.com>
This commit is contained in:
Joon Park
2021-11-09 15:11:07 +00:00
parent 154c77bf7f
commit 5f1de0fc60
3 changed files with 73 additions and 43 deletions
+5 -20
View File
@@ -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));
}
});
}