Skip to content

Commit

Permalink
feat(core): Implement EntityHydrator to simplify working with entities
Browse files Browse the repository at this point in the history
Relates to #1103
  • Loading branch information
michaelbromley committed Sep 30, 2021
1 parent 469e3f7 commit 28e6a3a
Show file tree
Hide file tree
Showing 13 changed files with 582 additions and 46 deletions.
133 changes: 133 additions & 0 deletions packages/core/e2e/entity-hydrator.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/* tslint:disable:no-non-null-assertion */
import { mergeConfig, Product } 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 { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';

import { HydrationTestPlugin } from './fixtures/test-plugins/hydration-test-plugin';

describe('Entity hydration', () => {
const { server, adminClient } = createTestEnvironment(
mergeConfig(testConfig, {
plugins: [HydrationTestPlugin],
}),
);

beforeAll(async () => {
await server.init({
initialData,
productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
customerCount: 1,
});
await adminClient.asSuperAdmin();
}, TEST_SETUP_TIMEOUT_MS);

afterAll(async () => {
await server.destroy();
});

it('includes existing relations', async () => {
const { hydrateProduct } = await adminClient.query<HydrateProductQuery>(GET_HYDRATED_PRODUCT, {
id: 'T_1',
});

expect(hydrateProduct.facetValues).toBeDefined();
expect(hydrateProduct.facetValues.length).toBe(2);
});

it('hydrates top-level single relation', async () => {
const { hydrateProduct } = await adminClient.query<HydrateProductQuery>(GET_HYDRATED_PRODUCT, {
id: 'T_1',
});

expect(hydrateProduct.featuredAsset.name).toBe('derick-david-409858-unsplash.jpg');
});

it('hydrates top-level array relation', async () => {
const { hydrateProduct } = await adminClient.query<HydrateProductQuery>(GET_HYDRATED_PRODUCT, {
id: 'T_1',
});

expect(hydrateProduct.assets.length).toBe(1);
expect(hydrateProduct.assets[0].asset.name).toBe('derick-david-409858-unsplash.jpg');
});

it('hydrates nested single relation', async () => {
const { hydrateProduct } = await adminClient.query<HydrateProductQuery>(GET_HYDRATED_PRODUCT, {
id: 'T_1',
});

expect(hydrateProduct.variants[0].product.id).toBe('T_1');
});

it('hydrates nested array relation', async () => {
const { hydrateProduct } = await adminClient.query<HydrateProductQuery>(GET_HYDRATED_PRODUCT, {
id: 'T_1',
});

expect(hydrateProduct.variants[0].options.length).toBe(2);
});

it('translates top-level translatable', async () => {
const { hydrateProduct } = await adminClient.query<HydrateProductQuery>(GET_HYDRATED_PRODUCT, {
id: 'T_1',
});

expect(hydrateProduct.variants.map(v => v.name).sort()).toEqual([
'Laptop 13 inch 16GB',
'Laptop 13 inch 8GB',
'Laptop 15 inch 16GB',
'Laptop 15 inch 8GB',
]);
});

it('translates nested translatable', async () => {
const { hydrateProduct } = await adminClient.query<HydrateProductQuery>(GET_HYDRATED_PRODUCT, {
id: 'T_1',
});

expect(
getVariantWithName(hydrateProduct, 'Laptop 13 inch 8GB')
.options.map(o => o.name)
.sort(),
).toEqual(['13 inch', '8GB']);
});

it('translates nested translatable 2', async () => {
const { hydrateProduct } = await adminClient.query<HydrateProductQuery>(GET_HYDRATED_PRODUCT, {
id: 'T_1',
});

expect(hydrateProduct.assets[0].product.name).toBe('Laptop');
});

it('populates ProductVariant price data', async () => {
const { hydrateProduct } = await adminClient.query<HydrateProductQuery>(GET_HYDRATED_PRODUCT, {
id: 'T_1',
});

expect(getVariantWithName(hydrateProduct, 'Laptop 13 inch 8GB').price).toBe(129900);
expect(getVariantWithName(hydrateProduct, 'Laptop 13 inch 8GB').priceWithTax).toBe(155880);
expect(getVariantWithName(hydrateProduct, 'Laptop 13 inch 16GB').price).toBe(219900);
expect(getVariantWithName(hydrateProduct, 'Laptop 13 inch 16GB').priceWithTax).toBe(263880);
expect(getVariantWithName(hydrateProduct, 'Laptop 15 inch 8GB').price).toBe(139900);
expect(getVariantWithName(hydrateProduct, 'Laptop 15 inch 8GB').priceWithTax).toBe(167880);
expect(getVariantWithName(hydrateProduct, 'Laptop 15 inch 16GB').price).toBe(229900);
expect(getVariantWithName(hydrateProduct, 'Laptop 15 inch 16GB').priceWithTax).toBe(275880);
});
});

function getVariantWithName(product: Product, name: string) {
return product.variants.find(v => v.name === name)!;
}

type HydrateProductQuery = { hydrateProduct: Product };

const GET_HYDRATED_PRODUCT = gql`
query GetHydratedProduct($id: ID!) {
hydrateProduct(id: $id)
}
`;
50 changes: 50 additions & 0 deletions packages/core/e2e/fixtures/test-plugins/hydration-test-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Args, Query, Resolver } from '@nestjs/graphql';
import {
Ctx,
EntityHydrator,
ID,
PluginCommonModule,
Product,
RequestContext,
TransactionalConnection,
VendurePlugin,
} from '@vendure/core';
import gql from 'graphql-tag';

