add primitive $contains support
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
+183
-145
@@ -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<DbFinalEntitiesRow>('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<DbFinalEntitiesRow>('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']);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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<DbSearchRow>('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}`,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user