Sfoglia il codice sorgente

docs: Improve docs working with DB relations

Relates to #2299
Michael Bromley 2 anni fa
parent
commit
de8e3c054c

+ 80 - 0
docs/docs/guides/developer-guide/the-service-layer/index.mdx

@@ -229,3 +229,83 @@ export class ItemService {
 Further examples can be found in the [TypeORM QueryBuilder documentation](https://typeorm.io/select-query-builder).
 
 :::
+
+### Working with relations
+
+One limitation of TypeORM's typings is that we have no way of knowing at build-time whether a particular relation will be
+joined at runtime. For instance, the following code will build without issues, but will result in a runtime error:
+
+```ts
+const product = await this.connection.getRepository(ctx, Product).findOne({
+    where: { id: productId },
+});
+if (product) {
+    // highlight-start
+    console.log(product.featuredAsset.preview);
+    // ^ Error: Cannot read property 'preview' of undefined
+    // highlight-end
+}
+```
+
+This is because the `featuredAsset` relation is not joined by default. The simple fix for the above example is to use
+the `relations` option:
+
+```ts
+const product = await this.connection.getRepository(ctx, Product).findOne({
+    where: { id: productId },
+    // highlight-next-line
+    relations: { featuredAsset: true },
+});
+```
+or in the case of the QueryBuilder API, we can use the `leftJoinAndSelect()` method:
+
+```ts
+const product = await this.connection.getRepository(ctx, Product).createQueryBuilder('product')
+    // highlight-next-line
+    .leftJoinAndSelect('product.featuredAsset', 'featuredAsset')
+    .where('product.id = :id', { id: productId })
+    .getOne();
+```
+
+### Using the EntityHydrator
+
+But what about when we do not control the code which fetches the entity from the database? For instance, we might be implementing
+a function which gets an entity passed to it by Vendure. In this case, we can use the [`EntityHydrator`](/reference/typescript-api/data-access/entity-hydrator/)
+to ensure that a given relation is "hydrated" (i.e. joined) before we use it:
+
+```ts
+import { EntityHydrator, ShippingCalculator } from '@vendure/core';
+
+let entityHydrator: EntityHydrator;
+
+const myShippingCalculator = new ShippingCalculator({
+    // ... rest of config omitted for brevity
+    init(injector) {
+        entityHydrator = injector.get(EntityHydrator);
+    },
+    calculate: (ctx, order, args) => {
+      // highlight-start
+      // ensure that the customer and customer.groups relations are joined
+      await entityHydrator.hydrate(ctx, order, { relations: ['customer.groups' ]});
+      // highlight-end
+
+      if (order.customer?.groups?.some(g => g.name === 'VIP')) {
+        // ... do something special for VIP customers
+      } else {
+        // ... do something else
+      }
+    },
+});
+```
+
+### Joining relations in built-in service methods
+
+Many of the core services allow an optional `relations` argument in their `findOne()` and `findMany()` and related methods.
+This allows you to specify which relations should be joined when the query is executed. For instance, in the [`ProductService`](/reference/typescript-api/services/product-service)
+there is a `findOne()` method which allows you to specify which relations should be joined:
+
+```ts
+const productWithAssets = await this.productService
+    .findOne(ctx, productId, ['featuredAsset', 'assets']);
+```
+

+ 2 - 2
docs/docs/reference/admin-ui-api/custom-input-components/default-inputs.md

@@ -289,7 +289,7 @@ class CustomerGroupFormInputComponent implements FormInputComponent, OnInit {
     constructor(dataService: DataService)
     ngOnInit() => ;
     selectGroup(group: ItemOf<GetCustomerGroupsQuery, 'customerGroups'>) => ;
-    compareWith(o1: ItemOf<GetCustomerGroupsQuery, 'customerGroups'>, o2: ItemOf<GetCustomerGroupsQuery, 'customerGroups'>) => ;
+    compareWith(o1: T, o2: T) => ;
 }
 ```
 * Implements: <code><a href='/reference/admin-ui-api/custom-input-components/form-input-component#forminputcomponent'>FormInputComponent</a></code>, <code>OnInit</code>
@@ -340,7 +340,7 @@ class CustomerGroupFormInputComponent implements FormInputComponent, OnInit {
 
 ### compareWith
 
-<MemberInfo kind="method" type={`(o1: ItemOf&#60;GetCustomerGroupsQuery, 'customerGroups'&#62;, o2: ItemOf&#60;GetCustomerGroupsQuery, 'customerGroups'&#62;) => `}   />
+<MemberInfo kind="method" type={`(o1: T, o2: T) => `}   />
 
 
 

+ 1 - 1
docs/docs/reference/admin-ui-api/custom-table-components/custom-column-component.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## CustomColumnComponent
 
-<GenerationInfo sourceFile="packages/admin-ui/src/lib/core/src/shared/components/data-table-2/data-table-custom-component.service.ts" sourceLine="43" packageName="@vendure/admin-ui" />
+<GenerationInfo sourceFile="packages/admin-ui/src/lib/core/src/shared/components/data-table-2/data-table-custom-component.service.ts" sourceLine="44" packageName="@vendure/admin-ui" />
 
 Components which are to be used to render custom cells in a data table should implement this interface.
 

+ 1 - 1
docs/docs/reference/admin-ui-api/custom-table-components/data-table-component-config.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## DataTableComponentConfig
 
-<GenerationInfo sourceFile="packages/admin-ui/src/lib/core/src/shared/components/data-table-2/data-table-custom-component.service.ts" sourceLine="53" packageName="@vendure/admin-ui" />
+<GenerationInfo sourceFile="packages/admin-ui/src/lib/core/src/shared/components/data-table-2/data-table-custom-component.service.ts" sourceLine="54" packageName="@vendure/admin-ui" />
 
 Configures a <a href='/reference/admin-ui-api/custom-detail-components/custom-detail-component#customdetailcomponent'>CustomDetailComponent</a> to be placed in the given location.
 

+ 28 - 6
docs/docs/reference/typescript-api/data-access/entity-hydrator.md

@@ -11,20 +11,42 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## EntityHydrator
 
-<GenerationInfo sourceFile="packages/core/src/service/helpers/entity-hydrator/entity-hydrator.service.ts" sourceLine="53" packageName="@vendure/core" since="1.3.0" />
+<GenerationInfo sourceFile="packages/core/src/service/helpers/entity-hydrator/entity-hydrator.service.ts" sourceLine="75" packageName="@vendure/core" since="1.3.0" />
 
 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
+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*
 
 ```ts
-const product = await this.productVariantService
-  .getProductForVariant(ctx, variantId);
+import { Injectable } from '@nestjs/common';
+import { ID, RequestContext, EntityHydrator, ProductVariantService } from '@vendure/core';
 
-await this.entityHydrator
-  .hydrate(ctx, product, { relations: ['facetValues.facet' ]});
+@Injectable()
+export class MyService {
+
+  constructor(
+     // highlight-next-line
+     private entityHydrator: EntityHydrator,
+     private productVariantService: ProductVariantService,
+  ) {}
+
+  myMethod(ctx: RequestContext, variantId: ID) {
+    const product = await this.productVariantService
+      .getProductForVariant(ctx, variantId);
+
+    // at this stage, we don't know which of the Product relations
+    // will be joined at runtime.
+
+    // highlight-start
+    await this.entityHydrator
+      .hydrate(ctx, product, { relations: ['facetValues.facet' ]});
+
+    // You can be sure now that the `facetValues` & `facetValues.facet` relations are populated
+    // highlight-end
+  }
+}
 ```
 
 In this above example, the `product` instance will now have the `facetValues` relation

+ 25 - 25
docs/docs/reference/typescript-api/services/customer-service.md

@@ -31,8 +31,8 @@ class CustomerService {
     refreshVerificationToken(ctx: RequestContext, emailAddress: string) => Promise<void>;
     verifyCustomerEmailAddress(ctx: RequestContext, verificationToken: string, password?: string) => Promise<ErrorResultUnion<VerifyCustomerAccountResult, Customer>>;
     requestPasswordReset(ctx: RequestContext, emailAddress: string) => Promise<void>;
-    resetPassword(ctx: RequestContext, passwordResetToken: string, password: string) => Promise<
-        User | PasswordResetTokenExpiredError | PasswordResetTokenInvalidError | PasswordValidationError
+    resetPassword(ctx: RequestContext, passwordResetToken: string, password: string) => Promise<
+        User | PasswordResetTokenExpiredError | PasswordResetTokenInvalidError | PasswordValidationError
     >;
     requestUpdateEmailAddress(ctx: RequestContext, userId: ID, newEmailAddress: string) => Promise<boolean | EmailAddressConflictError>;
     updateEmailAddress(ctx: RequestContext, token: string) => Promise<boolean | IdentifierChangeTokenInvalidError | IdentifierChangeTokenExpiredError>;
@@ -69,8 +69,8 @@ class CustomerService {
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, userId: <a href='/reference/typescript-api/common/id#id'>ID</a>, filterOnChannel:  = true) => Promise&#60;<a href='/reference/typescript-api/entities/customer#customer'>Customer</a> | undefined&#62;`}   />
 
-Returns the Customer entity associated with the given userId, if one exists.
-Setting `filterOnChannel` to `true` will limit the results to Customers which are assigned
+Returns the Customer entity associated with the given userId, if one exists.
+Setting `filterOnChannel` to `true` will limit the results to Customers which are assigned
 to the current active Channel only.
 ### findAddressesByCustomerId
 
@@ -86,13 +86,13 @@ Returns a list of all <a href='/reference/typescript-api/entities/customer-group
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, input: CreateCustomerInput, password?: string) => Promise&#60;ErrorResultUnion&#60;CreateCustomerResult, <a href='/reference/typescript-api/entities/customer#customer'>Customer</a>&#62;&#62;`}   />
 
-Creates a new Customer, including creation of a new User with the special `customer` Role.
-
-If the `password` argument is specified, the Customer will be immediately verified. If not,
-then an <a href='/reference/typescript-api/events/event-types#accountregistrationevent'>AccountRegistrationEvent</a> is published, so that the customer can have their
-email address verified and set their password in a later step using the `verifyCustomerEmailAddress()`
-method.
-
+Creates a new Customer, including creation of a new User with the special `customer` Role.
+
+If the `password` argument is specified, the Customer will be immediately verified. If not,
+then an <a href='/reference/typescript-api/events/event-types#accountregistrationevent'>AccountRegistrationEvent</a> is published, so that the customer can have their
+email address verified and set their password in a later step using the `verifyCustomerEmailAddress()`
+method.
+
 This method is intended to be used in admin-created Customer flows.
 ### update
 
@@ -113,47 +113,47 @@ This method is intended to be used in admin-created Customer flows.
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, input: RegisterCustomerInput) => Promise&#60;RegisterCustomerAccountResult | EmailAddressConflictError | PasswordValidationError&#62;`}   />
 
-Registers a new Customer account with the <a href='/reference/typescript-api/auth/native-authentication-strategy#nativeauthenticationstrategy'>NativeAuthenticationStrategy</a> and starts
-the email verification flow (unless <a href='/reference/typescript-api/auth/auth-options#authoptions'>AuthOptions</a> `requireVerification` is set to `false`)
-by publishing an <a href='/reference/typescript-api/events/event-types#accountregistrationevent'>AccountRegistrationEvent</a>.
-
+Registers a new Customer account with the <a href='/reference/typescript-api/auth/native-authentication-strategy#nativeauthenticationstrategy'>NativeAuthenticationStrategy</a> and starts
+the email verification flow (unless <a href='/reference/typescript-api/auth/auth-options#authoptions'>AuthOptions</a> `requireVerification` is set to `false`)
+by publishing an <a href='/reference/typescript-api/events/event-types#accountregistrationevent'>AccountRegistrationEvent</a>.
+
 This method is intended to be used in storefront Customer-creation flows.
 ### refreshVerificationToken
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, emailAddress: string) => Promise&#60;void&#62;`}   />
 
-Refreshes a stale email address verification token by generating a new one and
+Refreshes a stale email address verification token by generating a new one and
 publishing a <a href='/reference/typescript-api/events/event-types#accountregistrationevent'>AccountRegistrationEvent</a>.
 ### verifyCustomerEmailAddress
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, verificationToken: string, password?: string) => Promise&#60;ErrorResultUnion&#60;VerifyCustomerAccountResult, <a href='/reference/typescript-api/entities/customer#customer'>Customer</a>&#62;&#62;`}   />
 
