Quellcode durchsuchen

feat(core): Implement EntityHydrator to simplify working with entities

Relates to #1103
Michael Bromley vor 4 Jahren
Ursprung
Commit
28e6a3a139

+ 133 - 0
packages/core/e2e/entity-hydrator.e2e-spec.ts

@@ -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 - 0
packages/core/e2e/fixtures/test-plugins/hydration-test-plugin.ts

@@ -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 - 0
packages/core/src/connection/connection.module.ts

@@ -12,6 +12,7 @@ import { TransactionalConnection } from './transactional-connection';
 let defaultTypeOrmModule: DynamicModule;
 
 @Module({
+    imports: [ConfigModule],
     providers: [TransactionalConnection, TransactionSubscriber],
     exports: [TransactionalConnection, TransactionSubscriber],
 })

+ 1 - 0
packages/core/src/connection/index.ts

@@ -1,3 +1,4 @@
 export * from './transactional-connection';
 export * from './transaction-subscriber';
 export * from './connection.module';
+export * from './types';

+ 1 - 33
packages/core/src/connection/transactional-connection.ts

@@ -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

+ 34 - 0
packages/core/src/connection/types.ts

@@ -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;
+}

+ 1 - 1
packages/core/src/plugin/default-job-queue-plugin/sql-job-queue-strategy.ts

@@ -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';

+ 73 - 0
packages/core/src/service/helpers/entity-hydrator/entity-hydrator-types.ts

@@ -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;

+ 226 - 0
packages/core/src/service/helpers/entity-hydrator/entity-hydrator.service.ts

