Browse Source

feat(core): Add support for deprecating custom fields with @deprecated directive (#3582)

Co-authored-by: Daniel Biegler <DanielBiegler@users.noreply.github.com>
Mohamed Al Ali 5 months ago
parent
commit
6fb399f62e

+ 41 - 0
docs/docs/guides/developer-guide/custom-fields/index.md

@@ -278,6 +278,7 @@ All custom fields share some common properties:
 - [`unique`](#unique)
 - [`validate`](#validate)
 - [`requiresPermission`](#requirespermission)
+- [`deprecated`](#deprecated)
 
 #### name
 
@@ -651,6 +652,46 @@ the entity's custom field value if the current customer meets the requirements.
 
 :::
 
+#### deprecated
+
+<CustomFieldProperty required={false} type="boolean | string" />
+
+Marks the custom field as deprecated in the GraphQL schema. When set to `true`, the field will be marked with the `@deprecated` directive. When set to a string, that string will be used as the deprecation reason.
+
+This is useful for API evolution - you can mark fields as deprecated to signal to API consumers that they should migrate to newer alternatives, while still maintaining backward compatibility.
+
+```ts title="src/vendure-config.ts"
+const config = {
+    // ...
+    customFields: {
+        Product: [
+            {
+                name: 'oldField',
+                type: 'string',
+                // highlight-next-line
+                deprecated: true,
+            },
+            {
+                name: 'legacyUrl',
+                type: 'string',
+                // highlight-next-line
+                deprecated: 'Use the new infoUrl field instead',
+            },
+        ]
+    }
+};
+```
+
+When querying the GraphQL schema, deprecated fields will be marked accordingly:
+
+```graphql
+type ProductCustomFields {
+    oldField: String @deprecated
+    legacyUrl: String @deprecated(reason: "Use the new infoUrl field instead")
+    infoUrl: String
+}
+```
+
 ### Properties for `string` fields
 
 In addition to the common properties, the `string` custom fields have some type-specific properties:

+ 43 - 0
packages/core/src/api/config/__snapshots__/graphql-custom-fields.spec.ts.snap

@@ -224,6 +224,49 @@ input UpdateProductCustomFieldsInput {
 }"
 `;
 
+exports[`addGraphQLCustomFields() > handles deprecated custom fields 1`] = `
+"type Product {
+  id: ID
+  customFields: ProductCustomFields
+}
+
+scalar JSON
+
+scalar DateTime
+
+type ProductCustomFields {
+  available: Boolean
+  oldField: String @deprecated
+  legacyField: Int @deprecated(reason: "Use newField instead")
+}"
+`;
+
+exports[`addGraphQLCustomFields() > handles deprecated custom fields with translations 1`] = `
+"type Product {
+  id: ID
+  translations: [ProductTranslation!]!
+  customFields: ProductCustomFields
+}
+
+type ProductTranslation {
+  id: ID
+  customFields: ProductTranslationCustomFields
+}
+
+scalar JSON
+
+scalar DateTime
+
+type ProductCustomFields {
+  available: Boolean
+  oldName: String @deprecated(reason: "Use name instead")
+}
+
+type ProductTranslationCustomFields {
+  oldName: String @deprecated(reason: "Use name instead")
+}"
+`;
+
 exports[`addGraphQLCustomFields() > publicOnly = true 1`] = `
 "type Product {
   id: ID

+ 39 - 1
packages/core/src/api/config/graphql-custom-fields.spec.ts

@@ -246,6 +246,44 @@ describe('addGraphQLCustomFields()', () => {
         const result = addGraphQLCustomFields(input, customFieldConfig, true);
         expect(printSchema(result)).toMatchSnapshot();
     });
+
+    it('handles deprecated custom fields', () => {
+        const input = `
+            type Product {
+                id: ID
+            }
+        `;
+        const customFieldConfig: CustomFields = {
+            Product: [
+                { name: 'available', type: 'boolean' },
+                { name: 'oldField', type: 'string', deprecated: true },
+                { name: 'legacyField', type: 'int', deprecated: 'Use newField instead' },
+            ],
+        };
+        const result = addGraphQLCustomFields(input, customFieldConfig, false);
+        expect(printSchema(result)).toMatchSnapshot();
+    });
+
+    it('handles deprecated custom fields with translations', () => {
+        const input = `
+            type Product {
+                id: ID
+                translations: [ProductTranslation!]!
+            }
+
+            type ProductTranslation {
+                id: ID
+            }
+        `;
+        const customFieldConfig: CustomFields = {
+            Product: [
+                { name: 'available', type: 'boolean' },
+                { name: 'oldName', type: 'localeString', deprecated: 'Use name instead' },
+            ],
+        };
+        const result = addGraphQLCustomFields(input, customFieldConfig, false);
+        expect(printSchema(result)).toMatchSnapshot();
+    });
 });
 
 describe('addOrderLineCustomFieldsInput()', () => {
@@ -260,7 +298,7 @@ describe('addOrderLineCustomFieldsInput()', () => {
             { name: 'giftWrap', type: 'boolean' },
             { name: 'message', type: 'string' },
         ];
-        const result = addOrderLineCustomFieldsInput(input, customFieldConfig);
+        const result = addOrderLineCustomFieldsInput(input, customFieldConfig, false);
         expect(printSchema(result)).toMatchSnapshot();
     });
 });

