Browse Source

fix(core): Allow list queries to filter/sort on calculated columns

Fixes #642
Michael Bromley 5 years ago
parent
commit
53253871c5

+ 183 - 130
packages/core/e2e/collection.e2e-spec.ts

@@ -330,159 +330,211 @@ describe('Collection resolver', () => {
         });
     });
 
-    it('collection by id', async () => {
-        const result = await adminClient.query<GetCollection.Query, GetCollection.Variables>(GET_COLLECTION, {
-            id: computersCollection.id,
+    describe('querying', () => {
+        it('collection by id', async () => {
+            const result = await adminClient.query<GetCollection.Query, GetCollection.Variables>(
+                GET_COLLECTION,
+                {
+                    id: computersCollection.id,
+                },
+            );
+            if (!result.collection) {
+                fail(`did not return the collection`);
+                return;
+            }
+            expect(result.collection.id).toBe(computersCollection.id);
         });
-        if (!result.collection) {
-            fail(`did not return the collection`);
-            return;
-        }
-        expect(result.collection.id).toBe(computersCollection.id);
-    });
 
-    it('collection by slug', async () => {
-        const result = await adminClient.query<GetCollection.Query, GetCollection.Variables>(GET_COLLECTION, {
-            slug: computersCollection.slug,
+        it('collection by slug', async () => {
+            const result = await adminClient.query<GetCollection.Query, GetCollection.Variables>(
+                GET_COLLECTION,
+                {
+                    slug: computersCollection.slug,
+                },
+            );
+            if (!result.collection) {
+                fail(`did not return the collection`);
+                return;
+            }
+            expect(result.collection.id).toBe(computersCollection.id);
         });
-        if (!result.collection) {
-            fail(`did not return the collection`);
-            return;
-        }
-        expect(result.collection.id).toBe(computersCollection.id);
-    });
 
-    it(
-        'throws if neither id nor slug provided',
-        assertThrowsWithMessage(async () => {
-            await adminClient.query<GetCollection.Query, GetCollection.Variables>(GET_COLLECTION, {});
-        }, 'Either the Collection id or slug must be provided'),
-    );
-
-    it(
-        'throws if id and slug do not refer to the same Product',
-        assertThrowsWithMessage(async () => {
-            await adminClient.query<GetCollection.Query, GetCollection.Variables>(GET_COLLECTION, {
-                id: computersCollection.id,
-                slug: pearCollection.slug,
-            });
-        }, 'The provided id and slug refer to different Collections'),
-    );
+        it(
+            'throws if neither id nor slug provided',
+            assertThrowsWithMessage(async () => {
+                await adminClient.query<GetCollection.Query, GetCollection.Variables>(GET_COLLECTION, {});
+            }, 'Either the Collection id or slug must be provided'),
+        );
 
-    it('parent field', async () => {
-        const result = await adminClient.query<GetCollection.Query, GetCollection.Variables>(GET_COLLECTION, {
-            id: computersCollection.id,
-        });
-        if (!result.collection) {
-            fail(`did not return the collection`);
-            return;
-        }
-        expect(result.collection.parent!.name).toBe('Electronics');
-    });
+        it(
+            'throws if id and slug do not refer to the same Product',
+            assertThrowsWithMessage(async () => {
+                await adminClient.query<GetCollection.Query, GetCollection.Variables>(GET_COLLECTION, {
+                    id: computersCollection.id,
+                    slug: pearCollection.slug,
+                });
+            }, 'The provided id and slug refer to different Collections'),
+        );
 
-    // Tests fix for https://github.com/vendure-ecommerce/vendure/issues/361
-    it('parent field resolved by CollectionEntityResolver', async () => {
-        const { product } = await adminClient.query<
-            GetProductCollectionsWithParent.Query,
-            GetProductCollectionsWithParent.Variables
-        >(GET_PRODUCT_COLLECTIONS_WITH_PARENT, {
-            id: 'T_1',
+        it('parent field', async () => {
+            const result = await adminClient.query<GetCollection.Query, GetCollection.Variables>(
+                GET_COLLECTION,
+                {
+                    id: computersCollection.id,
+                },
+            );
+            if (!result.collection) {
+                fail(`did not return the collection`);
+                return;
+            }
+            expect(result.collection.parent!.name).toBe('Electronics');
         });
 
-        expect(product?.collections.length).toBe(3);
-        expect(product?.collections.sort(sortById)).toEqual([
-            {
-                id: 'T_3',
-                name: 'Electronics',
-                parent: {
-                    id: 'T_1',
-                    name: '__root_collection__',
-                },
-            },
-            {
-                id: 'T_4',
-                name: 'Computers',
-                parent: {
+        // Tests fix for https://github.com/vendure-ecommerce/vendure/issues/361
+        it('parent field resolved by CollectionEntityResolver', async () => {
+            const { product } = await adminClient.query<
+                GetProductCollectionsWithParent.Query,
+                GetProductCollectionsWithParent.Variables
+            >(GET_PRODUCT_COLLECTIONS_WITH_PARENT, {
+                id: 'T_1',
+            });
+
+            expect(product?.collections.length).toBe(3);
+            expect(product?.collections.sort(sortById)).toEqual([
+                {
                     id: 'T_3',
                     name: 'Electronics',
+                    parent: {
+                        id: 'T_1',
+                        name: '__root_collection__',
+                    },
                 },
-            },
-            {
-                id: 'T_5',
-                name: 'Pear',
-                parent: {
+                {
                     id: 'T_4',
                     name: 'Computers',
+                    parent: {
+                        id: 'T_3',
+                        name: 'Electronics',
+                    },
                 },
-            },
-        ]);
-    });
+                {
+                    id: 'T_5',
+                    name: 'Pear',
+                    parent: {
+                        id: 'T_4',
+                        name: 'Computers',
+                    },
+                },
+            ]);
+        });
 
-    it('children field', async () => {
-        const result = await adminClient.query<GetCollection.Query, GetCollection.Variables>(GET_COLLECTION, {
-            id: electronicsCollection.id,
+        it('children field', async () => {
+            const result = await adminClient.query<GetCollection.Query, GetCollection.Variables>(
+                GET_COLLECTION,
+                {
+                    id: electronicsCollection.id,
+                },
+            );
+            if (!result.collection) {
+                fail(`did not return the collection`);
+                return;
+            }
+            expect(result.collection.children!.length).toBe(1);
+            expect(result.collection.children![0].name).toBe('Computers');
         });
-        if (!result.collection) {
-            fail(`did not return the collection`);
-            return;
-        }
-        expect(result.collection.children!.length).toBe(1);
-        expect(result.collection.children![0].name).toBe('Computers');
-    });
 
-    it('breadcrumbs', async () => {
-        const result = await adminClient.query<
-            GetCollectionBreadcrumbs.Query,
-            GetCollectionBreadcrumbs.Variables
-        >(GET_COLLECTION_BREADCRUMBS, {
-            id: pearCollection.id,
+        it('breadcrumbs', async () => {
+            const result = await adminClient.query<
+                GetCollectionBreadcrumbs.Query,
+                GetCollectionBreadcrumbs.Variables
+            >(GET_COLLECTION_BREADCRUMBS, {
+                id: pearCollection.id,
+            });
+            if (!result.collection) {
+                fail(`did not return the collection`);
+                return;
+            }
+            expect(result.collection.breadcrumbs).toEqual([
+                { id: 'T_1', name: ROOT_COLLECTION_NAME, slug: ROOT_COLLECTION_NAME },
+                {
+                    id: electronicsCollection.id,
+                    name: electronicsCollection.name,
+                    slug: electronicsCollection.slug,
+                },
+                {
+                    id: computersCollection.id,
+                    name: computersCollection.name,
+                    slug: computersCollection.slug,
+                },
+                { id: pearCollection.id, name: pearCollection.name, slug: pearCollection.slug },
+            ]);
         });
-        if (!result.collection) {
-            fail(`did not return the collection`);
-            return;
-        }
-        expect(result.collection.breadcrumbs).toEqual([
-            { id: 'T_1', name: ROOT_COLLECTION_NAME, slug: ROOT_COLLECTION_NAME },
-            {
-                id: electronicsCollection.id,
-                name: electronicsCollection.name,
-                slug: electronicsCollection.slug,
-            },
-            { id: computersCollection.id, name: computersCollection.name, slug: computersCollection.slug },
-            { id: pearCollection.id, name: pearCollection.name, slug: pearCollection.slug },
-        ]);
-    });
 
-    it('breadcrumbs for root collection', async () => {
-        const result = await adminClient.query<
-            GetCollectionBreadcrumbs.Query,
-            GetCollectionBreadcrumbs.Variables
-        >(GET_COLLECTION_BREADCRUMBS, {
-            id: 'T_1',
+        it('breadcrumbs for root collection', async () => {
+            const result = await adminClient.query<
+                GetCollectionBreadcrumbs.Query,
+                GetCollectionBreadcrumbs.Variables
+            >(GET_COLLECTION_BREADCRUMBS, {
+                id: 'T_1',
+            });
+            if (!result.collection) {
+                fail(`did not return the collection`);
+                return;
+            }
+            expect(result.collection.breadcrumbs).toEqual([
+                { id: 'T_1', name: ROOT_COLLECTION_NAME, slug: ROOT_COLLECTION_NAME },
+            ]);
         });
-        if (!result.collection) {
-            fail(`did not return the collection`);
-            return;
-        }
-        expect(result.collection.breadcrumbs).toEqual([
-            { id: 'T_1', name: ROOT_COLLECTION_NAME, slug: ROOT_COLLECTION_NAME },
-        ]);
-    });
 
-    it('collections.assets', async () => {
-        const { collections } = await adminClient.query<GetCollectionsWithAssets.Query>(gql`
-            query GetCollectionsWithAssets {
-                collections {
-                    items {
-                        assets {
-                            name
+        it('collections.assets', async () => {
+            const { collections } = await adminClient.query<GetCollectionsWithAssets.Query>(gql`
+                query GetCollectionsWithAssets {
+                    collections {
+                        items {
+                            assets {
+                                name
+                            }
                         }
                     }
                 }
-            }
-        `);
+            `);
 
-        expect(collections.items[0].assets).toBeDefined();
+            expect(collections.items[0].assets).toBeDefined();
+        });
+
+        // https://github.com/vendure-ecommerce/vendure/issues/642
+        it('sorting on Collection.productVariants.price', async () => {
+            const { collection } = await adminClient.query<GetCollection.Query, GetCollection.Variables>(
+                GET_COLLECTION,
+                {
+                    id: computersCollection.id,
+                    variantListOptions: {
+                        sort: {
+                            price: SortOrder.ASC,
+                        },
+                    },
+                },
+            );
+            expect(collection!.productVariants.items.map(i => i.price)).toEqual([
+                3799,
+                5374,
+                6900,
+                7489,
+                7896,
+                9299,
+                13435,
+                14374,
+                16994,
+                93120,
+                94920,
+                108720,
+                109995,
+                129900,
+                139900,
+                219900,
+                229900,
+            ]);
+        });
     });
 
     describe('moveCollection', () => {
@@ -1325,13 +1377,14 @@ describe('Collection resolver', () => {
 });
 
 export const GET_COLLECTION = gql`
-    query GetCollection($id: ID, $slug: String) {
+    query GetCollection($id: ID, $slug: String, $variantListOptions: ProductVariantListOptions) {
         collection(id: $id, slug: $slug) {
             ...Collection
-            productVariants {
+            productVariants(options: $variantListOptions) {
                 items {
                     id
                     name
+                    price
                 }
             }
         }

+ 67 - 8
packages/core/e2e/fixtures/test-plugins/list-query-plugin.ts

@@ -1,4 +1,5 @@
 import { Args, Query, Resolver } from '@nestjs/graphql';
+import { ID } from '@vendure/common/lib/shared-types';
 import {
     ListQueryBuilder,
     OnVendureBootstrap,
@@ -8,7 +9,10 @@ import {
     VendurePlugin,
 } from '@vendure/core';
 import gql from 'graphql-tag';
-import { Column, Entity } from 'typeorm';
+import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
+
+import { Calculated } from '../../../src/common/calculated-decorator';
+import { EntityId } from '../../../src/entity/entity-id.decorator';
 
 @Entity()
 export class TestEntity extends VendureEntity {
@@ -29,6 +33,40 @@ export class TestEntity extends VendureEntity {
 
     @Column()
     date: Date;
+
+    @Calculated({ expression: 'LENGTH(description)' })
+    get descriptionLength() {
+        return this.description.length || 0;
+    }
+
+    @Calculated({
+        relations: ['prices'],
+        expression: 'prices.price',
+    })
+    get price() {
+        return this.activePrice;
+    }
+
+    // calculated at runtime
+    activePrice: number;
+
+    @OneToMany(type => TestEntityPrice, price => price.parent)
+    prices: TestEntityPrice[];
+}
+
+@Entity()
+export class TestEntityPrice extends VendureEntity {
+    constructor(input: Partial<TestEntityPrice>) {
+        super(input);
+    }
+
+    @EntityId() channelId: ID;
+
+    @Column()
+    price: number;
+
+    @ManyToOne(type => TestEntity, parent => parent.prices)
+    parent: TestEntity;
 }
 
 @Resolver()
@@ -41,6 +79,11 @@ export class ListQueryResolver {
             .build(TestEntity, args.options)
             .getManyAndCount()
             .then(([items, totalItems]) => {
+                for (const item of items) {
+                    if (item.prices && item.prices.length) {
+                        item.activePrice = item.prices[0].price;
+                    }
+                }
                 return {
                     items,
                     totalItems,
@@ -59,6 +102,8 @@ const adminApiExtensions = gql`
         active: Boolean!
         order: Int!
         date: DateTime!
+        descriptionLength: Int!
+        price: Int!
     }
 
     type TestEntityList implements PaginatedList {
@@ -75,7 +120,7 @@ const adminApiExtensions = gql`
 
 @VendurePlugin({
     imports: [PluginCommonModule],
-    entities: [TestEntity],
+    entities: [TestEntity, TestEntityPrice],
     adminApiExtensions: {
         schema: adminApiExtensions,
         resolvers: [ListQueryResolver],
@@ -87,43 +132,57 @@ export class ListQueryPlugin implements OnVendureBootstrap {
     async onVendureBootstrap() {
         const count = await this.connection.getRepository(TestEntity).count();
         if (count === 0) {
-            await this.connection.getRepository(TestEntity).save([
+            const testEntities = await this.connection.getRepository(TestEntity).save([
                 new TestEntity({
                     label: 'A',
-                    description: 'Lorem ipsum',
+                    description: 'Lorem ipsum', // 11
                     date: new Date('2020-01-05T10:00:00.000Z'),
                     active: true,
                     order: 0,
                 }),
                 new TestEntity({
                     label: 'B',
-                    description: 'dolor sit',
+                    description: 'dolor sit', // 9
                     date: new Date('2020-01-15T10:00:00.000Z'),
                     active: true,
                     order: 1,
                 }),
                 new TestEntity({
                     label: 'C',
-                    description: 'consectetur adipiscing',
+                    description: 'consectetur adipiscing', // 22
                     date: new Date('2020-01-25T10:00:00.000Z'),
                     active: false,
                     order: 2,
                 }),
                 new TestEntity({
                     label: 'D',
-                    description: 'eiusmod tempor',
+                    description: 'eiusmod tempor', // 14
                     date: new Date('2020-01-30T10:00:00.000Z'),
                     active: true,
                     order: 3,
                 }),
                 new TestEntity({
                     label: 'E',
-                    description: 'incididunt ut',
+                    description: 'incididunt ut', // 13
                     date: new Date('2020-02-05T10:00:00.000Z'),
                     active: false,
                     order: 4,
                 }),
             ]);
+            for (const testEntity of testEntities) {
+                await this.connection.getRepository(TestEntityPrice).save([
+                    new TestEntityPrice({
+                        price: testEntity.description.length,
+                        channelId: 1,
+                        parent: testEntity,
+                    }),
+                    new TestEntityPrice({
+                        price: testEntity.description.length * 100,
+                        channelId: 2,
+                        parent: testEntity,
+                    }),
+                ]);
+            }
         }
     }
 }

+ 2 - 0
packages/core/e2e/graphql/fragments.ts

@@ -319,6 +319,8 @@ export const ORDER_FRAGMENT = gql`
         code
         state
         total
+        totalWithTax
+        totalQuantity
         currencyCode
         customer {
             id

+ 14 - 14
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -4497,11 +4497,14 @@ export type GetProductsWithVariantIdsQuery = {
 export type GetCollectionQueryVariables = Exact<{
     id?: Maybe<Scalars['ID']>;
     slug?: Maybe<Scalars['String']>;
+    variantListOptions?: Maybe<ProductVariantListOptions>;
 }>;
 
 export type GetCollectionQuery = {
     collection?: Maybe<
-        { productVariants: { items: Array<Pick<ProductVariant, 'id' | 'name'>> } } & CollectionFragment
+        {
+            productVariants: { items: Array<Pick<ProductVariant, 'id' | 'name' | 'price'>> };
+        } & CollectionFragment
     >;
 };
 
@@ -4962,7 +4965,15 @@ export type ShippingAddressFragment = Pick<
 
 export type OrderFragment = Pick<
     Order,
-    'id' | 'createdAt' | 'updatedAt' | 'code' | 'state' | 'total' | 'currencyCode'
+    | 'id'
+    | 'createdAt'
+    | 'updatedAt'
+    | 'code'
+    | 'state'
+    | 'total'
+    | 'totalWithTax'
+    | 'totalQuantity'
+    | 'currencyCode'
 > & { customer?: Maybe<Pick<Customer, 'id' | 'firstName' | 'lastName'>> };
 
 export type OrderItemFragment = Pick<
@@ -5853,7 +5864,7 @@ export type GetOrderListWithQtyQuery = {
     orders: {
         items: Array<
             Pick<Order, 'id' | 'code' | 'totalQuantity'> & {
-                lines: Array<Pick<OrderLine, 'id' | 'quantity'> & { items: Array<Pick<OrderItem, 'id'>> }>;
+                lines: Array<Pick<OrderLine, 'id' | 'quantity'>>;
             }
         >;
     };
@@ -7863,17 +7874,6 @@ export namespace GetOrderListWithQty {
             >['lines']
         >[number]
     >;
-    export type _Items = NonNullable<
-        NonNullable<
-            NonNullable<
-                NonNullable<
-                    NonNullable<
-                        NonNullable<NonNullable<GetOrderListWithQtyQuery['orders']>['items']>[number]
-                    >['lines']
-                >[number]
-            >['items']
-        >[number]
-    >;
 }
 
 export namespace UpdateProductOptionGroup {

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

@@ -7,6 +7,7 @@ import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 
 import { ListQueryPlugin } from './fixtures/test-plugins/list-query-plugin';
+import { SortOrder } from './graphql/generated-e2e-admin-types';
 import { fixPostgresTimezone } from './utils/fix-pg-timezone';
 
 fixPostgresTimezone();
@@ -372,6 +373,103 @@ describe('ListQueryBuilder', () => {
             expect(getItemLabels(testEntities.items)).toEqual(['B']);
         });
     });
+
+    describe('sorting', () => {
+        it('sort by string', async () => {
+            const { testEntities } = await adminClient.query(GET_LIST, {
+                options: {
+                    sort: {
+                        label: SortOrder.DESC,
+                    },
+                },
+            });
+
+            expect(testEntities.items.map((x: any) => x.label)).toEqual(['E', 'D', 'C', 'B', 'A']);
+        });
+
+        it('sort by number', async () => {
+            const { testEntities } = await adminClient.query(GET_LIST, {
+                options: {
+                    sort: {
+                        order: SortOrder.DESC,
+                    },
+                },
+            });
+            expect(testEntities.items.map((x: any) => x.label)).toEqual(['E', 'D', 'C', 'B', 'A']);
+        });
+
+        it('sort by date', async () => {
+            const { testEntities } = await adminClient.query(GET_LIST, {
+                options: {
+                    sort: {
+                        date: SortOrder.DESC,
+                    },
+                },
+            });
+            expect(testEntities.items.map((x: any) => x.label)).toEqual(['E', 'D', 'C', 'B', 'A']);
+        });
+
+        it('sort by ID', async () => {
+            const { testEntities } = await adminClient.query(GET_LIST, {
+                options: {
+                    sort: {
+                        id: SortOrder.DESC,
+                    },
+                },
+            });
+            expect(testEntities.items.map((x: any) => x.label)).toEqual(['E', 'D', 'C', 'B', 'A']);
+        });
+    });
+
+    describe('calculated fields', () => {
+        it('filter by simple calculated property', async () => {
+            const { testEntities } = await adminClient.query(GET_LIST, {
+                options: {
+                    filter: {
+                        descriptionLength: {
+                            lt: 12,
+                        },
+                    },
+                },
+            });
+            expect(getItemLabels(testEntities.items)).toEqual(['A', 'B']);
+        });
+
+        it('filter by calculated property with join', async () => {
+            const { testEntities } = await adminClient.query(GET_LIST, {
+                options: {
+                    filter: {
+                        price: {
+                            lt: 14,
+                        },
+                    },
+                },
+            });
+            expect(getItemLabels(testEntities.items)).toEqual(['A', 'B', 'E']);
+        });
+
+        it('sort by simple calculated property', async () => {
+            const { testEntities } = await adminClient.query(GET_LIST, {
+                options: {
+                    sort: {
+                        descriptionLength: SortOrder.ASC,
+                    },
+                },
+            });
+            expect(testEntities.items.map((x: any) => x.label)).toEqual(['B', 'A', 'E', 'D', 'C']);
+        });
+
+        it('sort by calculated property with join', async () => {
+            const { testEntities } = await adminClient.query(GET_LIST, {
+                options: {
+                    sort: {
+                        price: SortOrder.ASC,
+                    },
+                },
+            });
+            expect(testEntities.items.map((x: any) => x.label)).toEqual(['B', 'A', 'E', 'D', 'C']);
+        });
+    });
 });
 
 const GET_LIST = gql`

+ 80 - 10
packages/core/e2e/order.e2e-spec.ts

@@ -151,16 +151,6 @@ describe('Orders resolver', () => {
         await server.destroy();
     });
 
-    it('orders', async () => {
-        const result = await adminClient.query<GetOrderList.Query>(GET_ORDERS_LIST);
-        expect(result.orders.items.map(o => o.id).sort()).toEqual(['T_1', 'T_2']);
-    });
-
-    it('order', async () => {
-        const result = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, { id: 'T_2' });
-        expect(result.order!.id).toBe('T_2');
-    });
-
     it('order history initially contains Created -> AddingItems transition', async () => {
         const { order } = await adminClient.query<GetOrderHistory.Query, GetOrderHistory.Variables>(
             GET_ORDER_HISTORY,
@@ -178,6 +168,86 @@ describe('Orders resolver', () => {
         ]);
     });
 
+    describe('querying', () => {
+        it('orders', async () => {
+            const result = await adminClient.query<GetOrderList.Query>(GET_ORDERS_LIST);
+            expect(result.orders.items.map(o => o.id).sort()).toEqual(['T_1', 'T_2']);
+        });
+
+        it('order', async () => {
+            const result = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: 'T_2',
+            });
+            expect(result.order!.id).toBe('T_2');
+        });
+
+        it('sort by total', async () => {
+            const result = await adminClient.query<GetOrderList.Query, GetOrderList.Variables>(
+                GET_ORDERS_LIST,
+                {
+                    options: {
+                        sort: {
+                            total: SortOrder.DESC,
+                        },
+                    },
+                },
+            );
+            expect(result.orders.items.map(o => pick(o, ['id', 'total']))).toEqual([
+                { id: 'T_2', total: 799600 },
+                { id: 'T_1', total: 269800 },
+            ]);
+        });
+
+        it('filter by totalWithTax', async () => {
+            const result = await adminClient.query<GetOrderList.Query, GetOrderList.Variables>(
+                GET_ORDERS_LIST,
+                {
+                    options: {
+                        filter: {
+                            totalWithTax: { gt: 323760 },
+                        },
+                    },
+                },
+            );
+            expect(result.orders.items.map(o => pick(o, ['id', 'totalWithTax']))).toEqual([
+                { id: 'T_2', totalWithTax: 959520 },
+            ]);
+        });
+
+        it('sort by totalQuantity', async () => {
+            const result = await adminClient.query<GetOrderList.Query, GetOrderList.Variables>(
+                GET_ORDERS_LIST,
+                {
+                    options: {
+                        sort: {
+                            totalQuantity: SortOrder.DESC,
+                        },
+                    },
+                },
+            );
+            expect(result.orders.items.map(o => pick(o, ['id', 'totalQuantity']))).toEqual([
+                { id: 'T_2', totalQuantity: 4 },
+                { id: 'T_1', totalQuantity: 2 },
+            ]);
+        });
+
+        it('filter by totalQuantity', async () => {
+            const result = await adminClient.query<GetOrderList.Query, GetOrderList.Variables>(
+                GET_ORDERS_LIST,
+                {
+                    options: {
+                        filter: {
+                            totalQuantity: { eq: 4 },
+                        },
+                    },
+                },
+            );
+            expect(result.orders.items.map(o => pick(o, ['id', 'totalQuantity']))).toEqual([
+                { id: 'T_2', totalQuantity: 4 },
+            ]);
+        });
+    });
+
     describe('payments', () => {
         let firstOrderCode: string;
         let firstOrderId: string;

+ 39 - 5
packages/core/src/common/calculated-decorator.ts

@@ -1,18 +1,52 @@
+import { OrderByCondition, SelectQueryBuilder } from 'typeorm';
+
+/**
+ * The property name we use to store the CalculatedColumnDefinitions to the
+ * entity class.
+ */
 export const CALCULATED_PROPERTIES = '__calculatedProperties__';
 
 /**
+ * Optional metadata used to tell the ListQueryBuilder how to deal with
+ * calculated columns when sorting or filtering.
+ */
+export interface CalculatedColumnQueryInstruction {
+    relations?: string[];
+    query?: (qb: SelectQueryBuilder<any>) => void;
+    expression: string;
+}
+
+export interface CalculatedColumnDefinition {
+    name: string | symbol;
+    listQuery?: CalculatedColumnQueryInstruction;
+}
+
+/**
+ * @description
  * Used to define calculated entity getters. The decorator simply attaches an array of "calculated"
  * property names to the entity's prototype. This array is then used by the {@link CalculatedPropertySubscriber}
  * to transfer the getter function from the prototype to the entity instance.
  */
-export function Calculated(): MethodDecorator {
-    return (target: object & { [key: string]: any }, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
+export function Calculated(queryInstruction?: CalculatedColumnQueryInstruction): MethodDecorator {
+    return (
+        target: object & { [key: string]: any },
+        propertyKey: string | symbol,
+        descriptor: PropertyDescriptor,
+    ) => {
+        const definition: CalculatedColumnDefinition = {
+            name: propertyKey,
+            listQuery: queryInstruction,
+        };
         if (target[CALCULATED_PROPERTIES]) {
-            if (!target[CALCULATED_PROPERTIES].includes(propertyKey)) {
-                target[CALCULATED_PROPERTIES].push(propertyKey);
+            if (
+                !target[CALCULATED_PROPERTIES].map((p: CalculatedColumnDefinition) => p.name).includes(
+                    definition.name,
+                )
+            ) {
+                target[CALCULATED_PROPERTIES].push(definition);
             }
         } else {
-            target[CALCULATED_PROPERTIES] = [propertyKey];
+            target[CALCULATED_PROPERTIES] = [definition];
         }
     };
 }

+ 20 - 3
packages/core/src/entity/order/order.entity.ts

@@ -135,17 +135,34 @@ export class Order extends VendureEntity implements ChannelAware, HasCustomField
         return [...groupedAdjustments.values()];
     }
 
-    @Calculated()
+    @Calculated({ expression: 'subTotal + shipping' })
     get total(): number {
         return this.subTotal + (this.shipping || 0);
     }
 
-    @Calculated()
+    @Calculated({ expression: 'subTotalWithTax + shippingWithTax' })
     get totalWithTax(): number {
         return this.subTotalWithTax + (this.shippingWithTax || 0);
     }
 
-    @Calculated()
+    @Calculated({
+        query: qb => {
+            qb.leftJoin(
+                qb1 => {
+                    return qb1
+                        .from(Order, 'order')
+                        .select('COUNT(DISTINCT items.id)', 'qty')
+                        .addSelect('order.id', 'oid')
+                        .leftJoin('order.lines', 'lines')
+                        .leftJoin('lines.items', 'items')
+                        .groupBy('order.id');
+                },
+                't1',
+                't1.oid = order.id',
+            );
+        },
+        expression: 't1.qty',
+    })
     get totalQuantity(): number {
         return summate(this.lines, 'quantity');
     }

+ 8 - 2
packages/core/src/entity/product-variant/product-variant.entity.ts

@@ -69,7 +69,10 @@ export class ProductVariant
      */
     currencyCode: CurrencyCode;
 
-    @Calculated()
+    @Calculated({
+        relations: ['productVariantPrices'],
+        expression: 'productVariantPrices.price',
+    })
     get price(): number {
         if (this.listPrice == null) {
             return 0;
@@ -77,7 +80,10 @@ export class ProductVariant
         return this.listPriceIncludesTax ? this.taxRateApplied.netPriceOf(this.listPrice) : this.listPrice;
     }
 
-    @Calculated()
+    @Calculated({
+        relations: ['productVariantPrices'],
+        expression: 'productVariantPrices.price',
+    })
     get priceWithTax(): number {
         if (this.listPrice == null) {
             return 0;

+ 13 - 6
packages/core/src/entity/subscribers.ts

@@ -1,6 +1,10 @@
 import { EntitySubscriberInterface, EventSubscriber, InsertEvent } from 'typeorm';
 
-import { CALCULATED_PROPERTIES } from '../common/calculated-decorator';
+import { CalculatedColumnDefinition, CALCULATED_PROPERTIES } from '../common/calculated-decorator';
+
+interface EntityPrototype {
+    [CALCULATED_PROPERTIES]: CalculatedColumnDefinition[];
+}
 
 @EventSubscriber()
 export class CalculatedPropertySubscriber implements EntitySubscriberInterface {
@@ -19,15 +23,18 @@ export class CalculatedPropertySubscriber implements EntitySubscriberInterface {
      */
     private moveCalculatedGettersToInstance(entity: any) {
         if (entity) {
-            const prototype = Object.getPrototypeOf(entity);
+            const prototype: EntityPrototype = Object.getPrototypeOf(entity);
             if (prototype.hasOwnProperty(CALCULATED_PROPERTIES)) {
-                for (const property of prototype[CALCULATED_PROPERTIES]) {
-                    const getterDescriptor = Object.getOwnPropertyDescriptor(prototype, property);
+                for (const calculatedPropertyDef of prototype[CALCULATED_PROPERTIES]) {
+                    const getterDescriptor = Object.getOwnPropertyDescriptor(
+                        prototype,
+                        calculatedPropertyDef.name,
+                    );
                     const getFn = getterDescriptor && getterDescriptor.get;
-                    if (getFn && !entity.hasOwnProperty(property)) {
+                    if (getFn && !entity.hasOwnProperty(calculatedPropertyDef.name)) {
                         const boundGetFn = getFn.bind(entity);
                         Object.defineProperties(entity, {
-                            [property]: {
+                            [calculatedPropertyDef.name]: {
                                 get: () => boundGetFn(),
                                 enumerable: true,
                             },

+ 18 - 0
packages/core/src/service/helpers/list-query-builder/get-column-metadata.ts → packages/core/src/service/helpers/list-query-builder/connection-utils.ts

@@ -2,6 +2,12 @@ import { Type } from '@vendure/common/lib/shared-types';
 import { Connection } from 'typeorm';
 import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata';
 
+import { CalculatedColumnDefinition, CALCULATED_PROPERTIES } from '../../../common/calculated-decorator';
+
+/**
+ * @description
+ * Returns TypeORM ColumnMetadata for the given entity type.
+ */
 export function getColumnMetadata<T>(connection: Connection, entity: Type<T>) {
     const metadata = connection.getMetadata(entity);
     const columns = metadata.columns;
@@ -16,3 +22,15 @@ export function getColumnMetadata<T>(connection: Connection, entity: Type<T>) {
     const alias = metadata.name.toLowerCase();
     return { columns, translationColumns, alias };
 }
+
+export function getEntityAlias<T>(connection: Connection, entity: Type<T>): string {
+    return connection.getMetadata(entity).name.toLowerCase();
+}
+
+/**
+ * @description
+ * Escapes identifiers in an expression according to the current database driver.
+ */
+export function escapeCalculatedColumnExpression(connection: Connection, expression: string): string {
+    return expression.replace(/\b([a-z]+[A-Z]\w+)\b/g, substring => connection.driver.escape(substring));
+}

+ 18 - 0
packages/core/src/service/helpers/list-query-builder/get-calculated-columns.ts

@@ -0,0 +1,18 @@
+import { Type } from '@vendure/common/lib/shared-types';
+
+import { CalculatedColumnDefinition, CALCULATED_PROPERTIES } from '../../../common/calculated-decorator';
+
+/**
+ * @description
+ * Returns calculated columns definitions for the given entity type.
+ */
+export function getCalculatedColumns(entity: Type<any>) {
+    const calculatedColumns: CalculatedColumnDefinition[] = [];
+    const prototype = entity.prototype;
+    if (prototype.hasOwnProperty(CALCULATED_PROPERTIES)) {
+        for (const property of prototype[CALCULATED_PROPERTIES]) {
+            calculatedColumns.push(property);
+        }
+    }
+    return calculatedColumns;
+}

+ 42 - 1
packages/core/src/service/helpers/list-query-builder/list-query-builder.ts

@@ -1,5 +1,6 @@
 import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
 import { ID, Type } from '@vendure/common/lib/shared-types';
+import { unique } from '@vendure/common/lib/unique';
 import { FindConditions, FindManyOptions, FindOneOptions, SelectQueryBuilder } from 'typeorm';
 import { BetterSqlite3Driver } from 'typeorm/driver/better-sqlite3/BetterSqlite3Driver';
 import { SqljsDriver } from 'typeorm/driver/sqljs/SqljsDriver';
@@ -10,6 +11,8 @@ import { ListQueryOptions } from '../../../common/types/common-types';
 import { VendureEntity } from '../../../entity/base/base.entity';
 import { TransactionalConnection } from '../../transaction/transactional-connection';
 
+import { getColumnMetadata, getEntityAlias } from './connection-utils';
+import { getCalculatedColumns } from './get-calculated-columns';
 import { parseChannelParam } from './parse-channel-param';
 import { parseFilterParams } from './parse-filter-params';
 import { parseSortParams } from './parse-sort-params';
@@ -68,6 +71,9 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
         // tslint:disable-next-line:no-non-null-assertion
         FindOptionsUtils.joinEagerRelations(qb, qb.alias, qb.expressionMap.mainAlias!.metadata);
 
+        // join the tables required by calculated columns
+        this.joinCalculatedColumnRelations(qb, entity, options);
+
         filter.forEach(({ clause, parameters }) => {
             qb.andWhere(clause, parameters);
         });
@@ -79,7 +85,42 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
             }
         }
 
-        return qb.orderBy(sort);
+        qb.orderBy(sort);
+        return qb;
+    }
+
+    /**
+     * Some calculated columns (those with the `@Calculated()` decorator) require extra joins in order
+     * to derive the data needed for their expressions.
+     */
+    private joinCalculatedColumnRelations<T extends VendureEntity>(
+        qb: SelectQueryBuilder<T>,
+        entity: Type<T>,
+        options: ListQueryOptions<T>,
+    ) {
+        const calculatedColumns = getCalculatedColumns(entity);
+        const filterAndSortFields = unique([
+            ...Object.keys(options.filter || {}),
+            ...Object.keys(options.sort || {}),
+        ]);
+        const alias = getEntityAlias(this.connection.rawConnection, entity);
+        for (const field of filterAndSortFields) {
+            const calculatedColumnDef = calculatedColumns.find(c => c.name === field);
+            const instruction = calculatedColumnDef?.listQuery;
+            if (instruction) {
+                const relations = instruction.relations || [];
+                for (const relation of relations) {
+                    const propertyPath = relation.includes('.') ? relation : `${alias}.${relation}`;
+                    const relationAlias = relation.includes('.')
+                        ? relation.split('.').reverse()[0]
+                        : relation;
+                    qb.innerJoinAndSelect(propertyPath, relationAlias);
+                }
+                if (typeof instruction.query === 'function') {
+                    instruction.query(qb);
+                }
+            }
+        }
     }
 
     /**

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

@@ -14,7 +14,8 @@ import {
 } from '../../../common/types/common-types';
 import { VendureEntity } from '../../../entity/base/base.entity';
 
-import { getColumnMetadata } from './get-column-metadata';
+import { escapeCalculatedColumnExpression, getColumnMetadata } from './connection-utils';
+import { getCalculatedColumns } from './get-calculated-columns';
 
 export interface WhereCondition {
     clause: string;
@@ -33,17 +34,23 @@ export function parseFilterParams<T extends VendureEntity>(
         return [];
     }
     const { columns, translationColumns, alias } = getColumnMetadata(connection, entity);
+    const calculatedColumns = getCalculatedColumns(entity);
     const output: WhereCondition[] = [];
     const dbType = connection.options.type;
     let argIndex = 1;
     for (const [key, operation] of Object.entries(filterParams)) {
         if (operation) {
+            const calculatedColumnDef = calculatedColumns.find(c => c.name === key);
+            const instruction = calculatedColumnDef?.listQuery;
+            const calculatedColumnExpression = instruction?.expression;
             for (const [operator, operand] of Object.entries(operation as object)) {
                 let fieldName: string;
                 if (columns.find(c => c.propertyName === key)) {
                     fieldName = `${alias}.${key}`;
                 } else if (translationColumns.find(c => c.propertyName === key)) {
                     fieldName = `${alias}_translations.${key}`;
+                } else if (calculatedColumnExpression) {
+                    fieldName = escapeCalculatedColumnExpression(connection, calculatedColumnExpression);
                 } else {
                     throw new UserInputError('error.invalid-filter-field');
                 }

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

@@ -7,7 +7,8 @@ import { UserInputError } from '../../../common/error/errors';
 import { NullOptionals, SortParameter } from '../../../common/types/common-types';
 import { VendureEntity } from '../../../entity/base/base.entity';
 
-import { getColumnMetadata } from './get-column-metadata';
+import { escapeCalculatedColumnExpression, getColumnMetadata } from './connection-utils';
+import { getCalculatedColumns } from './get-calculated-columns';
 
 /**
  * Parses the provided SortParameter array against the metadata of the given entity, ensuring that only
@@ -25,22 +26,32 @@ export function parseSortParams<T extends VendureEntity>(
         return {};
     }
     const { columns, translationColumns, alias } = getColumnMetadata(connection, entity);
+    const calculatedColumns = getCalculatedColumns(entity);
     const output: OrderByCondition = {};
     for (const [key, order] of Object.entries(sortParams)) {
+        const calculatedColumnDef = calculatedColumns.find(c => c.name === key);
         if (columns.find(c => c.propertyName === key)) {
             output[`${alias}.${key}`] = order as any;
         } else if (translationColumns.find(c => c.propertyName === key)) {
             output[`${alias}_translations.${key}`] = order as any;
+        } else if (calculatedColumnDef) {
+            const instruction = calculatedColumnDef.listQuery;
+            if (instruction) {
+                output[escapeCalculatedColumnExpression(connection, instruction.expression)] = order as any;
+            }
         } else {
             throw new UserInputError('error.invalid-sort-field', {
                 fieldName: key,
-                validFields: getValidSortFields([...columns, ...translationColumns]),
+                validFields: [
+                    ...getValidSortFields([...columns, ...translationColumns]),
+                    ...calculatedColumns.map(c => c.name.toString()),
+                ].join(', '),
             });
         }
     }
     return output;
 }
 
-function getValidSortFields(columns: ColumnMetadata[]): string {
-    return unique(columns.map(c => c.propertyName)).join(', ');
+function getValidSortFields(columns: ColumnMetadata[]): string[] {
+    return unique(columns.map(c => c.propertyName));
 }