-Given a valid verification token which has been published in an <a href='/reference/typescript-api/events/event-types#accountregistrationevent'>AccountRegistrationEvent</a>, this
+Given a valid verification token which has been published in an <a href='/reference/typescript-api/events/event-types#accountregistrationevent'>AccountRegistrationEvent</a>, this
 method is used to set the Customer as `verified` as part of the account registration flow.
 ### requestPasswordReset
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, emailAddress: string) => Promise&#60;void&#62;`}   />
 
-Publishes a new <a href='/reference/typescript-api/events/event-types#passwordresetevent'>PasswordResetEvent</a> for the given email address. This event creates
+Publishes a new <a href='/reference/typescript-api/events/event-types#passwordresetevent'>PasswordResetEvent</a> for the given email address. This event creates
 a token which can be used in the `resetPassword()` method.
 ### resetPassword
 
-<MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, passwordResetToken: string, password: string) => Promise&#60;
         <a href='/reference/typescript-api/entities/user#user'>User</a> | PasswordResetTokenExpiredError | PasswordResetTokenInvalidError | PasswordValidationError
     &#62;`}   />
+<MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, passwordResetToken: string, password: string) => Promise&#60;         <a href='/reference/typescript-api/entities/user#user'>User</a> | PasswordResetTokenExpiredError | PasswordResetTokenInvalidError | PasswordValidationError     &#62;`}   />
 
