Browse Source

feat(server): Implement sorting for list queries

Relates to #4
Michael Bromley 7 years ago
parent
commit
0e6ad233a0

+ 0 - 1
server/src/api/common/common.graphql

@@ -1 +0,0 @@
-scalar JSON

+ 1 - 1
server/src/api/customer/customer.api.graphql

@@ -1,5 +1,5 @@
 type Query {
-  customers(take: Int, skip: Int): CustomerList!
+  customers(options: ListOptions): CustomerList!
   customer(id: ID!): Customer
 }
 

+ 0 - 20
server/src/api/customer/customer.controller.ts

@@ -1,20 +0,0 @@
-import { Controller, Get, Param } from '@nestjs/common';
-
-import { PaginatedList } from '../../../../shared/shared-types';
-import { Customer } from '../../entity/customer/customer.entity';
-import { CustomerService } from '../../service/customer.service';
-
-@Controller('customers')
-export class CustomerController {
-    constructor(private userService: CustomerService) {}
-
-    @Get()
-    findAll(): Promise<PaginatedList<Customer>> {
-        return this.userService.findAll();
-    }
-
-    @Get(':id')
-    findOne(@Param() params): Promise<Customer | undefined> {
-        return this.userService.findOne(params.id);
-    }
-}

+ 1 - 1
server/src/api/customer/customer.resolver.ts

