+220
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { TestDatabases } from '@backstage/backend-test-utils';
|
||||
import { applyEntityFilterToQuery } from './applyEntityFilterToQuery';
|
||||
import {
|
||||
DbFinalEntitiesRow,
|
||||
DbRefreshStateRow,
|
||||
DbSearchRow,
|
||||
} from '../../database/tables';
|
||||
import { Knex } from 'knex';
|
||||
import { applyDatabaseMigrations } from '../../database/migrations';
|
||||
import { FilterPredicate } from '@backstage/filter-predicates';
|
||||
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { buildEntitySearch } from '../../database/operations/stitcher/buildEntitySearch';
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
describe.each(databases.eachSupportedId())(
|
||||
'applyEntityFilterToQuery with predicate queries, %p',
|
||||
databaseId => {
|
||||
let knex: Knex;
|
||||
|
||||
beforeAll(async () => {
|
||||
knex = await databases.init(databaseId);
|
||||
await applyDatabaseMigrations(knex);
|
||||
await addEntity({
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: { name: 'service-a', namespace: 'default' },
|
||||
spec: { type: 'service', lifecycle: 'production', owner: 'team-a' },
|
||||
});
|
||||
await addEntity({
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: { name: 'service-b', namespace: 'default' },
|
||||
spec: { type: 'service', lifecycle: 'experimental', owner: 'team-b' },
|
||||
});
|
||||
await addEntity({
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: { name: 'website-c', namespace: 'default' },
|
||||
spec: { type: 'website', lifecycle: 'production', owner: 'team-a' },
|
||||
});
|
||||
await addEntity({
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'API',
|
||||
metadata: { name: 'api-d', namespace: 'default' },
|
||||
spec: { type: 'openapi', lifecycle: 'production' },
|
||||
});
|
||||
await addEntity({
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: { name: 'bare-e', namespace: 'default' },
|
||||
spec: {},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
knex.destroy();
|
||||
});
|
||||
|
||||
async function addEntity(entity: Entity) {
|
||||
const id = uuid();
|
||||
const entityRef = stringifyEntityRef(entity);
|
||||
const entityJson = JSON.stringify(entity);
|
||||
|
||||
await knex<DbRefreshStateRow>('refresh_state').insert({
|
||||
entity_id: id,
|
||||
entity_ref: entityRef,
|
||||
unprocessed_entity: entityJson,
|
||||
errors: '[]',
|
||||
next_update_at: '2031-01-01 23:00:00',
|
||||
last_discovery_at: '2021-04-01 13:37:00',
|
||||
});
|
||||
|
||||
await knex<DbFinalEntitiesRow>('final_entities').insert({
|
||||
entity_id: id,
|
||||
entity_ref: entityRef,
|
||||
final_entity: entityJson,
|
||||
hash: 'h',
|
||||
stitch_ticket: '',
|
||||
});
|
||||
|
||||
const search = await buildEntitySearch(id, entity);
|
||||
await knex<DbSearchRow>('search').insert(search);
|
||||
}
|
||||
|
||||
async function query(predicate: FilterPredicate): Promise<string[]> {
|
||||
const q =
|
||||
knex<DbFinalEntitiesRow>('final_entities').whereNotNull('final_entity');
|
||||
applyEntityFilterToQuery({
|
||||
query: predicate,
|
||||
targetQuery: q,
|
||||
onEntityIdField: 'final_entities.entity_id',
|
||||
knex,
|
||||
});
|
||||
return await q.then(rows =>
|
||||
rows.map(row => JSON.parse(row.final_entity!).metadata.name).toSorted(),
|
||||
);
|
||||
}
|
||||
|
||||
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('filters with $any', async () => {
|
||||
await expect(
|
||||
query({
|
||||
$any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }],
|
||||
}),
|
||||
).resolves.toEqual(['service-a', 'service-b', 'website-c']);
|
||||
});
|
||||
|
||||
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('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 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,
|
||||
});
|
||||
const result = await q.then(rows =>
|
||||
rows.map(row => JSON.parse(row.final_entity!).metadata.name).toSorted(),
|
||||
);
|
||||
expect(result).toEqual(['service-a', 'service-b']);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { parseEntityQuery } from './parseEntityQuery';
|
||||
import { encodeCursor } from '../util';
|
||||
import { Cursor } from '../../catalog/types';
|
||||
|
||||
describe('parseEntityQuery', () => {
|
||||
describe('initial request', () => {
|
||||
it('returns empty result for empty request', () => {
|
||||
const result = parseEntityQuery({});
|
||||
expect(result).toEqual({
|
||||
query: undefined,
|
||||
orderFields: undefined,
|
||||
fullTextFilter: undefined,
|
||||
fields: undefined,
|
||||
limit: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a simple query predicate', () => {
|
||||
const query = { kind: 'component' };
|
||||
const result = parseEntityQuery({ query });
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ query: { kind: 'component' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('parses a complex query with $all, $any, $not', () => {
|
||||
const query = {
|
||||
$all: [
|
||||
{ kind: 'component' },
|
||||
{ $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }] },
|
||||
{ $not: { 'spec.lifecycle': 'experimental' } },
|
||||
],
|
||||
};
|
||||
const result = parseEntityQuery({ query });
|
||||
expect(result).toEqual(expect.objectContaining({ query }));
|
||||
});
|
||||
|
||||
it('parses query with $exists operator', () => {
|
||||
const query = { 'metadata.labels.team': { $exists: true } };
|
||||
const result = parseEntityQuery({ query });
|
||||
expect(result).toEqual(expect.objectContaining({ query }));
|
||||
});
|
||||
|
||||
it('parses query with $in operator', () => {
|
||||
const query = { kind: { $in: ['component', 'api'] } };
|
||||
const result = parseEntityQuery({ query });
|
||||
expect(result).toEqual(expect.objectContaining({ query }));
|
||||
});
|
||||
|
||||
it('passes through limit', () => {
|
||||
const result = parseEntityQuery({ limit: 50 });
|
||||
expect(result).toEqual(expect.objectContaining({ limit: 50 }));
|
||||
});
|
||||
|
||||
it('passes through fields', () => {
|
||||
const result = parseEntityQuery({
|
||||
fields: ['metadata.name', 'kind'],
|
||||
});
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ fields: ['metadata.name', 'kind'] }),
|
||||
);
|
||||
});
|
||||
|
||||
it('parses orderField into orderFields', () => {
|
||||
const result = parseEntityQuery({
|
||||
orderField: ['metadata.name,asc', 'metadata.namespace,desc'],
|
||||
});
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
orderFields: [
|
||||
{ field: 'metadata.name', order: 'asc' },
|
||||
{ field: 'metadata.namespace', order: 'desc' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('parses fullTextFilter', () => {
|
||||
const result = parseEntityQuery({
|
||||
fullTextFilter: { term: 'search term', fields: ['metadata.name'] },
|
||||
});
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
fullTextFilter: {
|
||||
term: 'search term',
|
||||
fields: ['metadata.name'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults fullTextFilter term to empty string when missing', () => {
|
||||
const result = parseEntityQuery({
|
||||
fullTextFilter: {} as any,
|
||||
});
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
fullTextFilter: { term: '', fields: undefined },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on invalid query predicate', () => {
|
||||
expect(() =>
|
||||
parseEntityQuery({ query: { $invalid: true } as any }),
|
||||
).toThrow(/Invalid query/);
|
||||
});
|
||||
|
||||
it('throws when query root is not an object', () => {
|
||||
expect(() => parseEntityQuery({ query: 'bad' as any })).toThrow(
|
||||
/Query must be an object/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on invalid orderField order value', () => {
|
||||
expect(() =>
|
||||
parseEntityQuery({ orderField: ['metadata.name,sideways'] }),
|
||||
).toThrow(/Invalid order field order/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cursor request', () => {
|
||||
function makeCursor(partial: Partial<Cursor>): string {
|
||||
const full: Cursor = {
|
||||
orderFields: [],
|
||||
orderFieldValues: [],
|
||||
isPrevious: false,
|
||||
...partial,
|
||||
};
|
||||
return encodeCursor(full);
|
||||
}
|
||||
|
||||
it('decodes a valid cursor', () => {
|
||||
const cursor = makeCursor({
|
||||
orderFields: [{ field: 'metadata.name', order: 'asc' }],
|
||||
orderFieldValues: ['test'],
|
||||
isPrevious: false,
|
||||
});
|
||||
const result = parseEntityQuery({ cursor });
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
cursor: expect.objectContaining({
|
||||
orderFields: [{ field: 'metadata.name', order: 'asc' }],
|
||||
orderFieldValues: ['test'],
|
||||
isPrevious: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes through limit and fields with cursor', () => {
|
||||
const cursor = makeCursor({});
|
||||
const result = parseEntityQuery({
|
||||
cursor,
|
||||
limit: 25,
|
||||
fields: ['metadata.name'],
|
||||
});
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
limit: 25,
|
||||
fields: ['metadata.name'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on empty cursor string', () => {
|
||||
expect(() => parseEntityQuery({ cursor: '' })).toThrow(
|
||||
/Cursor cannot be empty/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on invalid base64 cursor', () => {
|
||||
expect(() => parseEntityQuery({ cursor: '!!not-valid!!' })).toThrow(
|
||||
/Malformed cursor/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on invalid JSON in cursor', () => {
|
||||
const cursor = Buffer.from('not json', 'utf8').toString('base64');
|
||||
expect(() => parseEntityQuery({ cursor })).toThrow(/Malformed cursor/);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user