@Resolver()
export class TestAdminPluginResolver {
constructor(private connection: TransactionalConnection, private entityHydrator: EntityHydrator) {}

@Query()
async hydrateProduct(@Ctx() ctx: RequestContext, @Args() args: { id: ID }) {
const product = await this.connection.getRepository(ctx, Product).findOne(args.id, {
relations: ['facetValues'],
});
// tslint:disable-next-line:no-non-null-assertion
await this.entityHydrator.hydrate(ctx, product!, {
relations: [
'variants.options',
'variants.product',
'assets.product',
'facetValues.facet',
'featuredAsset',
'variants.stockMovements',
],
applyProductVariantPrices: true,
});
return product;
}
}

@VendurePlugin({
imports: [PluginCommonModule],
adminApiExtensions: {
resolvers: [TestAdminPluginResolver],
schema: gql`
extend type Query {
hydrateProduct(id: ID!): JSON
}
`,
},
})
export class HydrationTestPlugin {}
1 change: 1 addition & 0 deletions packages/core/src/connection/connection.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { TransactionalConnection } from './transactional-connection';
let defaultTypeOrmModule: DynamicModule;

@Module({
imports: [ConfigModule],
providers: [TransactionalConnection, TransactionSubscriber],
exports: [TransactionalConnection, TransactionSubscriber],
})
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/connection/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './transactional-connection';
export * from './transaction-subscriber';
export * from './connection.module';
export * from './types';
34 changes: 1 addition & 33 deletions packages/core/src/connection/transactional-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,39 +18,7 @@ import { EntityNotFoundError } from '../common/error/errors';
import { ChannelAware, SoftDeletable } from '../common/types/common-types';
import { VendureEntity } from '../entity/base/base.entity';

import { TransactionSubscriber } from './transaction-subscriber';

/**
* @description
* Options used by the {@link TransactionalConnection} `getEntityOrThrow` method.
*
* @docsCategory data-access
*/
export interface GetEntityOrThrowOptions<T = any> extends FindOneOptions<T> {
/**
* @description
* An optional channelId to limit results to entities assigned to the given Channel. Should
* only be used when getting entities that implement the {@link ChannelAware} interface.
*/
channelId?: ID;
/**
* @description
* If set to a positive integer, it will retry getting the entity in case it is initially not
* found.
*
* @since 1.1.0
* @default 0
*/
retries?: number;
/**
* @description
* Specifies the delay in ms to wait between retries.
*
* @since 1.1.0
* @default 25
*/
retryDelay?: number;
}
import { GetEntityOrThrowOptions } from './types';