@@ -0,0 +1,226 @@
+import { Injectable } from '@nestjs/common';
+import { Type } from '@vendure/common/lib/shared-types';
+import { unique } from '@vendure/common/lib/unique';
+
+import { RequestContext } from '../../../api/common/request-context';
+import { InternalServerError } from '../../../common/error/errors';
+import { Translatable } from '../../../common/types/locale-types';
+import { TransactionalConnection } from '../../../connection/transactional-connection';
+import { VendureEntity } from '../../../entity/base/base.entity';
+import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
+import { ProductPriceApplicator } from '../product-price-applicator/product-price-applicator';
+import { translateDeep } from '../utils/translate-entity';
+
+import { HydrateOptions } from './entity-hydrator-types';
+
+/**
+ * @description
+ * This is a helper class which is used to "hydrate" entity instances, which means to populate them
+ * with the specified relations. This is useful when writing plugin code which receives an entity
+ * and you need to ensure that one or more relations are present.
+ *
+ * @example
+ * ```TypeScript
+ * const product = this.productVariantService.getProductForVariant(ctx, variantId);
+ * await this.entityHydrator.hydrate(ctx, product, { relations: ['facetValues.facet' ]});
+ *```
+ *
+ * In this above example, the `product` instance will now have the `facetValues` relation
+ * available, and those FacetValues will have their `facet` relations joined too.
+ *
+ * This `hydrate` method will _also_ automatically take care or translating any
+ * translatable entities (e.g. Product, Collection, Facet), and if the `applyProductVariantPrices`
+ * options is used (see {@link HydrateOptions}), any related ProductVariant will have the correct
+ * Channel-specific prices applied to them.
+ *
+ * @since 1.3.0
+ */
+@Injectable()
+export class EntityHydrator {
+    constructor(
+        private connection: TransactionalConnection,
+        private productPriceApplicator: ProductPriceApplicator,
+    ) {}
+
+    /**
+     * @description
+     * Hydrates (joins) the specified relations to the target entity instance. This method
+     * mutates the `target` entity.
+     *
+     * @example
+     * ```TypeScript
+     * await this.entityHydrator.hydrate(ctx, product, {
+     *   relations: [
+     *     'variants.stockMovements'
+     *     'optionGroups.options',
+     *     'featuredAsset',
+     *   ],
+     *   applyProductVariantPrices: true,
+     * });
+     * ```
+     *
+     * @since 1.3.0
+     */
+    async hydrate<Entity extends VendureEntity>(
+        ctx: RequestContext,
+        target: Entity,
+        options: HydrateOptions<Entity>,
+    ): Promise<Entity> {
+        if (options.relations) {
+            let missingRelations = this.getMissingRelations(target, options);
+
+            if (options.applyProductVariantPrices === true) {
+                const productVariantPriceRelations = this.getRequiredProductVariantRelations(
+                    target,
+                    missingRelations,
+                );
+                missingRelations = unique([...missingRelations, ...productVariantPriceRelations]);
+            }
+
+            if (missingRelations.length) {
+                const hydrated = await this.connection
+                    .getRepository(ctx, target.constructor)
+                    .findOne(target.id, {
+                        relations: missingRelations,
+                    });
+                const propertiesToAdd = unique(missingRelations.map(relation => relation.split('.')[0]));
+                for (const prop of propertiesToAdd) {
+                    (target as any)[prop] = (hydrated as any)[prop];
+                }
+
+                const relationsWithEntities = missingRelations.map(relation => ({
+                    entity: this.getRelationEntityAtPath(target, relation.split('.')),
+                    relation,
+                }));
+
+                if (options.applyProductVariantPrices === true) {
+                    for (const relationWithEntities of relationsWithEntities) {
+                        const entity = relationWithEntities.entity;
+                        if (entity) {
+                            if (Array.isArray(entity)) {
+                                if (entity[0] instanceof ProductVariant) {
+                                    await Promise.all(
+                                        entity.map((e: any) =>
+                                            this.productPriceApplicator.applyChannelPriceAndTax(e, ctx),
+                                        ),
+                                    );
+                                }
+                            } else {
+                                if (entity instanceof ProductVariant) {
+                                    await this.productPriceApplicator.applyChannelPriceAndTax(entity, ctx);
+                                }
+                            }
+                        }
+                    }
+                }
+
+                const translateDeepRelations = relationsWithEntities
+                    .filter(item => this.isTranslatable(item.entity))
+                    .map(item => item.relation.split('.'));
+
+                Object.assign(
+                    target,
+                    translateDeep(target as any, ctx.languageCode, translateDeepRelations as any),
+                );
+            }
+        }
+        return target;
+    }
+
+    /**
+     * Compares the requested relations against the actual existing relations on the target entity,
+     * and returns an array of all missing relation paths that would need to be fetched.
+     */
+    private getMissingRelations<Entity extends VendureEntity>(
+        target: Entity,
+        options: HydrateOptions<Entity>,
+    ) {
+        const missingRelations: string[] = [];
+        for (const relation of options.relations.slice().sort()) {
+            if (typeof relation === 'string') {
+                const parts = relation.split('.');
+                let entity: any = target;
+                const path = [];
+                for (const part of parts) {
+                    path.push(part);
+                    if (entity[part]) {
+                        entity = Array.isArray(entity[part]) ? entity[part][0] : entity[part];
+                    } else {
+                        const allParts = path.reduce((result, p, i) => {
+                            if (i === 0) {
+                                return [p];
+                            } else {
+                                return [...result, [result[result.length - 1], p].join('.')];
+                            }
+                        }, [] as string[]);
+                        missingRelations.push(...allParts);
+                    }
+                }
+            }
+        }
+        return unique(missingRelations);
+    }
+
+    private getRequiredProductVariantRelations<Entity extends VendureEntity>(
+        target: Entity,
+        missingRelations: string[],
+    ): string[] {
+        const relationsToAdd: string[] = [];
+        for (const relation of missingRelations) {
+            const entityType = this.getRelationEntityTypeAtPath(target, relation);
+            if (entityType === ProductVariant) {
+                relationsToAdd.push([relation, 'taxCategory'].join('.'));
+            }
+        }
+        return relationsToAdd;
+    }
+
+    /**
+     * Returns an instance of the related entity at the given path. E.g. a path of `['variants', 'featuredAsset']`
+     * will return an Asset instance.
+     */
+    private getRelationEntityAtPath(
+        entity: VendureEntity,
+        path: string[],
+    ): VendureEntity | VendureEntity[] | undefined {
+        let relation: any = entity;
+        for (let i = 0; i < path.length; i++) {
+            const part = path[i];
+            const isLast = i === path.length - 1;
+            if (relation[part]) {
+                relation = Array.isArray(relation[part]) && !isLast ? relation[part][0] : relation[part];
+            } else {
+                return;
+            }
+        }
+        return relation;
+    }
+
+    private getRelationEntityTypeAtPath(entity: VendureEntity, path: string): Type<VendureEntity> {
+        const { entityMetadatas } = this.connection.rawConnection;
+        const targetMetadata = entityMetadatas.find(m => m.target === entity.constructor);
+        if (!targetMetadata) {
+            throw new InternalServerError(
+                `Cannot find entity metadata for entity "${entity.constructor.name}"`,
+            );
+        }
+        let currentMetadata = targetMetadata;
+        for (const pathPart of path.split('.')) {
+            const relationMetadata = currentMetadata.findRelationWithPropertyPath(pathPart);
+            if (relationMetadata) {
+                currentMetadata = relationMetadata.inverseEntityMetadata;
+            } else {
+                throw new InternalServerError(
+                    `Cannot find relation metadata for entity "${currentMetadata.targetName}" at path "${pathPart}"`,
+                );
+            }
+        }
+        return currentMetadata.target as Type<VendureEntity>;
+    }
+
+    private isTranslatable(input: any | any[]): input is Translatable {
+        return Array.isArray(input)
+            ? input[0].hasOwnProperty('translations')
+            : input.hasOwnProperty('translations');
+    }
+}

+ 1 - 1
packages/core/src/service/index.ts

