add primitive $contains support

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2026-02-20 15:29:39 +01:00
parent c7c0dd5ed2
commit c25c589f9c
2 changed files with 215 additions and 154 deletions
@@ -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}`,
);
}