-Given a valid password reset token created by a call to the `requestPasswordReset()` method,
+Given a valid password reset token created by a call to the `requestPasswordReset()` method,
 this method will change the Customer's password to that given as the `password` argument.
 ### requestUpdateEmailAddress
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, userId: <a href='/reference/typescript-api/common/id#id'>ID</a>, newEmailAddress: string) => Promise&#60;boolean | EmailAddressConflictError&#62;`}   />
 
-Publishes a <a href='/reference/typescript-api/events/event-types#identifierchangerequestevent'>IdentifierChangeRequestEvent</a> for the given User. This event contains a token
-which is then used in the `updateEmailAddress()` method to change the email address of the User &
+Publishes a <a href='/reference/typescript-api/events/event-types#identifierchangerequestevent'>IdentifierChangeRequestEvent</a> for the given User. This event contains a token
+which is then used in the `updateEmailAddress()` method to change the email address of the User &
 Customer.
 ### updateEmailAddress
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, token: string) => Promise&#60;boolean | IdentifierChangeTokenInvalidError | IdentifierChangeTokenExpiredError&#62;`}   />
 
-Given a valid email update token published in a <a href='/reference/typescript-api/events/event-types#identifierchangerequestevent'>IdentifierChangeRequestEvent</a>, this method
+Given a valid email update token published in a <a href='/reference/typescript-api/events/event-types#identifierchangerequestevent'>IdentifierChangeRequestEvent</a>, this method
 will update the Customer & User email address.
 ### createOrUpdate
 