+ 18 - 1
packages/core/src/api/config/graphql-custom-fields.ts

@@ -618,7 +618,8 @@ function mapToFields(
                 return;
             }
             const name = nameFn ? nameFn(field) : field.name;
-            return `${name}: ${type}`;
+            const deprecationDirective = getDeprecationDirective(field);
+            return `${name}: ${type} ${deprecationDirective}`;
         })
         .filter(x => x != null);
     return res.join('\n');
@@ -639,6 +640,8 @@ function mapToStructFields(
                 return;
             }
             const name = nameFn ? nameFn(field) : field.name;
+            // Note: Struct fields don't currently support deprecation in the type system,
+            // but we keep this consistent for future extensibility
             return `${name}: ${type}`;
         })
         .filter(x => x != null);
@@ -747,3 +750,17 @@ function getStructInputName(entityName: string, fieldDef: StructCustomFieldConfi
 function pascalCase(input: string) {
     return input.charAt(0).toUpperCase() + input.slice(1);
 }
+
+function getDeprecationDirective(field: CustomFieldConfig): string {
+    if (!field.deprecated) {
+        return '';
+    }
+
+    if (typeof field.deprecated === 'string') {
+        // Escape quotes in the deprecation reason
+        const escapedReason = field.deprecated.replace(/"/g, '\\"');
+        return `@deprecated(reason: "${escapedReason}")`;
+    }
+
+    return '@deprecated';
+}

+ 17 - 6
packages/core/src/api/resolvers/admin/global-settings.resolver.ts

@@ -1,10 +1,10 @@
 import { Args, Info, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql';
 import {
-    CustomFields as GraphQLCustomFields,
+    EntityCustomFields,
     CustomFieldConfig as GraphQLCustomFieldConfig,
+    CustomFields as GraphQLCustomFields,
     RelationCustomFieldConfig as GraphQLRelationCustomFieldConfig,
     StructCustomFieldConfig as GraphQLStructCustomFieldConfig,
-    EntityCustomFields,
     MutationUpdateGlobalSettingsArgs,
     Permission,
     ServerConfig,
@@ -135,18 +135,29 @@ export class GlobalSettingsResolver {
                     .filter(c => !c.internal)
                     .map(c => ({ ...c, list: !!c.list as any }))
                     .map(c => {
-                        const { requiresPermission } = c;
-                        c.requiresPermission = Array.isArray(requiresPermission)
+                        const { requiresPermission, deprecated } = c;
+                        const result: any = { ...c };
+                        result.requiresPermission = Array.isArray(requiresPermission)
                             ? requiresPermission
                             : !!requiresPermission
                               ? [requiresPermission]
                               : [];
-                        return c;
+
+                        // Handle deprecation
+                        if (deprecated !== undefined) {
+                            result.deprecated = !!deprecated;
+                            result.deprecationReason =
+                                typeof deprecated === 'string' ? deprecated : undefined;
+                        } else {
+                            result.deprecated = false;
+                            result.deprecationReason = undefined;
+                        }
+                        return result;
                     })
                     .map(c => {
                         // In the VendureConfig, the relation entity is specified
                         // as the class, but the GraphQL API exposes it as a string.
-                        const customFieldConfig: GraphQLCustomFieldConfig = { ...c } as any;
+                        const customFieldConfig: GraphQLCustomFieldConfig = { ...c };
                         if (this.isRelationGraphQLType(customFieldConfig) && this.isRelationConfigType(c)) {
                             customFieldConfig.entity = c.entity.name;
                             customFieldConfig.scalarFields = this.getScalarFieldsOfType(

+ 22 - 0
packages/core/src/api/schema/common/custom-field-types.graphql

@@ -8,6 +8,8 @@ interface CustomField {
     internal: Boolean
     nullable: Boolean
     requiresPermission: [Permission!]
+    deprecated: Boolean
+    deprecationReason: String
     ui: JSON
 }
 
@@ -22,6 +24,8 @@ type StringCustomFieldConfig implements CustomField {
     internal: Boolean
     nullable: Boolean
     requiresPermission: [Permission!]
+    deprecated: Boolean
+    deprecationReason: String
     pattern: String
     options: [StringFieldOption!]
     ui: JSON
@@ -43,6 +47,8 @@ type LocaleStringCustomFieldConfig implements CustomField {
     internal: Boolean
     nullable: Boolean
     requiresPermission: [Permission!]
+    deprecated: Boolean
+    deprecationReason: String
     pattern: String
     ui: JSON
 }
@@ -56,6 +62,8 @@ type IntCustomFieldConfig implements CustomField {
     internal: Boolean
     nullable: Boolean
     requiresPermission: [Permission!]
+    deprecated: Boolean
+    deprecationReason: String
     min: Int
     max: Int
     step: Int
@@ -71,6 +79,8 @@ type FloatCustomFieldConfig implements CustomField {
     internal: Boolean
     nullable: Boolean
     requiresPermission: [Permission!]
+    deprecated: Boolean
+    deprecationReason: String
     min: Float
     max: Float
     step: Float
@@ -86,6 +96,8 @@ type BooleanCustomFieldConfig implements CustomField {
     internal: Boolean
     nullable: Boolean
     requiresPermission: [Permission!]
+    deprecated: Boolean
+    deprecationReason: String
     ui: JSON
 }
 """
@@ -102,6 +114,8 @@ type DateTimeCustomFieldConfig implements CustomField {
     internal: Boolean
     nullable: Boolean
     requiresPermission: [Permission!]
+    deprecated: Boolean
+    deprecationReason: String
     min: String
     max: String
     step: Int
@@ -118,6 +132,8 @@ type RelationCustomFieldConfig implements CustomField {
     internal: Boolean
     nullable: Boolean
     requiresPermission: [Permission!]
+    deprecated: Boolean
+    deprecationReason: String
     entity: String!
     scalarFields: [String!]!
     ui: JSON
@@ -133,6 +149,8 @@ type TextCustomFieldConfig implements CustomField {
     internal: Boolean
     nullable: Boolean
     requiresPermission: [Permission!]
+    deprecated: Boolean
+    deprecationReason: String
     ui: JSON
 }
 
@@ -146,6 +164,8 @@ type LocaleTextCustomFieldConfig implements CustomField {
     internal: Boolean
     nullable: Boolean
     requiresPermission: [Permission!]
+    deprecated: Boolean
+    deprecationReason: String
     ui: JSON
 }
 
@@ -244,6 +264,8 @@ type StructCustomFieldConfig implements CustomField {
     internal: Boolean
     nullable: Boolean
     requiresPermission: [Permission!]
+    deprecated: Boolean
+    deprecationReason: String
     ui: JSON
 }
 

+ 19 - 10
packages/core/src/config/custom-field/custom-field-types.ts

@@ -1,32 +1,32 @@
 import {
-    BooleanCustomFieldConfig as GraphQLBooleanCustomFieldConfig,
     CustomField,
+    BooleanCustomFieldConfig as GraphQLBooleanCustomFieldConfig,
+    BooleanStructFieldConfig as GraphQLBooleanStructFieldConfig,
     DateTimeCustomFieldConfig as GraphQLDateTimeCustomFieldConfig,
+    DateTimeStructFieldConfig as GraphQLDateTimeStructFieldConfig,
     FloatCustomFieldConfig as GraphQLFloatCustomFieldConfig,
+    FloatStructFieldConfig as GraphQLFloatStructFieldConfig,
     IntCustomFieldConfig as GraphQLIntCustomFieldConfig,
+    IntStructFieldConfig as GraphQLIntStructFieldConfig,
     LocaleStringCustomFieldConfig as GraphQLLocaleStringCustomFieldConfig,
     LocaleTextCustomFieldConfig as GraphQLLocaleTextCustomFieldConfig,
-    LocalizedString,
-    Permission,
     RelationCustomFieldConfig as GraphQLRelationCustomFieldConfig,
     StringCustomFieldConfig as GraphQLStringCustomFieldConfig,
-    TextCustomFieldConfig as GraphQLTextCustomFieldConfig,
+    StringStructFieldConfig as GraphQLStringStructFieldConfig,
     StructCustomFieldConfig as GraphQLStructCustomFieldConfig,
     StructField as GraphQLStructField,
-    StringStructFieldConfig as GraphQLStringStructFieldConfig,
-    IntStructFieldConfig as GraphQLIntStructFieldConfig,
+    TextCustomFieldConfig as GraphQLTextCustomFieldConfig,
     TextStructFieldConfig as GraphQLTextStructFieldConfig,
-    FloatStructFieldConfig as GraphQLFloatStructFieldConfig,
-    BooleanStructFieldConfig as GraphQLBooleanStructFieldConfig,
-    DateTimeStructFieldConfig as GraphQLDateTimeStructFieldConfig,
+    LocalizedString,
+    Permission,
 } from '@vendure/common/lib/generated-types';
 import {
     CustomFieldsObject,
     CustomFieldType,
     DefaultFormComponentId,
+    StructFieldType,
     Type,
     UiComponentConfig,
-    StructFieldType,
 } from '@vendure/common/lib/shared-types';
 
 import { RequestContext } from '../../api/common/request-context';
@@ -63,6 +63,15 @@ export type BaseTypedCustomFieldConfig<T extends CustomFieldType, C extends Cust
      * @since 2.2.0
      */
     requiresPermission?: Array<Permission | string> | Permission | string;
+    /**
+     * @description
+     * Marks the custom field as deprecated. When set to `true` or a string,
+     * the field will be marked as deprecated in the GraphQL schema.
+     * If a string is provided, it will be used as the deprecation reason.
+     *
+     * @since 3.4.0
+     */
+    deprecated?: boolean | string;
     ui?: UiComponentConfig<DefaultFormComponentId | string>;
 };