/**
* @description
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/connection/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { ID } from '@vendure/common/lib/shared-types';
import { FindOneOptions } from 'typeorm';

/**
* @description
* Options used by the {@link TransactionalConnection} `getEntityOrThrow` method.
*
* @docsCategory data-access
*/
export interface GetEntityOrThrowOptions<T = any> extends FindOneOptions<T> {
/**
* @description
* An optional channelId to limit results to entities assigned to the given Channel. Should
* only be used when getting entities that implement the {@link ChannelAware} interface.
*/
channelId?: ID;
/**
* @description
* If set to a positive integer, it will retry getting the entity in case it is initially not
* found.
*
* @since 1.1.0
* @default 0
*/
retries?: number;
/**
* @description
* Specifies the delay in ms to wait between retries.
*
* @since 1.1.0
* @default 25
*/
retryDelay?: number;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import { Brackets, Connection, EntityManager, FindConditions, In, LessThan } fro
import { Injector } from '../../common/injector';
import { InspectableJobQueueStrategy, JobQueueStrategy } from '../../config';
import { Logger } from '../../config/logger/vendure-logger';
import { TransactionalConnection } from '../../connection/transactional-connection';
import { Job, JobData } from '../../job-queue';
import { PollingJobQueueStrategy } from '../../job-queue/polling-job-queue-strategy';
import { TransactionalConnection } from '../../service';
import { ListQueryBuilder } from '../../service/helpers/list-query-builder/list-query-builder';

import { JobRecord } from './job-record.entity';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { VendureEntity } from '../../../entity/base/base.entity';

/**
* @description
* Options used to control which relations of the entity get hydrated
* when using the {@link EntityHydrator} helper.
*
* @since 1.3.0
*/
export interface HydrateOptions<Entity extends VendureEntity> {
/**
* @description
* Defines the relations to hydrate, using strings with dot notation to indicate
* nested joins. If the entity already has a particular relation available, that relation
* will be skipped (no extra DB join will be added).
*/
relations: Array<EntityRelationPaths<Entity>>;
/**
* @description
* If set to `true`, any ProductVariants will also have their `price` and `priceWithTax` fields
* applied based on the current context. If prices are not required, this can be left `false` which
* will be slightly more efficient.
*
* @default false
*/
applyProductVariantPrices?: boolean;
}

// The following types are all related to allowing dot-notation access to relation properties
export type EntityRelationKeys<T extends VendureEntity> = {
[K in Extract<keyof T, string>]: T[K] extends VendureEntity
? K
: T[K] extends VendureEntity[]
? K
: never;
}[Extract<keyof T, string>];

export type EntityRelations<T extends VendureEntity> = {
[K in EntityRelationKeys<T>]: T[K];
};

export type PathsToStringProps1<T extends VendureEntity> = T extends string
? []
: {
[K in EntityRelationKeys<T>]: K;
}[Extract<EntityRelationKeys<T>, string>];

export type PathsToStringProps2<T extends VendureEntity> = T extends string
? never
: {
[K in EntityRelationKeys<T>]: T[K] extends VendureEntity[]
? [K, PathsToStringProps1<T[K][number]>]
: [K, PathsToStringProps1<T[K]>];
}[Extract<EntityRelationKeys<T>, string>];

export type TripleDotPath = `${string}.${string}.${string}`;

export type EntityRelationPaths<T extends VendureEntity> =
| PathsToStringProps1<T>
| Join<PathsToStringProps2<T>, '.'>
| TripleDotPath;

// Based on https://stackoverflow.com/a/47058976/772859
export type Join<T extends Array<string | any>, D extends string> = T extends []
? never
: T extends [infer F]
? F
: // tslint:disable-next-line:no-shadowed-variable
T extends [infer F, ...infer R]
? F extends string
? `${F}${D}${Join<Extract<R, string[]>, D>}`
: never
: string;
Loading

0 comments on commit 28e6a3a

Please sign in to comment.