@@ -184,8 +184,8 @@ Creates a new <a href='/reference/typescript-api/entities/address#address'>Addre
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, order: <a href='/reference/typescript-api/entities/order#order'>Order</a>) => `}   />
 
-If the Customer associated with the given Order does not yet have any Addresses,
-this method will create new Address(es) based on the Order's shipping & billing
+If the Customer associated with the given Order does not yet have any Addresses,
+this method will create new Address(es) based on the Order's shipping & billing
 addresses.
 ### addNoteToCustomer
 

+ 4 - 4
docs/docs/reference/typescript-api/shipping/shipping-calculator.md

@@ -31,7 +31,7 @@ const flatRateCalculator = new ShippingCalculator({
       ui: { component: 'number-form-input', suffix: '%' },
     },
   },
-  calculate: (order, args) => {
+  calculate: (ctx, order, args) => {
     return {
       price: args.rate,
       taxRate: args.taxRate,
@@ -64,7 +64,7 @@ class ShippingCalculator<T extends ConfigArgs = ConfigArgs> extends Configurable
 
 ## ShippingCalculationResult
 
-<GenerationInfo sourceFile="packages/core/src/config/shipping-method/shipping-calculator.ts" sourceLine="74" packageName="@vendure/core" />
+<GenerationInfo sourceFile="packages/core/src/config/shipping-method/shipping-calculator.ts" sourceLine="79" packageName="@vendure/core" />
 
 The return value of the <a href='/reference/typescript-api/shipping/shipping-calculator#calculateshippingfn'>CalculateShippingFn</a>.
 
@@ -108,7 +108,7 @@ needed in the storefront application when listing eligible shipping methods.
 
 ## CalculateShippingFn
 
-<GenerationInfo sourceFile="packages/core/src/config/shipping-method/shipping-calculator.ts" sourceLine="114" packageName="@vendure/core" />
+<GenerationInfo sourceFile="packages/core/src/config/shipping-method/shipping-calculator.ts" sourceLine="119" packageName="@vendure/core" />
 
 A function which implements the specific shipping calculation logic. It takes an <a href='/reference/typescript-api/entities/order#order'>Order</a> and
 an arguments object and should return the shipping price as an integer in cents.
@@ -120,6 +120,6 @@ type CalculateShippingFn<T extends ConfigArgs> = (
     ctx: RequestContext,
     order: Order,
     args: ConfigArgValues<T>,
-    method: ShippingMethod
+    method: ShippingMethod,
 ) => CalculateShippingFnResult
 ```

