Parcourir la source

fix(core): Fix date handling for ListQueryBuilder

Fixes #251
Michael Bromley il y a 6 ans
Parent
commit
6a6397b8d1

+ 87 - 0
packages/core/e2e/fixtures/list-query-plugin.ts

@@ -0,0 +1,87 @@
+import { Args, Query, Resolver } from '@nestjs/graphql';
+import { InjectConnection } from '@nestjs/typeorm';
+import {
+    ListQueryBuilder,
+    OnVendureBootstrap,
+    PluginCommonModule,
+    VendureEntity,
+    VendurePlugin,
+} from '@vendure/core';
+import gql from 'graphql-tag';
+import { Column, Connection, Entity } from 'typeorm';
+
+@Entity()
+export class TestEntity extends VendureEntity {
+    constructor(input: Partial<TestEntity>) {
+        super(input);
+    }
+    @Column()
+    label: string;
+
+    @Column()
+    date: Date;
+}
+
+@Resolver()
+export class ListQueryResolver {
+    constructor(private listQueryBuilder: ListQueryBuilder) {}
+
+    @Query()
+    testEntities(@Args() args: any) {
+        return this.listQueryBuilder
+            .build(TestEntity, args.options)
+            .getManyAndCount()
+            .then(([items, totalItems]) => {
+                return {
+                    items,
+                    totalItems,
+                };
+            });
+    }
+}
+
+const adminApiExtensions = gql`
+    type TestEntity implements Node {
+        id: ID!
+        createdAt: DateTime!
+        updatedAt: DateTime!
+        label: String!
+        date: DateTime!
+    }
+
+    type TestEntityList implements PaginatedList {
+        totalItems: Int!
+        items: [TestEntity!]!
+    }
+
+    extend type Query {
+        testEntities(options: TestEntityListOptions): TestEntityList!
+    }
+
+    input TestEntityListOptions
+`;
+
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    entities: [TestEntity],
+    adminApiExtensions: {
+        schema: adminApiExtensions,
+        resolvers: [ListQueryResolver],
+    },
+})
+export class ListQueryPlugin implements OnVendureBootstrap {
+    constructor(@InjectConnection() private connection: Connection) {}
+
+    async onVendureBootstrap() {
+        const count = await this.connection.getRepository(TestEntity).count();
+        if (count === 0) {
+            await this.connection
+                .getRepository(TestEntity)
+                .save([
+                    new TestEntity({ label: 'A', date: new Date('2020-01-05T10:00:00.000Z') }),
+                    new TestEntity({ label: 'B', date: new Date('2020-01-15T10:00:00.000Z') }),
+                    new TestEntity({ label: 'C', date: new Date('2020-01-25T10:00:00.000Z') }),
+                ]);
+        }
+    }
+}

+ 125 - 0
packages/core/e2e/list-query-builder.e2e-spec.ts

@@ -0,0 +1,125 @@
+import { mergeConfig } from '@vendure/core';
+import { createTestEnvironment } from '@vendure/testing';
+import gql from 'graphql-tag';
+import path from 'path';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+
+import { ListQueryPlugin } from './fixtures/list-query-plugin';
+import { fixPostgresTimezone } from './utils/fix-pg-timezone';
+
+fixPostgresTimezone();
+
+describe('ListQueryBuilder', () => {
+    const { server, adminClient } = createTestEnvironment(
+        mergeConfig(testConfig, {
+            plugins: [ListQueryPlugin],
+        }),
+    );
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    function getItemLabels(items: any[]): string[] {
+        return items.map((x: any) => x.label).sort();
+    }
+
+    describe('date filtering', () => {
+        it('before', async () => {
+            const { testEntities } = await adminClient.query(GET_LIST, {
+                options: {
+                    filter: {
+                        date: {
+                            before: '2020-01-20T10:00:00.000Z',
+                        },
+                    },
+                },
+            });
+
+            expect(getItemLabels(testEntities.items)).toEqual(['A', 'B']);
+        });
+
+        it('before on same date', async () => {
+            const { testEntities } = await adminClient.query(GET_LIST, {
+                options: {
+                    filter: {
+                        date: {
+                            before: '2020-01-15T17:00:00.000Z',
+                        },
+                    },
+                },
+            });
+
+            expect(getItemLabels(testEntities.items)).toEqual(['A', 'B']);
+        });
+
+        it('after', async () => {
+            const { testEntities } = await adminClient.query(GET_LIST, {
+                options: {
+                    filter: {
+                        date: {
+                            after: '2020-01-20T10:00:00.000Z',
+                        },
+                    },
+                },
+            });
+
+            expect(getItemLabels(testEntities.items)).toEqual(['C']);
+        });
+
+        it('after on same date', async () => {
+            const { testEntities } = await adminClient.query(GET_LIST, {
+                options: {
+                    filter: {
+                        date: {
+                            after: '2020-01-25T09:00:00.000Z',
+                        },
+                    },
+                },
+            });
+
+            expect(getItemLabels(testEntities.items)).toEqual(['C']);
+        });
+
+        it('between', async () => {
+            const { testEntities } = await adminClient.query(GET_LIST, {
+                options: {
+                    filter: {
+                        date: {
+                            between: {
+                                start: '2020-01-10T10:00:00.000Z',
+                                end: '2020-01-20T10:00:00.000Z',
+                            },
+                        },
+                    },
+                },
+            });
+
+            expect(getItemLabels(testEntities.items)).toEqual(['B']);
+        });
+    });
+});
+
+const GET_LIST = gql`
+    query GetTestEntities($options: TestEntityListOptions) {
+        testEntities(options: $options) {
+            totalItems
+            items {
+                id
+                label
+                date
+            }
+        }
+    }
+`;