@@ -13,7 +13,7 @@ export class CustomerResolver {
     @Query('customers')
     @ApplyIdCodec()
     async customers(obj, args): Promise<PaginatedList<Customer>> {
-        return this.customerService.findAll(args.take, args.skip);
+        return this.customerService.findAll(args.options);
     }
 
     @Query('customer')

+ 1 - 1
server/src/api/product/product.api.graphql

@@ -1,5 +1,5 @@
 type Query {
-    products(languageCode: LanguageCode, take: Int, skip: Int): ProductList!
+    products(languageCode: LanguageCode, options: ListOptions): ProductList!
     product(id: ID!, languageCode: LanguageCode): Product
 }
 

+ 1 - 1
server/src/api/product/product.resolver.ts

@@ -20,7 +20,7 @@ export class ProductResolver {
     @Query('products')
     @ApplyIdCodec()
     async products(obj, args): Promise<PaginatedList<Translated<Product>>> {
-        return this.productService.findAll(args.languageCode, args.take, args.skip);
+        return this.productService.findAll(args.languageCode, args.options);
     }
 
     @Query('product')

+ 1 - 2
server/src/app.module.ts

@@ -9,7 +9,6 @@ import { CustomFields } from '../../shared/shared-types';
 import { AdministratorResolver } from './api/administrator/administrator.resolver';
 import { AuthController } from './api/auth/auth.controller';
 import { ConfigResolver } from './api/config/config.resolver';
-import { CustomerController } from './api/customer/customer.controller';
 import { CustomerResolver } from './api/customer/customer.resolver';
 import { ProductOptionResolver } from './api/product-option/product-option.resolver';
 import { ProductResolver } from './api/product/product.resolver';
@@ -31,7 +30,7 @@ import { ProductService } from './service/product.service';
 
 @Module({
     imports: [GraphQLModule, TypeOrmModule.forRoot(getConfig().dbConnectionOptions)],
-    controllers: [AuthController, CustomerController],
+    controllers: [AuthController],
     providers: [
         AdministratorResolver,
         AdministratorService,

+ 32 - 0
server/src/common/build-list-query.ts

@@ -0,0 +1,32 @@
+import { Connection, SelectQueryBuilder } from 'typeorm';
+import { FindOptionsUtils } from 'typeorm/find-options/FindOptionsUtils';
+
+import { Type } from '../../../shared/shared-types';
+import { VendureEntity } from '../entity/base/base.entity';
+
+import { ListQueryOptions } from './common-types';
+import { parseSortParams } from './parse-sort-params';
+
+/**
+ * Creates and configures a SelectQueryBuilder for queries that return paginated lists of entities.
+ */
+export function buildListQuery<T extends VendureEntity>(
+    connection: Connection,
+    entity: Type<T>,
+    options: ListQueryOptions,
+    relations?: string[],
+): SelectQueryBuilder<T> {
+    const skip = options.skip;
+    let take = options.take;
+    if (options.skip !== undefined && options.take === undefined) {
+        take = Number.MAX_SAFE_INTEGER;
+    }
+    const order = parseSortParams(connection, entity, options.sort);
+
+    const qb = connection.createQueryBuilder<T>(entity, entity.name.toLowerCase());
+    FindOptionsUtils.applyFindManyOptionsOrConditionsToQueryBuilder(qb, { relations, take, skip });
+    // tslint:disable-next-line:no-non-null-assertion
+    FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias!.metadata);
+
+    return qb.orderBy(order);
+}

+ 18 - 0
server/src/common/common-types.graphql

@@ -6,3 +6,21 @@ interface PaginatedList {
 interface Node {
     id: ID!
 }
+
+input ListOptions {
+    take: Int
+    skip: Int
+    sort: [SortParameter!]
+}
+
+enum SortOrder {
+    ASC
+    DESC
+}
+
+input SortParameter {
+    field: String!
+    order: SortOrder
+}
+
+scalar JSON

+ 23 - 0
server/src/common/common-types.ts

@@ -8,3 +8,26 @@ export type ReadOnlyRequired<T> = { +readonly [K in keyof T]-?: T[K] };
  * Given an array type e.g. Array<string>, return the inner type e.g. string.
  */
 export type UnwrappedArray<T extends any[]> = T[number];
+
+/**
+ * Parameters for list queries
+ */
+export interface ListQueryOptions {
+    take: number;
+    skip: number;
+    sort: SortParameter[];
+    filter: FilterParameter[];
+}
+
+export interface SortParameter {
+    field: string;
+    order: 'asc' | 'desc';
+}
+
+export interface FilterParameter {
+    field: string;
+    operator: FilterOperator;
+    value: string | number;
+}
+
+export type FilterOperator = 'gt' | 'gte' | 'lt' | 'lte' | 'eq' | 'contains' | 'startsWith';

+ 157 - 0
server/src/common/parse-sort-params.spec.ts

@@ -0,0 +1,157 @@
+import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata';
+import { RelationMetadata } from 'typeorm/metadata/RelationMetadata';
+
+import { Type } from '../../../shared/shared-types';
+import { ProductTranslation } from '../entity/product/product-translation.entity';
+import { Product } from '../entity/product/product.entity';
+import { I18nError } from '../i18n/i18n-error';
+
+import { SortParameter } from './common-types';
+import { parseSortParams } from './parse-sort-params';
+
+describe('parseSortParams()', () => {
+    it('works with no params', () => {
+        const connection = new MockConnection();
+        connection.setColumns(Product, [{ propertyName: 'id' }, { propertyName: 'image' }]);
+
+        const result = parseSortParams(connection as any, Product, []);
+        expect(result).toEqual({});
+    });
+
+    it('works with a single param', () => {
+        const connection = new MockConnection();
+        connection.setColumns(Product, [{ propertyName: 'id' }, { propertyName: 'image' }]);
+
+        const sortParams: SortParameter[] = [{ field: 'id', order: 'asc' }];
+
+        const result = parseSortParams(connection as any, Product, sortParams);
+        expect(result).toEqual({
+            'product.id': 'ASC',
+        });
+    });
+
+    it('defaults the order to "ASC', () => {
+        const connection = new MockConnection();
+        connection.setColumns(Product, [{ propertyName: 'id' }, { propertyName: 'image' }]);
+
+        const sortParams: SortParameter[] = [{ field: 'id' } as any, { field: 'image', order: 'foo' } as any];
+
+        const result = parseSortParams(connection as any, Product, sortParams);
+        expect(result).toEqual({
+            'product.id': 'ASC',
+            'product.image': 'ASC',
+        });
+    });
+
+    it('works with multiple params', () => {
+        const connection = new MockConnection();
+        connection.setColumns(Product, [
+            { propertyName: 'id' },
+            { propertyName: 'image' },
+            { propertyName: 'createdAt' },
+        ]);
+
+        const sortParams: SortParameter[] = [
+            { field: 'id', order: 'asc' },
+            { field: 'createdAt', order: 'desc' },
+            { field: 'image', order: 'asc' },
+        ];
+
+        const result = parseSortParams(connection as any, Product, sortParams);
+        expect(result).toEqual({
+            'product.id': 'ASC',
+            'product.createdAt': 'DESC',
+            'product.image': 'ASC',
+        });
+    });
+
+    it('works with localized fields', () => {
+        const connection = new MockConnection();
+        connection.setColumns(Product, [{ propertyName: 'id' }, { propertyName: 'image' }]);
+        connection.setRelations(Product, [{ propertyName: 'translations', type: ProductTranslation }]);
+        connection.setColumns(ProductTranslation, [
+            { propertyName: 'id' },
+            { propertyName: 'name' },
+            { propertyName: 'base', relationMetadata: {} as any },
+        ]);
+
+        const sortParams: SortParameter[] = [{ field: 'id', order: 'asc' }, { field: 'name', order: 'desc' }];
+
+        const result = parseSortParams(connection as any, Product, sortParams);
+        expect(result).toEqual({
+            'product.id': 'ASC',
+            'product_translations.name': 'DESC',
+        });
+    });
+
+    it('works with custom fields', () => {
+        const connection = new MockConnection();
+        connection.setColumns(Product, [{ propertyName: 'id' }, { propertyName: 'infoUrl' }]);
+
+        const sortParams: SortParameter[] = [{ field: 'infoUrl', order: 'asc' }];
+
+        const result = parseSortParams(connection as any, Product, sortParams);
+        expect(result).toEqual({
+            'product.infoUrl': 'ASC',
+        });
+    });
+
+    it('works with localized custom fields', () => {
+        const connection = new MockConnection();
+        connection.setColumns(Product, [{ propertyName: 'id' }]);
+        connection.setRelations(Product, [{ propertyName: 'translations', type: ProductTranslation }]);
+        connection.setColumns(ProductTranslation, [{ propertyName: 'id' }, { propertyName: 'shortName' }]);
+
+        const sortParams: SortParameter[] = [{ field: 'shortName', order: 'asc' }];
+
+        const result = parseSortParams(connection as any, Product, sortParams);
+        expect(result).toEqual({
+            'product_translations.shortName': 'ASC',
+        });
+    });
+
+    it('throws if an invalid field is passed', () => {
+        const connection = new MockConnection();
+        connection.setColumns(Product, [{ propertyName: 'id' }, { propertyName: 'image' }]);
+        connection.setRelations(Product, [{ propertyName: 'translations', type: ProductTranslation }]);
+        connection.setColumns(ProductTranslation, [
+            { propertyName: 'id' },
+            { propertyName: 'name' },
+            { propertyName: 'base', relationMetadata: {} as any },
+        ]);
+
+        const sortParams: SortParameter[] = [{ field: 'invalid', order: 'asc' }];
+
+        try {
+            parseSortParams(connection as any, Product, sortParams);
+            fail('should not get here');
+        } catch (e) {
+            expect(e instanceof I18nError).toBe(true);
+            expect(e.message).toBe('error.invalid-sort-field');
+            expect(e.variables.fieldName).toBe('invalid');
+            expect(e.variables.validFields).toEqual('id, image, name');
+        }
+    });
+});
+
+type PropertiesMap = {
+    [name: string]: string | any;
+};
+
+class MockConnection {
+    private columnsMap = new Map<Type<any>, Array<Partial<ColumnMetadata>>>();
+    private relationsMap = new Map<Type<any>, Array<Partial<RelationMetadata>>>();
+    setColumns(entity: Type<any>, value: Array<Partial<ColumnMetadata>>) {
+        this.columnsMap.set(entity, value);
+    }
+    setRelations(entity: Type<any>, value: Array<Partial<RelationMetadata>>) {
+        this.relationsMap.set(entity, value);
+    }
+    getMetadata = (entity: Type<any>) => {
+        return {
+            name: entity.name,
+            columns: this.columnsMap.get(entity) || [],
+            relations: this.relationsMap.get(entity) || [],
+        };
+    };
+}

+ 59 - 0
server/src/common/parse-sort-params.ts

@@ -0,0 +1,59 @@
+import { Connection, OrderByCondition } from 'typeorm';
+import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata';
+
+import { Type } from '../../../shared/shared-types';
+import { VendureEntity } from '../entity/base/base.entity';
+import { I18nError } from '../i18n/i18n-error';
+
+import { SortParameter } from './common-types';
+
+/**
+ * Parses the provided SortParameter array against the metadata of the given entity, ensuring that only
+ * valid fields are being sorted against. The output assumes
+ * @param connection
+ * @param entity
+ * @param sortParams
+ */
+export function parseSortParams(
+    connection: Connection,
+    entity: Type<VendureEntity>,
+    sortParams: SortParameter[] | undefined,
+): OrderByCondition {
+    if (!sortParams || sortParams.length === 0) {
+        return {};
+    }
+
+    const metadata = connection.getMetadata(entity);
+    const columns = metadata.columns;
+    let translationColumns: ColumnMetadata[] = [];
+    const relations = metadata.relations;
+
+    const translationRelation = relations.find(r => r.propertyName === 'translations');
+    if (translationRelation) {
+        const translationMetadata = connection.getMetadata(translationRelation.type);
+        translationColumns = columns.concat(translationMetadata.columns.filter(c => !c.relationMetadata));
+    }
+
+    const output = {};
+
+    for (const param of sortParams) {
+        const key = param.field;
+        const alias = metadata.name.toLowerCase();
+        const order = param.order && param.order.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
+        if (columns.find(c => c.propertyName === key)) {
+            output[`${alias}.${key}`] = order;
+        } else if (translationColumns.find(c => c.propertyName === key)) {
+            output[`${alias}_translations.${key}`] = order;
+        } else {
+            throw new I18nError('error.invalid-sort-field', {
+                fieldName: key,
+                validFields: getValidSortFields([...columns, ...translationColumns]),
+            });
+        }
+    }
+    return output;
+}
+
+function getValidSortFields(columns: ColumnMetadata[]): string {
+    return Array.from(new Set(columns.map(c => c.propertyName))).join(', ');
+}

+ 2 - 1
server/src/i18n/messages/en.json

@@ -1,6 +1,7 @@
 {
   "error": {
     "customer-with-id-not-found": "No customer with the id { customerId } was found",
-    "entity-has-no-translation-in-language": "Translatable entity '{ entityName }' has not been translated into the requested language ({ languageCode })"
+    "entity-has-no-translation-in-language": "Translatable entity '{ entityName }' has not been translated into the requested language ({ languageCode })",
+    "invalid-sort-field": "The sort field '{ fieldName }' is invalid. Valid fields are: { validFields }"
   }
 }

+ 5 - 7
server/src/service/customer.service.ts

@@ -5,6 +5,8 @@ import { Connection } from 'typeorm';
 import { ID, PaginatedList } from '../../../shared/shared-types';
 import { PasswordService } from '../auth/password.service';
 import { Role } from '../auth/role';
+import { buildListQuery } from '../common/build-list-query';
+import { ListQueryOptions } from '../common/common-types';
 import { CreateAddressDto } from '../entity/address/address.dto';
 import { Address } from '../entity/address/address.entity';
 import { CreateCustomerDto } from '../entity/customer/customer.dto';
@@ -19,13 +21,9 @@ export class CustomerService {
         private passwordService: PasswordService,
     ) {}
 
-    findAll(take?: number, skip?: number): Promise<PaginatedList<Customer>> {
-        if (skip !== undefined && take === undefined) {
-            take = Number.MAX_SAFE_INTEGER;
-        }
-
-        return this.connection.manager
-            .findAndCount(Customer, { skip, take })
+    findAll(options: ListQueryOptions): Promise<PaginatedList<Customer>> {
+        return buildListQuery(this.connection, Customer, options)
+            .getManyAndCount()
             .then(([items, totalItems]) => ({ items, totalItems }));
     }
 

+ 5 - 7
server/src/service/product.service.ts

@@ -3,6 +3,8 @@ import { InjectConnection } from '@nestjs/typeorm';
 import { Connection } from 'typeorm';
 
 import { ID, PaginatedList } from '../../../shared/shared-types';
+import { buildListQuery } from '../common/build-list-query';
+import { ListQueryOptions } from '../common/common-types';
 import { DEFAULT_LANGUAGE_CODE } from '../common/constants';
 import { assertFound } from '../common/utils';
 import { ProductOptionGroup } from '../entity/product-option-group/product-option-group.entity';
@@ -23,15 +25,11 @@ export class ProductService {
         private translationUpdaterService: TranslationUpdaterService,
     ) {}
 
-    findAll(lang: LanguageCode, take?: number, skip?: number): Promise<PaginatedList<Translated<Product>>> {
+    findAll(lang: LanguageCode, options: ListQueryOptions): Promise<PaginatedList<Translated<Product>>> {
         const relations = ['variants', 'optionGroups', 'variants.options'];
 
-        if (skip !== undefined && take === undefined) {
-            take = Number.MAX_SAFE_INTEGER;
-        }
-
-        return this.connection.manager
-            .findAndCount(Product, { relations, take, skip })
+        return buildListQuery(this.connection, Product, options, relations)
+            .getManyAndCount()
             .then(([products, totalItems]) => {
                 const items = products.map(product =>
                     translateDeep(product, lang, ['optionGroups', 'variants', ['variants', 'options']]),