@@ -1,5 +1,6 @@
 export * from './helpers/active-order/active-order.service';
 export * from './helpers/config-arg/config-arg.service';
+export * from './helpers/entity-hydrator/entity-hydrator.service';
 export * from './helpers/external-authentication/external-authentication.service';
 export * from './helpers/custom-field-relation/custom-field-relation.service';
 export * from './helpers/fulfillment-state-machine/fulfillment-state';
@@ -46,4 +47,3 @@ export * from './services/tax-category.service';
 export * from './services/tax-rate.service';
 export * from './services/user.service';
 export * from './services/zone.service';
-export * from '../connection/transactional-connection';

+ 2 - 0
packages/core/src/service/service.module.ts

@@ -9,6 +9,7 @@ import { JobQueueModule } from '../job-queue/job-queue.module';
 import { ActiveOrderService } from './helpers/active-order/active-order.service';
 import { ConfigArgService } from './helpers/config-arg/config-arg.service';
 import { CustomFieldRelationService } from './helpers/custom-field-relation/custom-field-relation.service';
+import { EntityHydrator } from './helpers/entity-hydrator/entity-hydrator.service';
 import { ExternalAuthenticationService } from './helpers/external-authentication/external-authentication.service';
 import { FulfillmentStateMachine } from './helpers/fulfillment-state-machine/fulfillment-state-machine';
 import { ListQueryBuilder } from './helpers/list-query-builder/list-query-builder';
@@ -114,6 +115,7 @@ const helpers = [
     LocaleStringHydrator,
     ActiveOrderService,
     ProductPriceApplicator,
+    EntityHydrator,
 ];
 
 /**

+ 49 - 0
packages/dev-server/test-plugins/entity-hydrate-plugin.ts

@@ -0,0 +1,49 @@
+/* tslint:disable:no-non-null-assertion */
+import { Args, Query, Resolver } from '@nestjs/graphql';
+import {
+    Ctx,
+    EntityHydrator,
+    ID,
+    PluginCommonModule,
+    Product,
+    ProductVariant,
+    ProductVariantService,
+    RequestContext,
+    TransactionalConnection,
+    VendurePlugin,
+} from '@vendure/core';
+import gql from 'graphql-tag';
+
+@Resolver()
+class TestResolver {
+    constructor(
+        private productVariantService: ProductVariantService,
+        private connection: TransactionalConnection,
+        private entityHydrator: EntityHydrator,
+    ) {}
+
+    @Query()
+    async hydrateTest(@Ctx() ctx: RequestContext, @Args() args: { id: ID }) {
+        const product = await this.connection.getRepository(ctx, Product).findOne(args.id, {
+            relations: ['featuredAsset'],
+        });
+        await this.entityHydrator.hydrate(ctx, product!, {
+            relations: ['facetValues.facet', 'variants.options', 'assets'],
+        });
+        return product;
+    }
+}
+
+// A plugin to explore solutions to https://github.com/vendure-ecommerce/vendure/issues/1103
+@VendurePlugin({
+    imports: [PluginCommonModule],
+    adminApiExtensions: {
+        schema: gql`
+            extend type Query {
+                hydrateTest(id: ID!): JSON
+            }
+        `,
+        resolvers: [TestResolver],
+    },
+})
+export class EntityHydratePlugin {}

+ 10 - 11
scripts/codegen/generate-graphql-types.ts

@@ -24,6 +24,7 @@ const specFileToIgnore = [
     'custom-permissions.e2e-spec',
     'parallel-transactions.e2e-spec',
     'order-merge.e2e-spec',
+    'entity-hydrator.e2e-spec',
 ];
 const E2E_ADMIN_QUERY_FILES = path.join(
     __dirname,
@@ -119,18 +120,16 @@ Promise.all([
                     plugins: clientPlugins,
                     config: e2eConfig,
                 },
-                [path.join(
-                    __dirname,
-                    '../../packages/admin-ui/src/lib/core/src/common/generated-types.ts',
-                )]: {
-                    schema: [ADMIN_SCHEMA_OUTPUT_FILE, path.join(__dirname, 'client-schema.ts')],
-                    documents: CLIENT_QUERY_FILES,
-                    plugins: clientPlugins,
-                    config: {
-                        ...config,
-                        skipTypeNameForRoot: true,
+                [path.join(__dirname, '../../packages/admin-ui/src/lib/core/src/common/generated-types.ts')]:
+                    {
+                        schema: [ADMIN_SCHEMA_OUTPUT_FILE, path.join(__dirname, 'client-schema.ts')],
+                        documents: CLIENT_QUERY_FILES,
+                        plugins: clientPlugins,
+                        config: {
+                            ...config,
+                            skipTypeNameForRoot: true,
+                        },
                     },
-                },
                 [path.join(
                     __dirname,
                     '../../packages/admin-ui/src/lib/core/src/common/introspection-result.ts',