+ 27 - 5
packages/core/src/service/helpers/entity-hydrator/entity-hydrator.service.ts

@@ -16,16 +16,38 @@ 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
+ * 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
  * ```ts
- * const product = await this.productVariantService
- *   .getProductForVariant(ctx, variantId);
+ * import { Injectable } from '\@nestjs/common';
+ * import { ID, RequestContext, EntityHydrator, ProductVariantService } from '\@vendure/core';
  *
- * await this.entityHydrator
- *   .hydrate(ctx, product, { relations: ['facetValues.facet' ]});
+ * \@Injectable()
+ * export class MyService {
+ *
+ *   constructor(
+ *      // highlight-next-line
+ *      private entityHydrator: EntityHydrator,
+ *      private productVariantService: ProductVariantService,
+ *   ) {}
+ *
+ *   myMethod(ctx: RequestContext, variantId: ID) {
+ *     const product = await this.productVariantService
+ *       .getProductForVariant(ctx, variantId);
+ *
+ *     // at this stage, we don't know which of the Product relations
+ *     // will be joined at runtime.
+ *
+ *     // highlight-start
+ *     await this.entityHydrator
+ *       .hydrate(ctx, product, { relations: ['facetValues.facet' ]});
+ *
+ *     // You can be sure now that the `facetValues` & `facetValues.facet` relations are populated
+ *     // highlight-end
+ *   }
+ * }
  *```
  *
  * In this above example, the `product` instance will now have the `facetValues` relation