+ 6 - 6
packages/core/src/common/types/common-types.ts

@@ -51,7 +51,7 @@ export interface ListQueryOptions<T extends VendureEntity> {
  * nullable fields have the type `field?: <type> | null`.
  */
 export type NullOptionals<T> = {
-    [K in keyof T]: undefined extends T[K] ? NullOptionals<T[K]> | null : NullOptionals<T[K]>;
+    [K in keyof T]: undefined extends T[K] ? NullOptionals<T[K]> | null : NullOptionals<T[K]>
 };
 
 export type SortOrder = 'ASC' | 'DESC';
@@ -103,13 +103,13 @@ export interface NumberOperators {
 }
 
 export interface DateRange {
-    start: string;
-    end: string;
+    start: Date;
+    end: Date;
 }
 
 export interface DateOperators {
-    eq?: string;
-    before?: string;
-    after?: string;
+    eq?: Date;
+    before?: Date;
+    after?: Date;
     between?: DateRange;
 }

+ 12 - 9
packages/core/src/service/helpers/list-query-builder/parse-filter-params.spec.ts

@@ -186,12 +186,12 @@ describe('parseFilterParams()', () => {
             connection.setColumns(Product, [{ propertyName: 'createdAt', type: 'datetime' }]);
             const filterParams: FilterParameter<Product> = {
                 createdAt: {
-                    eq: '2018-01-01',
+                    eq: new Date('2018-01-01T10:00:00.000Z'),
                 },
             };
             const result = parseFilterParams(connection as any, Product, filterParams);
             expect(result[0].clause).toBe(`product.createdAt = :arg1`);
-            expect(result[0].parameters).toEqual({ arg1: '2018-01-01' });
+            expect(result[0].parameters).toEqual({ arg1: '2018-01-01 10:00:00.000' });
         });
 
         describe('before', () => {
@@ -199,12 +199,12 @@ describe('parseFilterParams()', () => {
             connection.setColumns(Product, [{ propertyName: 'createdAt', type: 'datetime' }]);
             const filterParams: FilterParameter<Product> = {
                 createdAt: {
-                    before: '2018-01-01',
+                    before: new Date('2018-01-01T10:00:00.000Z'),
                 },
             };
             const result = parseFilterParams(connection as any, Product, filterParams);
             expect(result[0].clause).toBe(`product.createdAt < :arg1`);
-            expect(result[0].parameters).toEqual({ arg1: '2018-01-01' });
+            expect(result[0].parameters).toEqual({ arg1: '2018-01-01 10:00:00.000' });
         });
 
         describe('after', () => {
@@ -212,12 +212,12 @@ describe('parseFilterParams()', () => {
             connection.setColumns(Product, [{ propertyName: 'createdAt', type: 'datetime' }]);
             const filterParams: FilterParameter<Product> = {
                 createdAt: {
-                    after: '2018-01-01',
+                    after: new Date('2018-01-01T10:00:00.000Z'),
                 },
             };
             const result = parseFilterParams(connection as any, Product, filterParams);
             expect(result[0].clause).toBe(`product.createdAt > :arg1`);
-            expect(result[0].parameters).toEqual({ arg1: '2018-01-01' });
+            expect(result[0].parameters).toEqual({ arg1: '2018-01-01 10:00:00.000' });
         });
 
         describe('between', () => {
@@ -226,14 +226,17 @@ describe('parseFilterParams()', () => {
             const filterParams: FilterParameter<Product> = {
                 createdAt: {
                     between: {
-                        start: '2018-01-01',
-                        end: '2018-02-01',
+                        start: new Date('2018-01-01T10:00:00.000Z'),
+                        end: new Date('2018-02-01T10:00:00.000Z'),
                     },
                 },
             };
             const result = parseFilterParams(connection as any, Product, filterParams);
             expect(result[0].clause).toBe(`product.createdAt BETWEEN :arg1_a AND :arg1_b`);
-            expect(result[0].parameters).toEqual({ arg1_a: '2018-01-01', arg1_b: '2018-02-01' });
+            expect(result[0].parameters).toEqual({
+                arg1_a: '2018-01-01 10:00:00.000',
+                arg1_b: '2018-02-01 10:00:00.000',
+            });
         });
     });
 

+ 19 - 4
packages/core/src/service/helpers/list-query-builder/parse-filter-params.ts

@@ -1,6 +1,7 @@
 import { Type } from '@vendure/common/lib/shared-types';
 import { assertNever } from '@vendure/common/lib/shared-utils';
 import { Connection, ConnectionOptions } from 'typeorm';
+import { DateUtils } from 'typeorm/util/DateUtils';
 
 import { UserInputError } from '../../../common/error/errors';
 import {
@@ -73,7 +74,7 @@ function buildWhereCondition(
         case 'eq':
             return {
                 clause: `${fieldName} = :arg${argIndex}`,
-                parameters: { [`arg${argIndex}`]: operand },
+                parameters: { [`arg${argIndex}`]: convertDate(operand) },
             };
         case 'contains':
             const LIKE = dbType === 'postgres' ? 'ILIKE' : 'LIKE';
@@ -85,13 +86,13 @@ function buildWhereCondition(
         case 'before':
             return {
                 clause: `${fieldName} < :arg${argIndex}`,
-                parameters: { [`arg${argIndex}`]: operand },
+                parameters: { [`arg${argIndex}`]: convertDate(operand) },
             };
         case 'gt':
         case 'after':
             return {
                 clause: `${fieldName} > :arg${argIndex}`,
-                parameters: { [`arg${argIndex}`]: operand },
+                parameters: { [`arg${argIndex}`]: convertDate(operand) },
             };
         case 'lte':
             return {
@@ -106,7 +107,10 @@ function buildWhereCondition(
         case 'between':
             return {
                 clause: `${fieldName} BETWEEN :arg${argIndex}_a AND :arg${argIndex}_b`,
-                parameters: { [`arg${argIndex}_a`]: operand.start, [`arg${argIndex}_b`]: operand.end },
+                parameters: {
+                    [`arg${argIndex}_a`]: convertDate(operand.start),
+                    [`arg${argIndex}_b`]: convertDate(operand.end),
+                },
             };
         default:
             assertNever(operator);
@@ -116,3 +120,14 @@ function buildWhereCondition(
         parameters: {},
     };
 }
+
+/**
+ * Converts a JS Date object to a string format recognized by all DB engines.
+ * See https://github.com/vendure-ecommerce/vendure/issues/251
+ */
+function convertDate(input: Date | string | number): string | number {
+    if (input instanceof Date) {
+        return DateUtils.mixedDateToUtcDatetimeString(input);
+    }
+    return input;
+}

+ 1 - 1
scripts/codegen/generate-graphql-types.ts

@@ -9,7 +9,7 @@ import { ADMIN_API_PATH, API_PORT, SHOP_API_PATH } from '../../packages/common/s
 import { downloadIntrospectionSchema } from './download-introspection-schema';
 
 const CLIENT_QUERY_FILES = path.join(__dirname, '../../packages/admin-ui/src/app/data/definitions/**/*.ts');
-const E2E_ADMIN_QUERY_FILES = path.join(__dirname, '../../packages/core/e2e/**/!(import.e2e-spec|plugin.e2e-spec|shop-definitions|custom-fields.e2e-spec).ts');
+const E2E_ADMIN_QUERY_FILES = path.join(__dirname, '../../packages/core/e2e/**/!(import.e2e-spec|plugin.e2e-spec|shop-definitions|custom-fields.e2e-spec|list-query-builder.e2e-spec).ts');
 const E2E_SHOP_QUERY_FILES = [
     path.join(__dirname, '../../packages/core/e2e/graphql/shop-definitions.ts'),
 ];