Filtering

This commit is contained in:
Fredrik Adelöw
2020-05-26 14:48:37 +02:00
parent 8c71c29b60
commit 642de90255
6 changed files with 209 additions and 17 deletions
@@ -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);
}
+8 -2
View File
@@ -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);
});
});
});
+23 -4
View File
@@ -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;
}