ソースを参照

feat(core): Implement unique constraint for custom fields

Closes #1476
Michael Bromley 3 年 前
コミット
07e1601796

+ 1 - 1
docs/content/developer-guide/customizing-models.md

@@ -19,7 +19,7 @@ const config = {
       { name: 'shortName', type: 'localeString' },
     ],
     User: [
-      { name: 'socialLoginToken', type: 'string' },
+      { name: 'socialLoginToken', type: 'string', unique: true },
     ],
   },
 }

+ 62 - 0
packages/core/e2e/custom-fields.e2e-spec.ts

@@ -1,6 +1,7 @@
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { Asset, CustomFields, mergeConfig, TransactionalConnection } from '@vendure/core';
 import { createTestEnvironment } from '@vendure/testing';
+import { fail } from 'assert';
 import gql from 'graphql-tag';
 import path from 'path';
 
@@ -161,6 +162,11 @@ const customConfig = mergeConfig(testConfig(), {
                     }
                 },
             },
+            {
+                name: 'uniqueString',
+                type: 'string',
+                unique: true,
+            },
         ],
         Facet: [
             {
@@ -244,6 +250,7 @@ describe('Custom fields', () => {
                 { name: 'localeStringList', type: 'localeString', list: true },
                 { name: 'stringListWithDefault', type: 'string', list: true },
                 { name: 'intListWithValidation', type: 'int', list: true },
+                { name: 'uniqueString', type: 'string', list: false },
                 // The internal type should not be exposed at all
                 // { name: 'internalString', type: 'string' },
             ],
@@ -832,4 +839,59 @@ describe('Custom fields', () => {
             }, `Field "internalString" is not defined by type "ProductFilterParameter"`),
         );
     });
+
+    describe('unique constraint', () => {
+        it('setting unique value works', async () => {
+            const result = await adminClient.query(
+                gql`
+                    mutation {
+                        updateProduct(input: { id: "T_1", customFields: { uniqueString: "foo" } }) {
+                            id
+                            customFields {
+                                uniqueString
+                            }
+                        }
+                    }
+                `,
+            );
+
+            expect(result.updateProduct.customFields.uniqueString).toBe('foo');
+        });
+
+        it('setting conflicting value fails', async () => {
+            try {
+                await adminClient.query(gql`
+                    mutation {
+                        createProduct(
+                            input: {
+                                translations: [
+                                    { languageCode: en, name: "test 2", slug: "test-2", description: "" }
+                                ]
+                                customFields: { uniqueString: "foo" }
+                            }
+                        ) {
+                            id
+                        }
+                    }
+                `);
+                fail('Should have thrown');
+            } catch (e: any) {
+                let duplicateKeyErrMessage = 'unassigned';
+                switch (customConfig.dbConnectionOptions.type) {
+                    case 'mariadb':
+                    case 'mysql':
+                        duplicateKeyErrMessage = `ER_DUP_ENTRY: Duplicate entry 'foo' for key`;
+                        break;
+                    case 'postgres':
+                        duplicateKeyErrMessage = `duplicate key value violates unique constraint`;
+                        break;
+                    case 'sqlite':
+                    case 'sqljs':
+                        duplicateKeyErrMessage = `UNIQUE constraint failed: product.customFieldsUniquestring`;
+                        break;
+                }
+                expect(e.message).toContain(duplicateKeyErrMessage);
+            }
+        });
+    });
 });

+ 3 - 0
packages/core/src/config/custom-field/custom-field-types.ts

@@ -41,6 +41,7 @@ export type BaseTypedCustomFieldConfig<T extends CustomFieldType, C extends Cust
      */
     public?: boolean;
     nullable?: boolean;
+    unique?: boolean;
     ui?: UiComponentConfig<DefaultFormComponentId | string>;
 };
 
@@ -127,6 +128,8 @@ export type CustomFieldConfig =
  * * `internal?: boolean`: Whether or not the custom field is exposed at all via the GraphQL APIs. Defaults to `false`.
  * * `defaultValue?: any`: The default value when an Entity is created with this field.
  * * `nullable?: boolean`: Whether the field is nullable in the database. If set to `false`, then a `defaultValue` should be provided.
+ * * `unique?: boolean`: Whether the value of the field should be unique. When set to `true`, a UNIQUE constraint is added to the column. Defaults
+ *     to `false`.
  * * `validate?: (value: any) => string | LocalizedString[] | void`: A custom validation function. If the value is valid, then
  *     the function should not return a value. If a string or LocalizedString array is returned, this is interpreted as an error message.
  *

+ 8 - 0
packages/core/src/entity/register-custom-entity-fields.ts

@@ -5,6 +5,7 @@ import {
     ColumnOptions,
     ColumnType,
     ConnectionOptions,
+    Index,
     JoinColumn,
     JoinTable,
     ManyToMany,
@@ -89,6 +90,7 @@ function registerCustomFieldsForEntity(
                         default: getDefault(customField, dbEngine),
                         name,
                         nullable: nullable === false ? false : true,
+                        unique: customField.unique ?? false,
                     };
                     if ((customField.type === 'string' || customField.type === 'localeString') && !list) {
                         const length = customField.length || 255;
@@ -114,6 +116,12 @@ function registerCustomFieldsForEntity(
                         options.precision = 6;
                     }
                     Column(options)(instance, name);
+                    if ((dbEngine === 'mysql' || dbEngine === 'mariadb') && customField.unique === true) {
+                        // The MySQL driver seems to work differently and will only apply a unique
+                        // constraint if an index is defined on the column. For postgres/sqlite it is
+                        // sufficient to add the `unique: true` property to the column options.
+                        Index({ unique: true })(instance, name);
+                    }
                 }
             };