implement relations contains

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2026-02-20 17:04:59 +01:00
parent c25c589f9c
commit bec9fe21d7
2 changed files with 256 additions and 8 deletions
@@ -45,18 +45,24 @@ describe.each(databases.eachSupportedId())(
kind: 'Component',
metadata: { name: 'service-a', namespace: 'default' },
spec: { type: 'service', lifecycle: 'production', owner: 'team-a' },
relations: [
{ type: 'ownedBy', targetRef: 'group:default/team-a' },
{ type: 'consumesApi', targetRef: 'api:default/api-d' },
],
});
await addEntity({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'service-b', namespace: 'default' },
spec: { type: 'service', lifecycle: 'experimental', owner: 'team-b' },
relations: [{ type: 'ownedBy', targetRef: 'group:default/team-b' }],
});
await addEntity({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'website-c', namespace: 'default' },
spec: { type: 'website', lifecycle: 'production', owner: 'team-a' },
relations: [{ type: 'ownedBy', targetRef: 'group:default/team-a' }],
});
await addEntity({
apiVersion: 'backstage.io/v1alpha1',
@@ -147,6 +153,12 @@ describe.each(databases.eachSupportedId())(
]);
});
it('throws on unsupported field operator', async () => {
await expect(query({ kind: { $bad: 'value' } as any })).rejects.toThrow(
/\$contains operator, but got/,
);
});
it('throws on top-level primitive', async () => {
await expect(query('bad-value' as any)).rejects.toThrow(
/top-level primitive values are not supported/,
@@ -252,13 +264,127 @@ describe.each(databases.eachSupportedId())(
).resolves.toEqual(['bare-e']);
});
it('throws on non-primitive $contains', async () => {
it('matches relations by type only (existence)', async () => {
await expect(
query({ relations: { $contains: { type: 'consumesApi' } } }),
).resolves.toEqual(['service-a']);
});
it('matches relations by type and exact targetRef', async () => {
await expect(
query({
'metadata.tags': { $contains: { nested: 'object' } } as any,
relations: {
$contains: {
type: 'ownedBy',
targetRef: 'group:default/team-a',
},
},
}),
).resolves.toEqual(['service-a', 'website-c']);
});
it('matches relations by type and targetRef case-insensitively', async () => {
await expect(
query({
relations: {
$contains: {
type: 'OwnedBy',
targetRef: 'Group:Default/Team-A',
},
},
}),
).resolves.toEqual(['service-a', 'website-c']);
});
it('handles mixed-case keys in $contains object', async () => {
await expect(
query({
relations: {
$contains: {
TyPe: 'ownedBy',
TargetRef: 'group:default/team-a',
},
},
} as any),
).resolves.toEqual(['service-a', 'website-c']);
});
it('matches relations by type and targetRef with $in', async () => {
await expect(
query({
relations: {
$contains: {
type: 'ownedBy',
targetRef: {
$in: ['group:default/team-a', 'group:default/team-b'],
},
},
},
}),
).resolves.toEqual(['service-a', 'service-b', 'website-c']);
});
it('throws on unsupported keys in $contains object', async () => {
await expect(
query({
relations: {
$contains: { type: 'ownedBy', badKey: 'value' },
} as any,
}),
).rejects.toThrow(/Unsupported key "badKey" in \$contains/);
});
it('throws on duplicate keys in $contains object', async () => {
await expect(
query({
relations: {
// JSON.parse allows duplicate keys, last one wins, but we
// simulate via an object that has both casings
$contains: JSON.parse('{"type": "ownedBy", "Type": "ownedBy"}'),
},
}),
).rejects.toThrow(/Duplicate key "Type" in \$contains/);
});
it('throws on $contains object without type', async () => {
await expect(
query({
relations: {
$contains: { targetRef: 'group:default/team-a' },
} as any,
}),
).rejects.toThrow(/requires a "type" string property/);
});
it('throws on empty $in array in targetRef', async () => {
await expect(
query({
relations: {
$contains: { type: 'ownedBy', targetRef: { $in: [] } },
},
}),
).rejects.toThrow(/Empty "\$in" array for \$contains on "relations"/);
});
it('throws on unsupported targetRef value', async () => {
await expect(
query({
relations: {
$contains: { type: 'ownedBy', targetRef: ['bad'] },
} as any,
}),
).rejects.toThrow(/Unsupported value in \$contains for "relations"/);
});
it('throws on object $contains for non-relations fields', async () => {
await expect(
query({
'metadata.tags': {
$contains: { type: 'something' },
} as any,
}),
).rejects.toThrow(
/Non primitive forms of the \$contains operator is not supported/,
/Object form of \$contains is not supported for field/,
);
});
});
@@ -191,17 +191,139 @@ function applyFieldCondition(options: {
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?
// Object form of $contains - currently only supports relation-style
// objects with "type" and optional "targetRef" keys.
//
// FROM: `{ "relations": { "$contains": { "type": "ownedBy", "targetRef": "group:default/team-a" } } }`
//
// TO: search for key = "relations.ownedby" AND value = "group:default/team-a"
if (isObject(target)) {
if (key === 'relations') {
return applyContainsRelation({
target,
targetQuery,
onEntityIdField,
knex,
});
}
throw new InputError(
`Object form of $contains is not supported for field "${key}"`,
);
}
const actual = JSON.stringify(target);
throw new InputError(
'Non primitive forms of the $contains operator is not supported',
`Unsupported $contains target for field "${key}": ${actual}`,
);
}
}
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 ${actual}`,
`Invalid filter predicate value for field "${key}": expected a primitive value, $exists, $in, $hasPrefix, or $contains operator, but got ${actual}`,
);
}
/**
* Handles expressions on the form
*
* ```
* {
* "relations": {
* "$contains": {
* "type": "ownedBy",
* "targetRef": "group:default/team-a"
* }
* }
* }
* ```
*
* which map onto the search table's special `relation.<type>: <targetRef>`
* syntax.
*
* Only the keys "type" and "targetRef" are supported. The "type" key is
* required. If "targetRef" is omitted, it becomes an existence check for any
* relation of that type. The "targetRef" value can be a string or an `$in`
* array.
*/
function applyContainsRelation(options: {
target: Record<string, unknown>;
targetQuery: Knex.QueryBuilder;
onEntityIdField: string;
knex: Knex;
}): Knex.QueryBuilder {
const { target: rawTarget, targetQuery, onEntityIdField, knex } = options;
function parseStringOrIn(value: unknown): string[] {
if (typeof value === 'string') {
return [value.toLocaleLowerCase('en-US')];
}
if (
isObject(value) &&
Object.keys(value).length === 1 &&
'$in' in value &&
Array.isArray(value.$in) &&
value.$in.every((v): v is string => typeof v === 'string')
) {
if (value.$in.length === 0) {
throw new InputError(
`Empty "$in" array for $contains on "relations" is not allowed`,
);
}
return value.$in.map(v => v.toLocaleLowerCase('en-US'));
}
const actual = JSON.stringify(value);
throw new InputError(
`Unsupported value in $contains for "relations": expected a string or { "$in": [strings] }, but got ${actual}`,
);
}
let type: string | undefined;
let targetRef: string[] | undefined;
for (const [rawKey, value] of Object.entries(rawTarget)) {
const key = rawKey.toLocaleLowerCase('en-US');
if (key === 'type') {
if (type !== undefined) {
throw new InputError(
`Duplicate key "${rawKey}" in $contains for "relations"`,
);
}
if (typeof value !== 'string') {
throw new InputError(
`The $contains operator for "relations" requires a "type" string property`,
);
}
type = value;
} else if (key === 'targetref') {
if (targetRef !== undefined) {
throw new InputError(
`Duplicate key "${rawKey}" in $contains for "relations"`,
);
}
targetRef = parseStringOrIn(value);
} else {
throw new InputError(
`Unsupported key "${rawKey}" in $contains for "relations". Only "type" and "targetRef" are supported`,
);
}
}
if (!type) {
throw new InputError(
`The $contains operator for "relations" requires a "type" string property`,
);
}
const matchQuery = knex<DbSearchRow>('search')
.select('search.entity_id')
.where({ key: `relations.${type.toLocaleLowerCase('en-US')}` });
if (targetRef) {
matchQuery.whereIn('value', targetRef);
}
return targetQuery.andWhere(onEntityIdField, 'in', matchQuery);
}