catalog: add support for multiple sortfields

Signed-off-by: Vincenzo Scamporlino <vincenzos@spotify.com>
This commit is contained in:
Vincenzo Scamporlino
2022-09-09 11:17:21 +02:00
parent b8c262e4fb
commit 5b35637307
11 changed files with 173 additions and 86 deletions
@@ -362,11 +362,13 @@ describe('CatalogClient', () => {
fields: ['a', 'b'],
limit: 100,
query: 'query',
sortField: 'metadata.name',
sortFieldOrder: 'asc',
sortFields: [
{ field: 'metadata.name', order: 'asc' },
{ field: 'metadata.uid', order: 'desc' },
],
});
expect(mockedEndpoint.mock.calls[0][0].url.search).toBe(
'?limit=100&sortField=metadata.name&sortFieldOrder=asc&fields=a,b&query=query',
'?limit=100&sortField=metadata.name,asc&sortField=metadata.uid,desc&fields=a,b&query=query',
);
});
@@ -383,8 +385,7 @@ describe('CatalogClient', () => {
fields: ['a', 'b'],
limit: 100,
query: 'query',
sortField: 'metadata.name',
sortFieldOrder: 'asc',
sortFields: [{ field: 'metadata.name', order: 'asc' }],
cursor: 'cursor',
});
expect(mockedEndpoint.mock.calls[0][0].url.search).toBe(
+5 -13
View File
@@ -217,24 +217,16 @@ export class CatalogClient implements CatalogApi {
const params: string[] = [];
if (isPaginatedEntitiesInitialRequest(request)) {
const {
fields = [],
filter,
limit,
sortField,
sortFieldOrder,
query,
} = request ?? {};
const { fields = [], filter, limit, sortFields, query } = request ?? {};
params.push(...this.getParams(filter));
if (limit !== undefined) {
params.push(`limit=${limit}`);
}
if (sortField !== undefined) {
params.push(`sortField=${sortField}`);
}
if (sortFieldOrder !== undefined) {
params.push(`sortFieldOrder=${sortFieldOrder}`);
if (sortFields !== undefined) {
sortFields.forEach(({ field, order }) =>
params.push(`sortField=${field},${order}`),
);
}
if (fields.length) {
params.push(`fields=${fields.map(encodeURIComponent).join(',')}`);
+6 -2
View File
@@ -366,6 +366,11 @@ export type EntitiesFilter =
| Record<string, string | symbol | (string | symbol)[]>
| undefined;
/**
* Used for sorting the entities.
*/
export type EntitySortField = { field: string; order: 'asc' | 'desc' };
/**
* The response type for {@link CatalogClient.addLocation}.
*
@@ -411,9 +416,8 @@ export type GetPaginatedEntitiesInitialRequest =
fields?: string[];
limit?: number;
filter?: EntitiesFilter;
sortField?: string;
sortFields?: EntitySortField[];
query?: string;
sortFieldOrder?: 'asc' | 'desc';
}
| undefined;
+15 -11
View File
@@ -46,6 +46,12 @@ export type EntityOrder = {
order: 'asc' | 'desc';
};
// TODO(vinzscam): remove this type in favor of EntityOrder
export type EntitySortField = {
field: string;
order?: 'asc' | 'desc' | undefined;
};
/**
* Matches rows in the search table.
* @public
@@ -234,9 +240,8 @@ export interface PaginatedEntitiesInitialRequest {
fields?: (entity: Entity) => Entity;
limit?: number;
filter?: EntityFilter;
sortField?: string;
sortFields?: EntitySortField[];
query?: string;
sortFieldOrder?: 'asc' | 'desc' | undefined;
}
/**
@@ -283,20 +288,19 @@ export interface PaginatedEntitiesResponse {
*/
export type Cursor = {
/**
* the id of the field used for sorting the data.
* For example, metadata.name
* An array of fields used for sorting the data.
* For example, [ { field: 'metadata.name', order: 'asc' } ]
*/
sortField: string;
sortFields: EntitySortField[];
/**
* The value of the last item returned
* by the request. This is used for performing
* cursor based pagination.
* The value of the cursor of the last item returned.
* This is used for performing pagination.
*/
sortFieldId: string;
sortFieldIds: string[];
/**
* In which order the data should be paginated.
* A filter to apply on the full list of entities.
*/
sortFieldOrder: 'asc' | 'desc';
filter?: EntityFilter;
/**
* true if the cursor is a previous cursor.
@@ -729,7 +729,7 @@ describe('DefaultEntitiesCatalog', () => {
const request1: PaginatedEntitiesInitialRequest = {
filter,
limit,
sortField: 'metadata.name',
sortFields: [{ field: 'metadata.name' }],
};
const response1 = await catalog.paginatedEntities(request1);
expect(response1.entities).toEqual([entityFrom('A'), entityFrom('B')]);
@@ -881,8 +881,7 @@ describe('DefaultEntitiesCatalog', () => {
const request1: PaginatedEntitiesInitialRequest = {
filter,
limit,
sortField: 'metadata.name',
sortFieldOrder: 'desc',
sortFields: [{ field: 'metadata.name', order: 'desc' }],
};
const response1 = await catalog.paginatedEntities(request1);
expect(response1.entities).toEqual([entityFrom('G'), entityFrom('F')]);
@@ -1032,7 +1031,8 @@ describe('DefaultEntitiesCatalog', () => {
const request: PaginatedEntitiesInitialRequest = {
filter,
limit: 100,
sortField: 'metadata.name',
sortFields: [{ field: 'metadata.name' }],
query: 'cAt ',
};
const response = await catalog.paginatedEntities(request);
@@ -35,6 +35,7 @@ import {
EntityFacetsResponse,
EntityFilter,
EntityPagination,
EntitySortField,
PaginatedEntitiesRequest,
PaginatedEntitiesResponse,
} from '../catalog/types';
@@ -54,8 +55,10 @@ import {
isPaginatedEntitiesInitialRequest,
} from './util';
const defaultSortField = 'metadata.name';
const defaultSortFieldOrder = 'asc';
const defaultSortField: EntitySortField = {
field: 'metadata.name',
order: 'asc',
};
function parsePagination(input?: EntityPagination): {
limit?: number;
@@ -328,19 +331,26 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
const db = this.database;
const limit = request?.limit ?? 20;
const cursor: Omit<Cursor, 'sortFieldId'> & { sortFieldId?: string } = {
const cursor: Omit<Cursor, 'sortFieldIds'> & { sortFieldIds?: string[] } = {
firstFieldId: '',
sortField: defaultSortField,
sortFieldOrder: defaultSortFieldOrder,
sortFields: [defaultSortField],
isPrevious: false,
...parseCursorFromRequest(request),
};
const isFetchingBackwards = cursor.isPrevious;
// TODO(vinzscam): at the moment only a single sortField is supported
const sortField: EntitySortField = {
...defaultSortField,
...cursor.sortFields[0],
};
const sortFieldId = cursor.sortFieldIds?.[0];
const dbQuery = db('search')
.join('final_entities', 'search.entity_id', 'final_entities.entity_id')
.where('key', cursor.sortField);
.where('key', sortField.field);
if (cursor.filter) {
parseFilter(cursor.filter, dbQuery, db, false, 'search.entity_id');
@@ -356,21 +366,19 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
const countQuery = dbQuery.clone();
const isOrderingDescending = cursor.sortFieldOrder === 'desc';
if (cursor.sortFieldId) {
const isOrderingDescending = sortField.order === 'desc';
if (sortFieldId) {
dbQuery.andWhere(
'value',
isFetchingBackwards !== isOrderingDescending ? '<' : '>',
cursor.sortFieldId,
sortFieldId,
);
}
dbQuery
.orderBy(
'value',
isFetchingBackwards
? invertOrder(cursor.sortFieldOrder)
: cursor.sortFieldOrder,
isFetchingBackwards ? invertOrder(sortField.order) : sortField.order,
)
// fetch an extra item to check if there are more items.
.limit(isFetchingBackwards ? limit : limit + 1);
@@ -408,7 +416,7 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
const nextCursor = hasMoreResults
? encodeCursor({
...cursor,
sortFieldId: rows[rows.length - 1].value,
sortFieldIds: [rows[rows.length - 1].value],
firstFieldId,
isPrevious: false,
totalItems,
@@ -421,7 +429,7 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
rows[0].value !== cursor.firstFieldId
? encodeCursor({
...cursor,
sortFieldId: rows[0].value,
sortFieldIds: [rows[0].value],
firstFieldId: cursor.firstFieldId,
isPrevious: true,
totalItems,
@@ -625,13 +633,8 @@ function parseCursorFromRequest(
request?: PaginatedEntitiesRequest,
): Partial<Cursor> {
if (isPaginatedEntitiesInitialRequest(request)) {
const {
filter,
sortField = defaultSortField,
sortFieldOrder = defaultSortFieldOrder,
query,
} = request;
return { filter, sortField, sortFieldOrder, query };
const { filter, sortFields = [defaultSortField], query } = request;
return { filter, sortFields, query };
}
if (isPaginatedEntitiesCursorRequest(request)) {
try {
@@ -646,6 +649,6 @@ function parseCursorFromRequest(
return {};
}
function invertOrder(order: Cursor['sortFieldOrder']) {
function invertOrder(order: EntitySortField['order']) {
return order === 'asc' ? 'desc' : 'asc';
}
@@ -163,7 +163,7 @@ describe('createRouter readonly disabled', () => {
totalItems: 0,
});
const response = await request(app).get(
'/v2beta1/entities?filter=a=1,a=2,b=3&filter=c=4',
'/v2beta1/entities?filter=a=1,a=2,b=3&filter=c=4&sortField=metadata.name,asc&sortField=metadata.uid,desc',
);
expect(response.status).toEqual(200);
@@ -180,6 +180,10 @@ describe('createRouter readonly disabled', () => {
{ allOf: [{ key: 'c', values: ['4'] }] },
],
},
sortFields: [
{ field: 'metadata.name', order: 'asc' },
{ field: 'metadata.uid', order: 'desc' },
],
});
});
@@ -0,0 +1,57 @@
/*
* Copyright 2022 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 { parseEntitySortFieldParams } from './parseEntitySortFieldParams';
describe('parseEntitySortFieldParams', () => {
it('supports no sort fields', () => {
const result = parseEntitySortFieldParams({});
expect(result).toEqual(undefined);
});
it('supports single sortField', () => {
const result = parseEntitySortFieldParams({
sortField: ['metadata.name,desc'],
})!;
expect(result).toEqual([{ field: 'metadata.name', order: 'desc' }]);
});
it('supports single sortField without order', () => {
const result = parseEntitySortFieldParams({
sortField: ['metadata.name'],
})!;
expect(result).toEqual([{ field: 'metadata.name' }]);
});
it('supports multiple sort fields', () => {
const result = parseEntitySortFieldParams({
sortField: ['metadata.name,desc', 'metadata.uid,asc'],
});
expect(result).toEqual([
{ field: 'metadata.name', order: 'desc' },
{ field: 'metadata.uid', order: 'asc' },
]);
});
it('throws if sortField order is not valid', () => {
expect(() =>
parseEntitySortFieldParams({
sortField: ['metadata.name,desc', 'metadata.uid,invalid'],
}),
).toThrow(/Invalid sort field order/);
});
});
@@ -0,0 +1,41 @@
/*
* Copyright 2022 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 { InputError } from '@backstage/errors';
import { EntitySortField } from '../../catalog/types';
import { parseStringsParam } from './common';
export function parseEntitySortFieldParams(
params: Record<string, unknown>,
): EntitySortField[] | undefined {
const sortFieldStrings = parseStringsParam(params.sortField, 'sortField');
if (!sortFieldStrings) {
return undefined;
}
return sortFieldStrings.map(sortFieldString => {
const [field, order] = sortFieldString.split(',');
if (order !== undefined && !isOrder(order)) {
throw new InputError('Invalid sort field order, must be asc or desc');
}
return { field, order };
});
}
export function isOrder(order: string): order is 'asc' | 'desc' {
return ['asc', 'desc'].includes(order);
}
@@ -28,8 +28,7 @@ describe('parsePaginatedEntitiesParams', () => {
fields: ['kind'],
limit: '3',
filter: ['a=1', 'b=2'],
sortField: 'sortField',
sortFieldOrder: 'desc',
sortField: ['metadata.name,desc'],
query: 'query',
};
const parsedObj = parsePaginatedEntitiesParams(
@@ -37,8 +36,9 @@ describe('parsePaginatedEntitiesParams', () => {
) as PaginatedEntitiesInitialRequest;
expect(parsedObj.limit).toBe(3);
expect(parsedObj.fields).toBeDefined();
expect(parsedObj.sortField).toBe('sortField');
expect(parsedObj.sortFieldOrder).toBe('desc');
expect(parsedObj.sortFields).toEqual([
{ field: 'metadata.name', order: 'desc' },
]);
expect(parsedObj.filter).toBeDefined();
expect(parsedObj.query).toBe('query');
expect(parsedObj).not.toHaveProperty('authorizationToken');
@@ -50,8 +50,7 @@ describe('parsePaginatedEntitiesParams', () => {
) as PaginatedEntitiesInitialRequest;
expect(parsedObj.limit).toBeUndefined();
expect(parsedObj.fields).toBeUndefined();
expect(parsedObj.sortField).toBeUndefined();
expect(parsedObj.sortFieldOrder).toBeUndefined();
expect(parsedObj.sortFields).toBeUndefined();
expect(parsedObj.filter).toBeUndefined();
expect(parsedObj.query).toBeUndefined();
expect(parsedObj).not.toHaveProperty('authorizationToken');
@@ -63,8 +62,7 @@ describe('parsePaginatedEntitiesParams', () => {
limit: 'asd',
},
{ filter: 3 },
{ sortField: [] },
{ sortFieldOrder: 'something' },
{ sortField: ['metadata.uid,diagonal'] },
{ fields: [4] },
{ query: [] },
])('should throw if some parameter is not valid %p', params => {
@@ -14,7 +14,6 @@
* limitations under the License.
*/
import { InputError } from '@backstage/errors';
import {
PaginatedEntitiesCursorRequest,
PaginatedEntitiesInitialRequest,
@@ -22,6 +21,7 @@ import {
} from '../../catalog/types';
import { parseIntegerParam, parseStringParam } from './common';
import { parseEntityFilterParams } from './parseEntityFilterParams';
import { parseEntitySortFieldParams } from './parseEntitySortFieldParams';
import { parseEntityTransformParams } from './parseEntityTransformParams';
export function parsePaginatedEntitiesParams(
@@ -42,33 +42,16 @@ export function parsePaginatedEntitiesParams(
const filter = parseEntityFilterParams(params);
const query = parseStringParam(params.query, 'query');
const sortField = parseStringParam(params.sortField, 'sortField');
const sortFieldOrder = parseSortFieldOrder(
params.sortFieldOrder,
'sortFieldOrder',
);
const sortFields = parseEntitySortFieldParams(params);
const response: Omit<PaginatedEntitiesInitialRequest, 'authorizationToken'> =
{
fields,
filter,
limit,
sortField,
sortFieldOrder,
sortFields,
query,
};
return response;
}
function parseSortFieldOrder(sortFieldOrder: unknown, ctx: string) {
const isSortFieldOrder =
sortFieldOrder === undefined ||
sortFieldOrder === 'asc' ||
sortFieldOrder === 'desc';
if (isSortFieldOrder) {
return sortFieldOrder;
}
throw new InputError(`Invalid ${ctx}, not asc or desc`);
}