Filtering
This commit is contained in:
@@ -17,14 +17,14 @@
|
||||
import { NotFoundError } from '@backstage/backend-common';
|
||||
import { Database } from '../database';
|
||||
import { DescriptorEnvelope } from '../ingestion/types';
|
||||
import { EntitiesCatalog } from './types';
|
||||
import { EntitiesCatalog, EntityFilters } from './types';
|
||||
|
||||
export class DatabaseEntitiesCatalog implements EntitiesCatalog {
|
||||
constructor(private readonly database: Database) {}
|
||||
|
||||
async entities(): Promise<DescriptorEnvelope[]> {
|
||||
async entities(filters?: EntityFilters): Promise<DescriptorEnvelope[]> {
|
||||
const items = await this.database.transaction(tx =>
|
||||
this.database.entities(tx),
|
||||
this.database.entities(tx, filters),
|
||||
);
|
||||
return items.map(i => i.entity);
|
||||
}
|
||||
|
||||
@@ -18,11 +18,17 @@ import * as yup from 'yup';
|
||||
import { DescriptorEnvelope } from '../ingestion';
|
||||
|
||||
//
|
||||
// Items
|
||||
// Entities
|
||||
//
|
||||
|
||||
export type EntityFilter = {
|
||||
key: string;
|
||||
values: (string | null)[];
|
||||
};
|
||||
export type EntityFilters = EntityFilter[];
|
||||
|
||||
export type EntitiesCatalog = {
|
||||
entities(): Promise<DescriptorEnvelope[]>;
|
||||
entities(filters?: EntityFilters): Promise<DescriptorEnvelope[]>;
|
||||
entity(
|
||||
kind: string,
|
||||
name: string,
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
DbEntityResponse,
|
||||
DbLocationsRow,
|
||||
} from './types';
|
||||
import { DescriptorEnvelope } from '../ingestion';
|
||||
|
||||
describe('Database', () => {
|
||||
let database: Knex;
|
||||
@@ -242,4 +243,107 @@ describe('Database', () => {
|
||||
).rejects.toThrow(ConflictError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('entities', () => {
|
||||
it('can get all entities with empty filters list', async () => {
|
||||
const catalog = new Database(database, getVoidLogger());
|
||||
const e1: DescriptorEnvelope = { apiVersion: 'a', kind: 'b' };
|
||||
const e2: DescriptorEnvelope = {
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
spec: { c: null },
|
||||
};
|
||||
await catalog.transaction(async tx => {
|
||||
await catalog.addEntity(tx, { entity: e1 });
|
||||
await catalog.addEntity(tx, { entity: e2 });
|
||||
});
|
||||
await expect(
|
||||
catalog.transaction(async tx => catalog.entities(tx, [])),
|
||||
).resolves.toEqual([
|
||||
{ locationId: undefined, entity: expect.objectContaining(e1) },
|
||||
{ locationId: undefined, entity: expect.objectContaining(e2) },
|
||||
]);
|
||||
});
|
||||
it('can get all specific entities for matching filters (naive case)', async () => {
|
||||
const catalog = new Database(database, getVoidLogger());
|
||||
const entities: DescriptorEnvelope[] = [
|
||||
{ apiVersion: 'a', kind: 'b' },
|
||||
{
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
spec: { c: 'some' },
|
||||
},
|
||||
{
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
spec: { c: null },
|
||||
},
|
||||
];
|
||||
|
||||
await catalog.transaction(async tx => {
|
||||
for (const entity of entities) {
|
||||
await catalog.addEntity(tx, { entity });
|
||||
}
|
||||
});
|
||||
|
||||
await expect(
|
||||
catalog.transaction(async tx =>
|
||||
catalog.entities(tx, [
|
||||
{ key: 'kind', values: ['b'] },
|
||||
{ key: 'spec.c', values: ['some'] },
|
||||
]),
|
||||
),
|
||||
).resolves.toEqual([
|
||||
{ locationId: undefined, entity: expect.objectContaining(entities[1]) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('can get all specific entities for matching filters with nulls (both missing and literal null value)', async () => {
|
||||
const catalog = new Database(database, getVoidLogger());
|
||||
const entities: DescriptorEnvelope[] = [
|
||||
{ apiVersion: 'a', kind: 'b' },
|
||||
{
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
spec: { c: 'some' },
|
||||
},
|
||||
{
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
spec: { c: null },
|
||||
},
|
||||
];
|
||||
|
||||
await catalog.transaction(async tx => {
|
||||
for (const entity of entities) {
|
||||
await catalog.addEntity(tx, { entity });
|
||||
}
|
||||
});
|
||||
|
||||
const rows = await catalog.transaction(async tx =>
|
||||
catalog.entities(tx, [
|
||||
{ key: 'kind', values: ['b'] },
|
||||
{ key: 'spec.c', values: [null, 'some'] },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(rows.length).toEqual(3);
|
||||
expect(rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
locationId: undefined,
|
||||
entity: expect.objectContaining(entities[0]),
|
||||
},
|
||||
{
|
||||
locationId: undefined,
|
||||
entity: expect.objectContaining(entities[1]),
|
||||
},
|
||||
{
|
||||
locationId: undefined,
|
||||
entity: expect.objectContaining(entities[2]),
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import Knex from 'knex';
|
||||
import lodash from 'lodash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Logger } from 'winston';
|
||||
import { EntityFilters } from '../catalog';
|
||||
import { DescriptorEnvelope, EntityMeta } from '../ingestion';
|
||||
import { buildEntitySearch } from './search';
|
||||
import {
|
||||
@@ -309,10 +310,30 @@ export class Database {
|
||||
return { locationId: request.locationId, entity: newEntity };
|
||||
}
|
||||
|
||||
async entities(tx: Knex.Transaction<any, any>): Promise<DbEntityResponse[]> {
|
||||
const rows = await tx<DbEntitiesRow>('entities')
|
||||
async entities(
|
||||
tx: Knex.Transaction<any, any>,
|
||||
filters?: EntityFilters,
|
||||
): Promise<DbEntityResponse[]> {
|
||||
let builder = tx<DbEntitiesRow>('entities');
|
||||
for (const [index, filter] of (filters ?? []).entries()) {
|
||||
builder = builder
|
||||
.leftOuterJoin(`entities_search as t${index}`, function join() {
|
||||
this.on('entities.id', '=', `t${index}.entity_id`).onIn(
|
||||
`t${index}.value`,
|
||||
filter.values.filter(x => x),
|
||||
);
|
||||
if (filter.values.some(x => !x)) {
|
||||
this.orOnNull(`t${index}.value`);
|
||||
}
|
||||
})
|
||||
.where(`t${index}.key`, '=', filter.key);
|
||||
}
|
||||
|
||||
const rows = await builder
|
||||
.orderBy('namespace', 'name')
|
||||
.select();
|
||||
.select('entities.*')
|
||||
.groupBy('id');
|
||||
|
||||
return rows.map(row => toEntityResponse(row));
|
||||
}
|
||||
|
||||
|
||||
@@ -14,12 +14,16 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
errorHandler,
|
||||
getVoidLogger,
|
||||
InputError,
|
||||
} from '@backstage/backend-common';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { createRouter } from './router';
|
||||
import { EntitiesCatalog, LocationsCatalog, Location } from '../catalog';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { EntitiesCatalog, Location, LocationsCatalog } from '../catalog';
|
||||
import { DescriptorEnvelope } from '../ingestion';
|
||||
import { createRouter } from './router';
|
||||
|
||||
class MockEntitiesCatalog implements EntitiesCatalog {
|
||||
entities = jest.fn();
|
||||
@@ -50,7 +54,26 @@ describe('createRouter', () => {
|
||||
const response = await request(app).get('/entities');
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(JSON.parse(response.text)).toEqual(entities);
|
||||
expect(response.body).toEqual(entities);
|
||||
});
|
||||
|
||||
it('parses single and multiple request parameters and passes them down', async () => {
|
||||
const catalog = new MockEntitiesCatalog();
|
||||
|
||||
const router = await createRouter({
|
||||
entitiesCatalog: catalog,
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
const app = express().use(router);
|
||||
const response = await request(app).get('/entities?a=1&a=&a=3&b=4&c=');
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(catalog.entities).toHaveBeenCalledWith([
|
||||
{ key: 'a', values: ['1', null, '3'] },
|
||||
{ key: 'b', values: ['4'] },
|
||||
{ key: 'c', values: [null] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,7 +93,26 @@ describe('createRouter', () => {
|
||||
const response = await request(app).get('/locations');
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(JSON.parse(response.text)).toEqual(locations);
|
||||
expect(response.body).toEqual(locations);
|
||||
});
|
||||
|
||||
it('rejects malformed locations', async () => {
|
||||
const location = ({
|
||||
id: 'a',
|
||||
typez: 'b',
|
||||
target: 'c',
|
||||
} as unknown) as Location;
|
||||
|
||||
const catalog = new MockLocationsCatalog();
|
||||
const router = await createRouter({
|
||||
locationsCatalog: catalog,
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
const app = express().use(router);
|
||||
const response = await request(app).post('/locations').send(location);
|
||||
|
||||
expect(response.status).toEqual(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,12 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { errorHandler } from '@backstage/backend-common';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
import {
|
||||
addLocationSchema,
|
||||
EntitiesCatalog,
|
||||
EntityFilters,
|
||||
LocationsCatalog,
|
||||
} from '../catalog';
|
||||
import { validateRequestBody } from './util';
|
||||
@@ -34,17 +36,33 @@ export async function createRouter(
|
||||
options: RouterOptions,
|
||||
): Promise<express.Router> {
|
||||
const { entitiesCatalog, locationsCatalog } = options;
|
||||
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
|
||||
if (entitiesCatalog) {
|
||||
// Entities
|
||||
router.get('/entities', async (_req, res) => {
|
||||
const entities = await entitiesCatalog.entities();
|
||||
router.get('/entities', async (req, res) => {
|
||||
const filters: EntityFilters = [];
|
||||
for (const [key, valueOrValues] of Object.entries(req.query)) {
|
||||
const values = Array.isArray(valueOrValues)
|
||||
? valueOrValues
|
||||
: [valueOrValues];
|
||||
if (values.some(v => typeof v !== 'string')) {
|
||||
res.status(400).send('Complex query parameters are not supported');
|
||||
return;
|
||||
}
|
||||
filters.push({
|
||||
key,
|
||||
values: values.map(v => v || null) as string[],
|
||||
});
|
||||
}
|
||||
|
||||
const entities = await entitiesCatalog.entities(filters);
|
||||
|
||||
res.status(200).send(entities);
|
||||
});
|
||||
}
|
||||
|
||||
// Locations
|
||||
if (locationsCatalog) {
|
||||
router
|
||||
.post('/locations', async (req, res) => {
|
||||
@@ -68,5 +86,6 @@ export async function createRouter(
|
||||
});
|
||||
}
|
||||
|
||||
router.use(errorHandler());
|
||||
return router;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user