Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: added sorting to get configs request #40

Merged
merged 5 commits into from
Aug 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion openapi3.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ paths:
- $ref: '#/components/parameters/CreatedByQuery'
- $ref: '#/components/parameters/OffsetQuery'
- $ref: '#/components/parameters/LimitQuery'
- $ref: '#/components/parameters/SortQuery'
operationId: getConfigs
summary: get configs based on filters
responses:
Expand All @@ -36,9 +37,10 @@ paths:
$ref: '#/components/schemas/config'
total:
type: integer

'400':
$ref: '#/components/responses/400BadRequest'
'422':
$ref: '#/components/responses/422UnprocessableEntity'
'500':
$ref: '#/components/responses/500InternalServerError'
post:
Expand Down Expand Up @@ -197,6 +199,12 @@ components:
application/json:
schema:
$ref: '#/components/schemas/error'
422UnprocessableEntity:
description: Unprocessable Entity
content:
application/json:
schema:
$ref: '#/components/schemas/error'
500InternalServerError:
description: Internal Server Error
content:
Expand Down Expand Up @@ -274,6 +282,23 @@ components:
required: false
schema:
type: string
SortQuery:
name: sort
in: query
description: "Sorts the results based on the value of one or more properties.\n
The value is a comma-separated list of property names and sort order.\n
properties should be separated by a colon and sort order should be either asc or desc. For example: configName:asc,schemaId:desc\n
The default sort order is ascending. If the sort order is not specified, the default sort order is used.
Each property is only allowed to appear once in the list."
example: [config-name:asc, schema-id:desc, version]
shimoncohen marked this conversation as resolved.
Show resolved Hide resolved
required: false
schema:
type: array
uniqueItems: true
items:
example: config-name:asc
type: string
pattern: ^(config-name|schema-id|version|created-at|created-by)(:asc|:desc){0,1}$
ShouldDereferenceConfigQuery:
name: shouldDereference
in: query
Expand Down
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
"jest-extended": "^4.0.2",
"jest-html-reporters": "^3.1.4",
"jest-openapi": "^0.14.2",
"jest-sorted": "^1.0.15",
"openapi-typescript": "^6.7.5",
"prettier": "^3.2.5",
"prettier-plugin-embed": "^0.4.15",
Expand Down
5 changes: 5 additions & 0 deletions src/common/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,8 @@ export interface OpenApiConfig {
jsonPath: string;
uiPath: string;
}

export type Prettify<T> = {
[K in keyof T]: T[K];
// eslint-disable-next-line @typescript-eslint/ban-types
} & {};
52 changes: 49 additions & 3 deletions src/configs/controllers/configController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@ import { SERVICES } from '../../common/constants';
import { ConfigManager } from '../models/configManager';
import { TypedRequestHandler } from '../../common/interfaces';
import type { components } from '../../openapiTypes';
import { Config } from '../models/config';
import { ConfigNotFoundError, ConfigSchemaMismatchError, ConfigValidationError, ConfigVersionMismatchError } from '../models/errors';
import { Config, SortOption, SortableFields } from '../models/config';
import {
ConfigNotFoundError,
ConfigSchemaMismatchError,
ConfigValidationError,
ConfigVersionMismatchError,
SortQueryRepeatError,
} from '../models/errors';

function configMapper(config: Config): components['schemas']['config'] {
return {
Expand All @@ -17,6 +23,42 @@ function configMapper(config: Config): components['schemas']['config'] {
};
}

const sortFieldsMap = new Map<string, SortableFields>(
Object.entries({
/* eslint-disable @typescript-eslint/naming-convention */
'config-name': 'configName',
version: 'version',
'created-at': 'createdAt',
'schema-id': 'schemaId',
'created-by': 'createdBy',
/* eslint-enable @typescript-eslint/naming-convention */
})
);

function sortOptionParser(sortArray: components['parameters']['SortQuery']): SortOption[] {
if (!sortArray) {
return [];
}

const parsedOptions: SortOption[] = [];
const fieldSet = new Set<string>();

for (const option of sortArray) {
const [field, order] = option.split(':');

if (fieldSet.has(field)) {
throw new SortQueryRepeatError(`Duplicate field in sort query: ${field}`);
}
fieldSet.add(field);

const parsedField = sortFieldsMap.get(field) as SortableFields;

parsedOptions.push({ field: parsedField, order: (order as 'asc' | 'desc' | undefined) ?? 'asc' });
}

return parsedOptions;
}

@injectable()
export class ConfigController {
public constructor(
Expand All @@ -26,10 +68,14 @@ export class ConfigController {

public getConfigs: TypedRequestHandler<'/config', 'get'> = async (req, res, next) => {
try {
const getConfigsResult = await this.manager.getConfigs(req.query);
const { sort, ...options } = req.query ?? {};
const getConfigsResult = await this.manager.getConfigs({ ...options, sort: sortOptionParser(sort) });
const formattedConfigs = getConfigsResult.configs.map(configMapper);
return res.status(httpStatus.OK).json({ configs: formattedConfigs, total: getConfigsResult.totalCount });
} catch (error) {
if (error instanceof SortQueryRepeatError) {
(error as HttpError).status = httpStatus.UNPROCESSABLE_ENTITY;
}
next(error);
}
};
Expand Down
7 changes: 7 additions & 0 deletions src/configs/models/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,10 @@ export type NewConfig = typeof configs.$inferInsert;

export type ConfigRef = typeof configsRefs.$inferSelect;
export type NewConfigRef = typeof configsRefs.$inferInsert;

export type SortableFields = keyof Omit<Config, 'config'>;

export interface SortOption {
field: SortableFields;
order: 'asc' | 'desc';
}
16 changes: 12 additions & 4 deletions src/configs/models/configManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ import { inject, injectable } from 'tsyringe';
import { Clone } from '@sinclair/typebox/value';
import pointer, { JsonObject } from 'json-pointer';
import { parseISO } from 'date-fns';
import type { Prettify } from '../../common/interfaces';
import { ConfigRepository, ConfigSearchParams, SqlPaginationParams } from '../repositories/configRepository';
import { SERVICES } from '../../common/constants';
import { paths, components } from '../../openapiTypes';
import { Config } from './config';
import { Config, SortOption } from './config';
import { Validator } from './configValidator';
import { ConfigNotFoundError, ConfigSchemaMismatchError, ConfigValidationError, ConfigVersionMismatchError } from './errors';
import { ConfigReference } from './configReference';

type GetConfigOptions = Prettify<Omit<NonNullable<paths['/config']['get']['parameters']['query']>, 'sort'> & { sort?: SortOption[] }>;

@injectable()
export class ConfigManager {
public constructor(
Expand Down Expand Up @@ -43,13 +46,18 @@ export class ConfigManager {
return config;
}

public async getConfigs(options?: paths['/config']['get']['parameters']['query']): Promise<{ configs: Config[]; totalCount: number }> {
public async getConfigs(options?: GetConfigOptions): Promise<{ configs: Config[]; totalCount: number }> {
const searchParams: ConfigSearchParams = {};
let paginationParams: SqlPaginationParams = {};
let sortParams: SortOption[] = [];
if (options) {
const { offset, limit, ...querySearchParams } = options;
const { offset, limit, sort, ...querySearchParams } = options;
paginationParams = { offset, limit };

if (sort !== undefined) {
sortParams = sort;
}

searchParams.configName = querySearchParams.config_name;
searchParams.q = querySearchParams.q;
searchParams.version = querySearchParams.version;
Expand All @@ -63,7 +71,7 @@ export class ConfigManager {
searchParams.createdBy = querySearchParams.created_by;
}

return this.configRepository.getConfigs(searchParams, paginationParams);
return this.configRepository.getConfigs(searchParams, paginationParams, sortParams);
}

public async createConfig(config: Omit<components['schemas']['config'], 'createdAt' | 'createdBy'>): Promise<void> {
Expand Down
7 changes: 7 additions & 0 deletions src/configs/models/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,10 @@ export class ConfigValidationError extends Error {
Object.setPrototypeOf(this, ConfigValidationError.prototype);
}
}

export class SortQueryRepeatError extends Error {
public constructor(message: string) {
super(message);
Object.setPrototypeOf(this, SortQueryRepeatError.prototype);
}
}
12 changes: 8 additions & 4 deletions src/configs/repositories/configRepository.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Logger, SQL, SQLWrapper, and, eq, gt, lt, sql, or, isNull } from 'drizzle-orm';
import { Logger, SQL, SQLWrapper, and, asc, desc, eq, gt, isNull, lt, or, sql } from 'drizzle-orm';
import { inject, scoped, Lifecycle } from 'tsyringe';
import { toDate } from 'date-fns-tz';
import { SERVICES } from '../../common/constants';
import type { Drizzle } from '../../db/createConnection';
import { type Config, type NewConfig, type NewConfigRef, configs, configsRefs } from '../models/config';
import { type Config, type NewConfig, type NewConfigRef, configs, configsRefs, SortOption } from '../models/config';
import type { ConfigReference } from '../models/configReference';
import { ConfigNotFoundError } from '../models/errors';

Expand Down Expand Up @@ -271,10 +271,13 @@ export class ConfigRepository {
*/
public async getConfigs(
searchParams: ConfigSearchParams,
paginationParams: SqlPaginationParams = { limit: 1, offset: 0 }
paginationParams: SqlPaginationParams = { limit: 1, offset: 0 },
sortingParams: SortOption[] = []
shimoncohen marked this conversation as resolved.
Show resolved Hide resolved
): Promise<{ configs: Config[]; totalCount: number }> {
const filterParams: SQLWrapper[] = this.getFilterParams(searchParams);

const orderByParams = sortingParams.map((sort) => (sort.order === 'asc' ? asc(configs[sort.field]) : desc(configs[sort.field])));

const configsQuery = this.drizzle
.select({
configName: configs.configName,
Expand All @@ -289,7 +292,8 @@ export class ConfigRepository {
.from(configs)
.where(and(...filterParams))
.offset(paginationParams.offset ?? DEFAULT_OFFSET)
.limit(paginationParams.limit ?? DEFAULT_LIMIT);
.limit(paginationParams.limit ?? DEFAULT_LIMIT)
.orderBy(...orderByParams);

const configsResult = await configsQuery.execute();

Expand Down
6 changes: 3 additions & 3 deletions src/db/migrations/0000_romantic_fixer.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ CREATE SCHEMA IF NOT EXISTS "config_server";

--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "config_server"."config" (
"name" text NOT NULL,
"schema_id" text NOT NULL,
"name" text NOT NULL COLLATE "C",
"schema_id" text NOT NULL COLLATE "C",
"version" integer NOT NULL,
"config" jsonb NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"created_by" text NOT NULL,
"created_by" text NOT NULL COLLATE "C",
CONSTRAINT "config_name_version_pk" PRIMARY KEY ("name", "version"),
"textsearchable_index_col" tsvector GENERATED ALWAYS AS (
to_tsvector(
Expand Down
3 changes: 3 additions & 0 deletions src/openapiTypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ export interface components {
LimitQuery?: number;
/** @description Search term for full-text search across relevant properties (implementation specific). */
FullTextQuery?: string;
/** @description Sorts the results based on the value of one or more properties. The value is a comma-separated list of property names with an optional "-" prefix to indicate descending order. */
SortQuery?: string[];
/** @description should the server bundle all refs into one config */
ShouldDereferenceConfigQuery?: boolean;
};
Expand All @@ -197,6 +199,7 @@ export interface operations {
created_by?: components['parameters']['CreatedByQuery'];
offset?: components['parameters']['OffsetQuery'];
limit?: components['parameters']['LimitQuery'];
sort?: components['parameters']['SortQuery'];
};
};
responses: {
Expand Down
2 changes: 1 addition & 1 deletion tests/configurations/integration/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ module.exports = {
rootDir: '../../../.',
testMatch: ['<rootDir>/tests/integration/**/*.spec.ts'],
setupFiles: ['<rootDir>/tests/configurations/jest.setup.ts'],
setupFilesAfterEnv: ['jest-openapi', 'jest-extended/all', '<rootDir>/tests/configurations/initJestOpenapi.setup.ts'],
setupFilesAfterEnv: ['jest-openapi', 'jest-sorted', 'jest-extended/all', '<rootDir>/tests/configurations/initJestOpenapi.setup.ts'],
reporters: [
'default',
[
Expand Down
1 change: 1 addition & 0 deletions tests/configurations/unit/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ module.exports = {
'!**/db/**',
'!**/routes/**',
'!<rootDir>/src/*',
'!<rootDir>/src/configs/models/config.ts',
],
coverageDirectory: '<rootDir>/coverage',
setupFilesAfterEnv: ['jest-openapi', '<rootDir>/tests/configurations/initJestOpenapi.setup.ts'],
Expand Down
Loading
Loading