diff --git a/.changeset/chatty-teachers-repeat.md b/.changeset/chatty-teachers-repeat.md new file mode 100644 index 000000000..0fe8be1f3 --- /dev/null +++ b/.changeset/chatty-teachers-repeat.md @@ -0,0 +1,102 @@ +--- +'@graphql-codegen/flutter-freezed': major +--- + +# Configuring the plugin using patterns + +## What has changed + +The following type definitions have been removed: + +- CustomDecorator = Record; + +- DecoratorToFreezed + - arguments?: string[]; + - applyOn: ApplyDecoratorOn[]; + - mapsToFreezedAs: '@Default' | '@deprecated' | 'final' | 'directive' | 'custom'; + +- FieldConfig + - final?: boolean; + - deprecated?: boolean; + - defaultValue?: any; + - customDecorators?: CustomDecorator; + +- FreezedConfig + - alwaysUseJsonKeyName?: boolean; + - copyWith?: boolean; + - customDecorators?: CustomDecorator; + - defaultUnionConstructor?: boolean; + - equal?: boolean; + - fromJsonToJson?: boolean; + - immutable?: boolean; + - makeCollectionsUnmodifiable?: boolean; + - mergeInputs?: string[]; + - mutableInputs?: boolean; + - privateEmptyConstructor?: boolean; + - unionKey?: string; + - unionValueCase?: 'FreezedUnionCase.camel' | 'FreezedUnionCase.pascal'; + +- TypeSpecificFreezedConfig + - deprecated?: boolean; + - config?: FreezedConfig; + - fields?: Record; + +- FlutterFreezedPluginConfig: + - fileName?: string; + - globalFreezedConfig?: FreezedConfig + - typeSpecificFreezedConfig?: Record; + +## Why those type definitions were removed + +The previous version allow you to configure GraphQL Types and its fields globally using the `globalFreezedConfig` and override the global configuration with specific ones of each GraphQL Type using the `typeSpecificFreezedConfig`. + +This resulted in a bloated configuration file with duplicated configuration for the same options but for different cases. + +To emphasize on the problem, consider the before and after configurations below: + +Before: + +```ts +{ + globalFreezedConfig: { + immutable: true, + }, + typeSpecificFreezedConfig: { + Starship: { + deprecated: true, + }, + Droid: { + config: { + immutable: false, + }, + fields: { + id: { + deprecated: true, + }, + }, + }, + }, +}; +``` + +After: + +```ts + +{ + immutable: TypeNamePattern.forAllTypeNamesExcludeTypeNames([Droid]), + deprecated: [ + [TypeNamePattern.forTypeNames([Starship]), ['default_factory']], + [FieldNamePattern.forFieldNamesOfTypeName([[Droid, id]]), ['default_factory_parameter']], + ], + } +``` + +The 2 configurations above do the same thing, the later being more compact, flexible and readable than the former. + +## How to update your existing configuration + +First understand the [usage of the Patterns](https://the-guild.dev/graphql/codegen/docs/guides/flutter-freezed#configuring-the-plugin), then create a new config file(preferably a typescript file: previous version of the code generator used a YAML file). +And implement the new configuration one by one inspecting the generated output. + +> Please avoid migrating all your configuration at once. Doing that means you wont be able to inspect the generated output and ensure that the expected results are produced. diff --git a/packages/plugins/dart/flutter-freezed/package.json b/packages/plugins/dart/flutter-freezed/package.json index b8eb6cfdd..c54e8e4bc 100644 --- a/packages/plugins/dart/flutter-freezed/package.json +++ b/packages/plugins/dart/flutter-freezed/package.json @@ -17,8 +17,8 @@ "@graphql-codegen/schema-ast": "^2.5.0", "@graphql-codegen/visitor-plugin-common": "2.13.1", "auto-bind": "~4.0.0", - "tslib": "~2.4.0", - "change-case-all": "1.0.15" + "change-case-all": "1.0.15", + "tslib": "~2.4.0" }, "peerDependencies": { "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" diff --git a/packages/plugins/dart/flutter-freezed/src/config.ts b/packages/plugins/dart/flutter-freezed/src/config.ts deleted file mode 100644 index 59908a1f9..000000000 --- a/packages/plugins/dart/flutter-freezed/src/config.ts +++ /dev/null @@ -1,542 +0,0 @@ -/** - * @name ApplyDecoratorOn - * @description Values that are passed to the `DecoratorToFreezed.applyOn` field that specifies where the custom decorator should be applied - */ -export type ApplyDecoratorOn = - | 'enum' - | 'enum_field' - | 'class' - | 'class_factory' - | 'union_factory' - | 'merged_input_factory' - | 'class_factory_parameter' - | 'union_factory_parameter' - | 'merged_input_parameter'; - -/** - * @name DecoratorToFreezed - * @description the value of a `CustomDecorator`. This value specifies how the the decorator should be handled by Freezed - */ -export type DecoratorToFreezed = { - /** - * @name arguments - * @description Arguments to be applied on the decorator. if the `mapsToFreezedAs === 'directive'`, use template string such `['$0', '$2', '$3']` to select/order the arguments of the directive to be used($0 is the first argument, $1 is the second). - * @default undefined - * @exampleMarkdown - * ```yaml - * arguments: [$0] # $0 is the first argument, $1 is the 2nd ... - * ``` - */ - arguments?: string[]; //['$0'] - - /** - * @name applyOn - * @description Specify where the decorator should be applied - * @exampleMarkdown - * ```yaml - * applyOn: ['class_factory','union_factory'], # applies this decorator on both class and union factory blocks - * ``` - */ - applyOn: ApplyDecoratorOn[]; - - /** - * @name mapsToFreezedAs - * @description maps to a Freezed decorator or use `custom` to use a custom decorator.If `mapsToFreezedAs === 'directive'` don't include the `@` prefix in the key of the customDecorator. If `mapsToFreezedAs === 'custom'` value, whatever you use as the key of the customDecorator is used just as it is, and the arguments spread into a parenthesis () */ - mapsToFreezedAs: '@Default' | '@deprecated' | 'final' | 'directive' | 'custom'; -}; - -/** - * @name CustomDecorator - * @description - * use this option to add annotations/decorators for the the generated output. Also use this to add @Assert decorators to validate the properties of the model - */ -export type CustomDecorator = Record; - -/** - * @name FreezedConfig - * @description configure what Freeze should generate - * @default DefaultFreezedConfig - */ -export interface FreezedConfig { - /** - * @name alwaysUseJsonKeyName - * @description Use @JsonKey(name: 'name') even if the name is already camelCased - * @default false - * - * @exampleMarkdown - * ```yaml - * generates: - * flutter_app/lib/data/models/app_models.dart - * plugins: - * - flutter-freezed - * config: - * alwaysUseJsonKeyName: true - * - * ``` - */ - - alwaysUseJsonKeyName?: boolean; - - /** - * @name copyWith - * @description set to false to disable Freezed copyWith method helper - * @default undefined - * - * @exampleMarkdown - * ```yaml - * generates: - * flutter_app/lib/data/models/app_models.dart - * plugins: - * - flutter-freezed - * config: - * copyWith: false - * ``` - */ - - copyWith?: boolean; - - /** - * @name customDecorators - * @description annotate/decorate the generated output. Also use this option to map GraphQL directives to freezed decorators. - * @default {} - * - * @exampleMarkdown - * ```yaml - * generates: - * flutter_app/lib/data/models/app_models.dart - * plugins: - * - flutter-freezed - * config: - * customDecorators: { - * 'default' : { - * mapsToFreezedAs: '@Default', - * arguments: ['$0'], - * }, - * 'deprecated' : { - * mapsToFreezedAs: '@deprecated', - * }, - * 'readonly' : { - * mapsToFreezedAs: 'final', - * }, - * '@Assert' : { - * mapsToFreezedAs: 'custom', - * applyOn: ['class_factory','union_factory'], # @Assert should ONLY be used on factories - * arguments: [ - * '(email != null && email != "") || (phoneNumber != null && phoneNumber != "")', - * 'provide either an email or a phoneNumber', - * ], - * }, # custom are used just as it given - * } - * - * ``` - */ - - customDecorators?: CustomDecorator; - - /** - * @name defaultUnionConstructor - * @description generate empty constructors for Union Types and mergedInputs - * @default true - * - * @exampleMarkdown - * ```yaml - * generates: - * flutter_app/lib/data/models/app_models.dart - * plugins: - * - flutter-freezed - * config: - * defaultUnionConstructor: true - * ``` - */ - - defaultUnionConstructor?: boolean; - - /** - * @name equal - * @description set to false to disable Freezed equal method helper - * @default undefined - * - * @exampleMarkdown - * ```yaml - * generates: - * flutter_app/lib/data/models/app_models.dart - * plugins: - * - flutter-freezed - * config: - * equal: false - * ``` - */ - - equal?: boolean; - - /** - * @name fromJsonToJson - * @description generate fromJson toJson methods on the classes with json_serialization. Requires the [json_serializable](https://pub.dev/packages/json_serializable) to be installed in your Flutter app - * @default true - * - * @exampleMarkdown - * ```yaml - * generates: - * flutter_app/lib/data/models/app_models.dart - * plugins: - * - flutter-freezed - * config: - * fromJsonToJson: true - * - * ``` - */ - - fromJsonToJson?: boolean; - - /** - * @name immutable - * @description set to true to use the `@freezed` decorator or false to use the `@unfreezed` decorator - * @default true - * - * @exampleMarkdown - * ```yaml - * generates: - * flutter_app/lib/data/models/app_models.dart - * plugins: - * - flutter-freezed - * config: - * immutable: true - * - * ``` - */ - - immutable?: boolean; - - /** - * @name makeCollectionsUnmodifiable - * @description allows collections(lists/maps) to be modified even if class is immutable - * @default undefined - * - * @exampleMarkdown - * ```yaml - * generates: - * flutter_app/lib/data/models/app_models.dart - * plugins: - * - flutter-freezed - * config: - * makeCollectionsUnmodifiable: true - * - * ``` - */ - - makeCollectionsUnmodifiable?: boolean; - - /** - * @name mergeInputs - * @description merge InputTypes as a union of an ObjectType where ObjectType is denoted by a $ in the pattern. - * @default [] - * - * @exampleMarkdown - * ```yaml - * generates: - * flutter_app/lib/data/models/app_models.dart - * plugins: - * - flutter-freezed - * config: - * mergeInputs: ["Create$Input", "Update$Input", "Delete$Input"] - * ``` - */ - - mergeInputs?: string[]; - - /** - * @name mutableInputs - * @description since inputs will be used to collect data, it makes sense to make them mutable with Freezed's `@unfreezed` decorator. This overrides(in order words: has a higher precedence than) the `immutable` config value `ONLY` for GraphQL `input types`. - * @default true - * - * @exampleMarkdown - * ```yaml - * generates: - * flutter_app/lib/data/models/app_models.dart - * plugins: - * - flutter-freezed - * config: - * mutableInputs: true - * - * ``` - */ - - mutableInputs?: boolean; - - /** - * @name privateEmptyConstructor - * @description if true, defines a private empty constructor to allow getter and methods to work on the class - * @default true - * - * @exampleMarkdown - * ```yaml - * generates: - * flutter_app/lib/data/models/app_models.dart - * plugins: - * - flutter-freezed - * config: - * privateEmptyConstructor: true - * - * ``` - */ - - privateEmptyConstructor?: boolean; - - /** - * @name unionKey - * @description specify the key to be used for Freezed union/sealed classes - * @default undefined - * - * @exampleMarkdown - * ```yaml - * generates: - * flutter_app/lib/data/models/app_models.dart - * plugins: - * - flutter-freezed - * config: - * unionKey: 'type' - * - * ``` - */ - - unionKey?: string; - - /** - * @name unionValueCase - * @description specify the casing style to be used for Freezed union/sealed classes - * @default undefined - * - * @exampleMarkdown - * ```yaml - * generates: - * flutter_app/lib/data/models/app_models.dart - * plugins: - * - flutter-freezed - * config: - * unionValueCase: 'FreezedUnionCase.pascal' - * - * ``` - */ - - unionValueCase?: 'FreezedUnionCase.camel' | 'FreezedUnionCase.pascal'; -} - -/** - * @name FieldConfig - * @description configuration for the field - */ -export interface FieldConfig { - /** - * @name final - * @description marks a field as final - * @default undefined - */ - - final?: boolean; - - /** - * @name deprecated - * @description marks a field as deprecated - * @default undefined - */ - - deprecated?: boolean; - - /** - * @name defaultValue - * @description annotate a field with a @Default(value: defaultValue) decorator - * @default undefined - */ - - defaultValue?: any; - - /** - * @name customDecorators - * @description specific directives to apply to the field. All `mapsToFreezedAs` values except `custom` are parsed so use the name of the directive without the `@` symbol as the key of the customDecorators. With the `custom` value, whatever you use as the key of the custom directive is used just as it is, and the arguments spread into a parenthesis () - * @default undefined - * @exampleMarkdown - * ```yaml - * customDecorators: { - * 'default' : { - * mapsToFreezedAs: '@Default', - * applyOn: ['class_factory_parameter], - * arguments: ['$0'], - * }, - * 'deprecated' : { - * mapsToFreezedAs: '@deprecated', - * applyOn: ['union_factory_parameter], - * }, - * 'readonly' : { - * mapsToFreezedAs: 'final', - * applyOn: ['class_factory_parameter','union_factory_parameter'], - * }, - * '@HiveField' : { - * mapsToFreezedAs: 'custom', - * applyOn: ['class_factory_parameter'], - * arguments: ['1'], - * }, # custom are used just as it given - * } - * ``` - */ - - customDecorators?: CustomDecorator; -} - -/** - * @name TypeSpecificFreezedConfig - * @description override the `FlutterFreezedPluginConfig.globalFreezedConfig` option for a specific type - */ -export interface TypeSpecificFreezedConfig { - /** marks a type as deprecated */ - - deprecated?: boolean; - - /** overrides the `globalFreezedConfig` for this type */ - - config?: FreezedConfig; - - /** configure fields for this type. The GraphQL field name is the key */ - - fields?: Record; -} - -/** - * @name FlutterFreezedPluginConfig - * @description configure the `flutter-freezed` plugin - */ -export interface FlutterFreezedPluginConfig /* extends TypeScriptPluginConfig */ { - /** - * @name camelCasedEnums - * @description Dart's recommended lint uses camelCase for enum fields. Set this option to `false` to use the same case as used in the GraphQL Schema but note this can cause lint issues. - * @default true - * - * @exampleMarkdown - * ```yaml - * generates: - * flutter_app/lib/data/models/app_models.dart - * plugins: - * - flutter-freezed - * config: - * camelCasedEnums: true - * ``` - */ - - camelCasedEnums?: boolean; - - /** - * @name customScalars - * @description map custom Scalars to Dart built-in types - * @default {} - * - * @exampleMarkdown - * ```yaml - * generates: - * flutter_app/lib/data/models/app_models.dart - * plugins: - * - flutter-freezed - * config: - * customScalars: - * { - * "jsonb": "Map", - * "timestamptz": "DateTime", - * "UUID": "String", - * } - * ``` - */ - - customScalars?: { [name: string]: string }; - - /** - * @name fileName - * @description this fileName will be used for the generated output file - * @default "app_models" - * - * @exampleMarkdown - * ```yaml - * generates: - * flutter_app/lib/data/models/app_models.dart - * plugins: - * - flutter-freezed - * config: - * fileName: app_models - * - * ``` - */ - - fileName?: string; - - /** - * @name globalFreezedConfig - * @description use the same Freezed configuration for every generated output - * @default DefaultFreezedConfig - * - * @exampleMarkdown - * ```yaml - * generates: - * flutter_app/lib/data/models/app_models.dart - * plugins: - * - flutter-freezed - * config: - * globalFreezedConfig: - * { - * immutable: false, - * unionValueCase: FreezedUnionCase.pascal, - * } - * - * ``` - */ - - globalFreezedConfig?: FreezedConfig; - - /** - * @name typeSpecificFreezedConfig - * @description override the `globalFreezedConfig` for specific types. The GraphQL Type name is the key - * @default undefined - * - * @exampleMarkdown - * ```yaml - * generates: - * flutter_app/lib/data/models/app_models.dart - * plugins: - * - flutter-freezed - * config: - * typeSpecificFreezedConfig: - * { - * 'Starship':{ - * config: { - * immutable: false, - * unionValueCase: FreezedUnionCase.pascal, - * }, - * fields: { - * 'id': { - * final: true, - * defaultValue: NanoId.id(), - * }, - * }, - * }, - * }, - * - * ``` - */ - - typeSpecificFreezedConfig?: Record; - - /** - * @name ignoreTypes - * @description names of GraphQL types to ignore when generating Freezed classes - * @default [] - * - * @exampleMarkdown - * ```yaml - * generates: - * flutter_app/lib/data/models/app_models.dart - * plugins: - * - flutter-freezed - * config: - * ignoreTypes: ["PaginatorInfo"] - * - * ``` - */ - - ignoreTypes?: string[]; -} diff --git a/packages/plugins/dart/flutter-freezed/src/config/config-value.ts b/packages/plugins/dart/flutter-freezed/src/config/config-value.ts new file mode 100644 index 000000000..0c6eea0ce --- /dev/null +++ b/packages/plugins/dart/flutter-freezed/src/config/config-value.ts @@ -0,0 +1,197 @@ +import { appliesOnBlock } from '../utils.js'; +import { FieldName, Pattern, TypeName, TypeNamePattern } from './pattern.js'; +import { + AppliesOn, + AppliesOnFactory, + AppliesOnParameters, + DART_SCALARS, + defaultFreezedPluginConfig, + FlutterFreezedPluginConfig, +} from './plugin-config.js'; + +export class Config { + static camelCasedEnums = (config: FlutterFreezedPluginConfig) => { + const _camelCasedEnums = config.camelCasedEnums; + + if (_camelCasedEnums === true) { + return 'camelCase'; + } else if (_camelCasedEnums === false) { + return undefined; + } + return _camelCasedEnums; + }; + + static copyWith = (config: FlutterFreezedPluginConfig, typeName?: TypeName) => { + return Config.enableWithBooleanOrTypeFieldName(config.copyWith, typeName); + }; + + static customScalars = (config: FlutterFreezedPluginConfig, graphqlScalar: string): string => { + return config.customScalars?.[graphqlScalar] ?? DART_SCALARS[graphqlScalar] ?? graphqlScalar; + }; + + static defaultValues = ( + config: FlutterFreezedPluginConfig, + blockAppliesOn: readonly AppliesOnParameters[], + typeName: TypeName, + fieldName: FieldName + ) => { + const decorator = (defaultValue: string) => (defaultValue ? `@Default(${defaultValue})\n` : ''); + + const defaultValue = config.defaultValues + ?.filter( + ([pattern, , configAppliesOn]) => + Pattern.findLastConfiguration(pattern, typeName, fieldName) && appliesOnBlock(configAppliesOn, blockAppliesOn) + ) + ?.slice(-1)?.[0]?.[1]; + + return decorator(defaultValue); + }; + + static deprecated = ( + config: FlutterFreezedPluginConfig, + blockAppliesOn: readonly (AppliesOnFactory | AppliesOnParameters)[], + typeName: TypeName, + fieldName?: FieldName + ) => { + const isDeprecated = + config.deprecated + ?.filter( + ([pattern, configAppliesOn]) => + Pattern.findLastConfiguration(pattern, typeName, fieldName) && + appliesOnBlock(configAppliesOn, blockAppliesOn) + ) + ?.slice(-1)?.[0] !== undefined; + return isDeprecated ? '@deprecated\n' : ''; + }; + + static equal = (config: FlutterFreezedPluginConfig, typeName?: TypeName) => { + return Config.enableWithBooleanOrTypeFieldName(config.equal, typeName); + }; + + static escapeDartKeywords = ( + config: FlutterFreezedPluginConfig, + blockAppliesOn: readonly AppliesOn[], + typeName?: TypeName, + fieldName?: FieldName + ): [prefix?: string, suffix?: string] => { + const escapeDartKeywords = config.escapeDartKeywords; + + if (escapeDartKeywords === true) { + return ['', '_']; // use a suffix `_` + } else if (typeName && Array.isArray(escapeDartKeywords) && escapeDartKeywords.length > 0) { + const [, prefix, suffix] = escapeDartKeywords + .filter( + ([pattern, , , configAppliesOn]) => + Pattern.findLastConfiguration(pattern, typeName, fieldName) && + appliesOnBlock(configAppliesOn, blockAppliesOn) + ) + .slice(-1)[0]; + return [prefix, suffix]; + } + return ['', '']; // no suffix + }; + + static final = ( + config: FlutterFreezedPluginConfig, + blockAppliesOn: readonly AppliesOnParameters[], + typeName: TypeName, + fieldName: FieldName + ): boolean => { + return ( + config.final + ?.filter( + ([pattern, configAppliesOn]) => + Pattern.findLastConfiguration(pattern, typeName, fieldName) && + appliesOnBlock(configAppliesOn, blockAppliesOn) + ) + ?.slice(-1)?.[0] !== undefined + ); + }; + + /* static fromJsonToJson = ( // TODO: @next-version + config: FlutterFreezedPluginConfig, + blockAppliesOn?: readonly AppliesOnParameters[], + typeName?: TypeName, + fieldName?: FieldName + ) => { + const fromJsonToJson = config.fromJsonToJson; + + if (typeName && fieldName && Array.isArray(fromJsonToJson)) { + const [, classOrFunctionName, useClassConverter] = (fromJsonToJson ?? []) + ?.filter( + ([pattern, , , appliesOn]) => + Pattern.findLastConfiguration(pattern, typeName, fieldName) && appliesOnBlock(appliesOn, blockAppliesOn) + ) + ?.slice(-1)?.[0]; + return [classOrFunctionName, useClassConverter] as [classOrFunctionName: string, useClassConverter?: boolean]; + } else if (typeName && fromJsonToJson instanceof TypeNamePattern) { + return Pattern.findLastConfiguration(fromJsonToJson, typeName); + } + + return fromJsonToJson as boolean; + }; + */ + static ignoreTypes = (config: FlutterFreezedPluginConfig, typeName: TypeName): string[] => { + const ignoreTypes = config.ignoreTypes; + if (ignoreTypes) { + const isIgnored = Pattern.findLastConfiguration(ignoreTypes, typeName); + return isIgnored ? [typeName.value] : []; + } + return []; + }; + + static immutable = (config: FlutterFreezedPluginConfig, typeName?: TypeName) => { + return Config.enableWithBooleanOrTypeFieldName(config.immutable, typeName); + }; + + static makeCollectionsUnmodifiable = (config: FlutterFreezedPluginConfig, typeName?: TypeName) => { + return Config.enableWithBooleanOrTypeFieldName(config.makeCollectionsUnmodifiable, typeName); + }; + + static mergeTypes = (config: FlutterFreezedPluginConfig, typeName: TypeName) => { + return config.mergeTypes?.[typeName.value] ?? []; + }; + + static mutableInputs = (config: FlutterFreezedPluginConfig, typeName?: TypeName) => { + return Config.enableWithBooleanOrTypeFieldName(config.mutableInputs, typeName); + }; + + static privateEmptyConstructor = (config: FlutterFreezedPluginConfig, typeName?: TypeName) => { + return Config.enableWithBooleanOrTypeFieldName(config.privateEmptyConstructor, typeName); + }; + + static unionClass = (/* config: FlutterFreezedPluginConfig, index: number, unionTypeName: TypeName */) => { + // const unionClass = config['unionClass']; + + return undefined; + }; + + static unionKey = (/* config: FlutterFreezedPluginConfig, typeName: TypeName */): string | undefined => { + return undefined; + }; + + static unionValueCase = (/* config: FlutterFreezedPluginConfig, typeName: TypeName */): string | undefined => { + return undefined; + }; + + static unionValueDecorator = () => + /* config: FlutterFreezedPluginConfig, + unionTypeName: TypeName, + unionValueTypeName: TypeName */ + { + return undefined; + }; + + static enableWithBooleanOrTypeFieldName = (value?: boolean | TypeNamePattern, typeName?: TypeName) => { + if (typeof value === 'boolean') { + return value; + } else if (value !== undefined && typeName !== undefined) { + return Pattern.findLastConfiguration(value, typeName); + } + return undefined; + }; + + public static create = (...config: Partial[]): FlutterFreezedPluginConfig => { + return Object.assign({}, defaultFreezedPluginConfig, ...config); + }; +} diff --git a/packages/plugins/dart/flutter-freezed/src/config/pattern.ts b/packages/plugins/dart/flutter-freezed/src/config/pattern.ts new file mode 100644 index 000000000..d34bbee2f --- /dev/null +++ b/packages/plugins/dart/flutter-freezed/src/config/pattern.ts @@ -0,0 +1,980 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { arrayWrap, resetIndex, strToList } from '../utils.js'; + +/** + * A list of GraphQL Type Names + */ +type TypeNames = TypeName | TypeName[]; + +/** + * A list of field names of a GraphQL Type + */ +type FieldNames = FieldName | FieldName[]; + +/** + * @name TypeName + * @description represents a single valid GraphQL Type Name used in the GraphQL Schema provided + * @exampleMarkdown + * ```ts filename:"config.ts" + * // returns a TypeName + * let typeName: TypeName = TypeName.fromString('Droid'); + * + * // to configure a FactoryBlock, use the `TypeName.fromUnionOfTypeNames(className, factoryName)` + * let typeName: TypeName = TypeName.fromUnionOfTypeNames(SearchResult, Droid); + + * // the following will throw an error + * // can contain only a single value... + * let typeName: TypeName = TypeName.fromString('Droid, Human'); // throws an Error + * + * // value can not be an empty string... + * let typeName: TypeName = TypeName.fromString(''); // throws an Error + * + * // value must contain only AlphaNumeric characters only... + * let typeName: TypeName = TypeName.fromString('Invalid.Name'); // throws an Error + * ``` + */ + +export class TypeName { + private _value: string; + + private constructor(value: string) { + this._value = value; + } + get value(): string { + return this._value; + } + + static get allTypeNames(): string { + return '@*TypeNames'; + } + + static fromUnionOfTypeNames = (className: TypeName, factoryName: TypeName): TypeName => + new TypeName(`${className.value}_${factoryName.value}`); + + static fromString = (value: string) => { + if (value === undefined || value.length < 1) { + throw new Error('TypeName is the name of a GraphQL Type and it cannot be empty'); + } else if (/([^a-zA-Z0-9_])/gim.test(value)) { + throw new Error('TypeName is the name of a GraphQL Type and it must consist of only AlphaNumeric characters'); + } + return new TypeName(value.trim()); + }; +} + +/** + * @name FieldName + * @description Represents a single valid name of a field belong to a Graphql Type. + * @exampleMarkdown + * ```ts filename:"config.ts" + * // returns a FieldName + * let fieldName: FieldName = FieldName.fromString('id'); + * + * // the following will throw an error + * // can contain only a single value... + * let fieldName: FieldName = FieldName.fromString('id, name'); // throws an Error + * + * // value can not be an empty string... + * let fieldName: FieldName = FieldName.fromString(''); // throws an Error + * + * // value must contain only AlphaNumeric characters only... + * let fieldName: FieldName = FieldName.fromString('Invalid.Name'); // throws an Error + * ``` + */ + +export class FieldName { + private _value: string; + + private constructor(value: string) { + this._value = value; + } + + get value(): string { + return this._value; + } + + static get allFieldNames(): string { + return '@*FieldNames'; + } + + static fromString = (value: string) => { + if (value === undefined || value.length < 1) { + throw new Error('FieldName is the name of a field in a GraphQL Type and it cannot be empty'); + } else if (/([^a-zA-Z0-9_])/gim.test(value)) { + throw new Error( + 'FieldName is the name of a field in a GraphQL Type and it must consist of only AlphaNumeric characters' + ); + } + return new FieldName(value.trim()); + }; +} + +/** + * @name Pattern + * @description The base class for TypeNamePattern and FieldNamePattern + * @see {@link url TypeNamePattern} + * @see {@link url FieldNamePattern} + */ +export class Pattern { + private _value: string; + + protected constructor(value: string) { + this._value = value; + } + + get value(): string { + return this._value; + } + + protected static fromString = (value: string) => { + if (value === undefined || value.length < 1) { + throw new Error('Pattern cannot be created from an empty string'); + } + + return new Pattern(value.endsWith(';') ? value : `${value};`); + }; + + // #region attemptMatchAndConfigure + static attemptMatchAndConfigure = (pattern: FieldNamePattern, typeName: TypeName, fieldName?: FieldName) => { + if (pattern.value.split(';').filter(_pattern => _pattern.length > 0).length !== 1) { + throw new Error( + 'attemptMatchAndConfigure can only handle one pattern at a time... use the `splitPatterns(...)` helper function to split your patterns into a list and loop over the list calling the `attemptMatchAndConfigure(...)` for each single pattern' + ); + } + + const isTypeNamePattern = (baseName: string): boolean => { + if ( + fieldName === undefined && + (baseName === 'TypeNames' || baseName === 'AllTypeNames' || baseName === 'AllTypeNamesExcludeTypeNames') + ) { + return true; + } + return false; + }; + + const regexpFor = (baseName: string): RegExp => { + return isTypeNamePattern(baseName) + ? TypeNamePattern[`regexpFor${baseName}`] + : FieldNamePattern[`regexpFor${baseName}`]; + }; + + const matchAndConfigure = ( + baseName: string, + pattern: FieldNamePattern, + ...args: (TypeName | FieldName)[] + ): MatchAndConfigure | undefined => { + return isTypeNamePattern(baseName) + ? TypeNamePattern[`matchAndConfigure${baseName}`](pattern, ...args) + : FieldNamePattern[`matchAndConfigure${baseName}`](pattern, ...args); // check if fieldName is passed in ...args + }; + + const matchList: string[] = Pattern.getMatchList(fieldName === undefined ? 'TypeNamePattern' : 'FieldNamePattern'); + for (let i = 0; i < matchList.length; i++) { + const baseName = matchList[i]; + + if (regexpFor(baseName).test(pattern.value)) { + return matchAndConfigure(baseName, pattern, typeName, fieldName); + } + } + + return undefined; + }; + //#endregion + + //#region composePatterns + static compose = (data: Pattern[]): Pattern => { + if (data.length < 1) { + throw new Error('Pattern cannot be created... an empty array was passed as parameter'); + } + + return Pattern.fromString(data.map(pattern => pattern.value).join('')); + }; + //#endregion + + //#region split + static split = (pattern: Pattern): Pattern[] => { + return pattern.value + .split(';') + .filter(_pattern => _pattern.length > 0) + .map(_pattern => Pattern.fromString(_pattern)); + }; + //#endregion + + //#region helper methods + static getMatchList = (patternType: 'TypeNamePattern' | 'FieldNamePattern' | 'Pattern') => { + const baseNamesForTypeNamePattern = Object.getOwnPropertyNames(TypeNamePattern) + .filter(property => TypeNamePattern[property] instanceof RegExp) + .map(regexpForName => regexpForName.slice(9)); + + const baseNamesForFieldNamePattern = Object.getOwnPropertyNames(FieldNamePattern) + .filter(property => FieldNamePattern[property] instanceof RegExp) + .map(regexpForName => regexpForName.slice(9)); + + return patternType === 'TypeNamePattern' + ? baseNamesForTypeNamePattern + : patternType === 'FieldNamePattern' + ? baseNamesForFieldNamePattern + : [...baseNamesForTypeNamePattern, ...baseNamesForFieldNamePattern]; + }; + //#endregion + + /** + * finds the last pattern that configures the typeName and/or fieldName given + * @param pattern + * @param typeName + * @param fieldName + * @returns true if a pattern marks the typeName and or fieldName given to be configured, otherwise false + */ + static findLastConfiguration = (pattern: Pattern, typeName: TypeName, fieldName?: FieldName): boolean => { + const key = fieldName ? `${typeName.value}.${fieldName.value}` : typeName.value; + return Pattern.split(pattern) + .map(pattern => { + const result = Pattern.attemptMatchAndConfigure(pattern, typeName, fieldName); + return result?.[key]?.shouldBeConfigured; + }) + .filter(value => value !== undefined) + .reduce((_acc, value) => value, false); + }; +} + +/** + * @name TypeNamePattern + * + * @description A compact string of patterns used in the config for granular configuration of Graphql Types. + * + * The string can contain one or more patterns, each pattern ends with a semi-colon (`;`). + * + * To apply an option to all Graphql Types or all fields, use the allTypeNames (`@*TypeNames`) tokens. + * + * Wherever you use the allTypeNames token, know very well that you can make some exceptions. After all, to every rule, there is an exception. + * + * A **square bracket** (`[]`) is used to specify what should be included and a **negated square bracket** (`-[]`) is used to specify what should be excluded. + * + * Manually typing out a pattern may be prone to typos resulting in invalid patterns therefore the [`TypeNamePattern`]() class exposes some builder methods to be used in the plugin config file. + * + * ## Available Builder Methods and the patterns they make + * ```ts + * const Droid = TypeName.fromString('Droid'); + * const Starship = TypeName.fromString('Starship'); + * const Human = TypeName.fromString('Human'); + * const Movie = TypeName.fromString('Movie'); + + * + * // Configuring specific Graphql Types + * const pattern = TypeNamePattern.forTypeNames([Droid, Starship]); + * console.log(pattern); // "Droid;Starship;" + * + * // Configuring all Graphql Types + * const pattern = TypeNamePattern.forAllTypeNames(); + * console.log(pattern); // "@*TypeNames;" + + * // Configuring all Graphql Types except those specified in the exclusion list of TypeNames + * const pattern = TypeNamePattern.forAllTypeNamesExcludeTypeNames([Droid, Starship]); + * console.log(pattern); // "@*TypeNames-[Droid,Starship];" + * + */ + +export class TypeNamePattern extends Pattern { + private constructor(value: string) { + super(value); + } + + //#region `'TypeName;AnotherTypeName;'` + static forTypeNames = (typeNames: TypeNames): TypeNamePattern => { + typeNames = arrayWrap(typeNames); + + if (typeNames.length < 1) { + throw new Error('Pattern cannot be created... No TypeNames were specified'); + } + + return TypeNamePattern.fromString( + arrayWrap(typeNames) + .map(typeName => `${typeName.value};`) + .join('') + ); + }; + + static regexpForTypeNames = /\b(?!TypeNames|FieldNames\b)(?\w+;)/gim; // TODO: fix this: regexp.test('@*TypeName;') returns true which shouldn't happen + + static matchAndConfigureTypeNames = (pattern: TypeNamePattern, typeName: TypeName): MatchAndConfigure => { + const regexp = TypeNamePattern.regexpForTypeNames; + resetIndex(regexp); + + let result: RegExpExecArray | null; + + const matchAndConfigure: MatchAndConfigure = {}; + + while ((result = regexp.exec(pattern.value)) !== null) { + const _typeName = result.groups.typeName.replace(';', ''); + + const key = _typeName; + const matchFound = _typeName === typeName.value; + const shouldBeConfigured = matchFound; + + matchAndConfigure[key] = { matchFound, shouldBeConfigured }; + } + + resetIndex(regexp); + return matchAndConfigure; + }; + //#endregion + + //#region `'@*TypeNames;'` + static forAllTypeNames = (): TypeNamePattern => Pattern.fromString(TypeName.allTypeNames); + + static regexpForAllTypeNames = /(?@\*TypeNames;)/gim; + + static matchAndConfigureAllTypeNames = (pattern: TypeNamePattern, typeName: TypeName): MatchAndConfigure => { + const regexp = TypeNamePattern.regexpForAllTypeNames; + resetIndex(regexp); + + const matchAndConfigure: MatchAndConfigure = {}; + + const key = typeName.value; + const matchFound = regexp.test(pattern.value); + const shouldBeConfigured = matchFound; + + matchAndConfigure[key] = { matchFound, shouldBeConfigured }; + + resetIndex(regexp); + return matchAndConfigure; + }; + //#endregion + + //#region `'@*TypeNames-[excludeTypeNames];'` + static forAllTypeNamesExcludeTypeNames = (typeNames: TypeNames): TypeNamePattern => { + typeNames = arrayWrap(typeNames); + + if (typeNames.length < 1) { + throw new Error('Pattern cannot be created... No TypeNames were excluded'); + } + + const _typeNames = typeNames.map(typeName => typeName.value).join(); + + return Pattern.fromString(`${TypeName.allTypeNames}-[${_typeNames}];`); + }; + + static regexpForAllTypeNamesExcludeTypeNames = /@\*TypeNames-\[\s*(?(\w+,?\s*)*)\];/gim; + + static matchAndConfigureAllTypeNamesExcludeTypeNames = ( + pattern: TypeNamePattern, + typeName: TypeName + ): MatchAndConfigure => { + const regexp = TypeNamePattern.regexpForAllTypeNamesExcludeTypeNames; + resetIndex(regexp); + + let result: RegExpExecArray | null; + + const matchAndConfigure: MatchAndConfigure = {}; + + // typeNames that were excluded... + while ((result = regexp.exec(pattern.value)) !== null) { + const _typeNames = strToList(result.groups.typeNames); + + _typeNames.forEach(_typeName => { + const key = _typeName; + const matchFound = true; + const shouldBeConfigured = false; + + matchAndConfigure[key] = { matchFound, shouldBeConfigured }; + }); + } + + // interpret global pattern: if typeName is not excluded ... + const key = typeName.value; + if (matchAndConfigure[key] === undefined) { + matchAndConfigure[key] = { matchFound: false, shouldBeConfigured: true }; + } + + resetIndex(regexp); + return matchAndConfigure; + }; + //#endregion +} + +/** + * @name FieldNamePattern + * + * @description A compact string of patterns used in the config for granular configuration the fields of a Graphql Type + * + * The string can contain one or more patterns, each pattern ends with a semi-colon (`;`). + * + * A dot (`.`) is used to separate the TypeName from the FieldNames in each pattern. + * + * To apply an option to all Graphql Types or all fields, use the allTypeNames (`@*TypeNames`) and allFieldNames (`@*FieldNames`) tokens respectively. + * + * Wherever you use the allTypeNames and the allFieldNames, know very well that you can make some exceptions. After all, to every rule, there is an exception. + * + * A **square bracket** (`[]`) is used to specify what should be included and a **negated square bracket** (`-[]`) is used to specify what should be excluded. + * + * Manually typing out a pattern may be prone to typos resulting in invalid patterns therefore the [`FieldName`]() class exposes some builder methods to be used in your plugin config file. + * + * ## Available Builder Methods and the patterns they make + * ```ts + * const Droid = TypeName.fromString('Droid'); + * const Starship = TypeName.fromString('Starship'); + * const Human = TypeName.fromString('Human'); + * const Movie = TypeName.fromString('Movie'); + * + * const id = FieldName.fromString('id'); + * const name = FieldName.fromString('name'); + * const friends = FieldName.fromString('friends'); + * const friend = FieldName.fromString('friend'); + * const title = FieldName.fromString('title'); + * const episode = FieldName.fromString('episode'); + * const length = FieldName.fromString('length'); + * + * // Configuring specific fields of a specific Graphql Type + * const pattern = FieldNamePattern.forFieldNamesOfTypeName([ + * [Droid, [id, name, friends]], // individual + * [Human, [id, name, title]], // individual + * [Starship, [name, length]], // individual + * ]); + * console.log(pattern); // "Droid.[id,name,friends];Human.[id,name,title];Starship.[name,length];" + * + * // Configuring all fields of a specific Graphql Type + * const pattern = FieldNamePattern.forAllFieldNamesOfTypeName([Droid, Movie]); + * console.log(pattern); // "Droid.@*FieldNames;Movie.@*FieldNames;" + * + * // Configuring all fields except those specified in the exclusion list of FieldNames for a specific GraphQL Type + * const pattern = FieldNamePattern.forAllFieldNamesExcludeFieldNamesOfTypeName([ + * [Droid, [id, name, friends]], // individual + * [Human, [id, name, title]], // individual + * [Starship, [name, length]], // individual + * ]); + * console.log(pattern); // "Droid.@*FieldNames-[id,name,friends];Human.@*FieldNames-[id,name,title];Starship.@*FieldNames-[name,length];" + * + * // Configuring specific fields of all Graphql Types + * const pattern = FieldNamePattern.forFieldNamesOfAllTypeNames([id, name, friends]); + * console.log(pattern); // "@*TypeNames.[id,name,friends];" + * + * // Configuring all fields of all Graphql Types + * const pattern = FieldNamePattern.forAllFieldNamesOfAllTypeNames(); + * console.log(pattern); // "@*TypeNames.@*FieldNames;" + * + * // Configuring all fields except those specified in the exclusion list of FieldNames for all GraphQL Types + * const pattern = FieldNamePattern.forAllFieldNamesExcludeFieldNamesOfAllTypeNames([id, name, friends]); + * console.log(pattern); // "@*TypeNames.@*FieldNames-[id,name,friends];" + * + * // Configuring specific fields of all GraphQL Types except those specified in the exclusion list of TypeNames + * const pattern = FieldNamePattern.forFieldNamesOfAllTypeNamesExcludeTypeNames([Droid, Human], [id, name, friends]); + * console.log(pattern); // "@*TypeNames-[Droid,Human].[id,name,friends];" + * + * // Configuring all fields of all GraphQL Types except those specified in the exclusion list of TypeNames + * const pattern = FieldNamePattern.forAllFieldNamesOfAllTypeNamesExcludeTypeNames([Droid, Human]); + * console.log(pattern); // "@*TypeNames-[Droid,Human].@*FieldNames;" + * + * // Configuring all fields except those specified in the exclusion list of FieldNames of all GraphQL Types except those specified in the exclusion list of TypeNames + * const pattern = FieldNamePattern.forAllFieldNamesExcludeFieldNamesOfAllTypeNamesExcludeTypeNames( + * [Droid, Human], + * [id, name, friends] + * ); + * console.log(pattern); // "@*TypeNames-[Droid,Human].@*FieldNames-[id,name,friends];" + * ``` + * + */ + +export class FieldNamePattern extends Pattern { + private constructor(value: string) { + super(value); + } + + //#region `'TypeName.[fieldNames];'` + static forFieldNamesOfTypeName = (data: [typeNames: TypeNames, fieldNames: FieldNames][]): FieldNamePattern => { + const expandedPattern: Record = {}; + + if (data.length < 1) { + throw new Error('Pattern cannot be created... an empty array was passed as parameter'); + } + + data.forEach(([typeNames, fieldNames]) => { + const _typeNames = arrayWrap(typeNames); + const _fieldNames = arrayWrap(fieldNames); + + if (_typeNames.length < 1) { + throw new Error('Pattern cannot be created... No TypeNames were specified'); + } else if (_fieldNames.length < 1) { + throw new Error('Pattern cannot be created... No FieldNames were specified'); + } + + _typeNames.forEach(typeName => { + expandedPattern[typeName.value] = [...(expandedPattern[typeName.value] ?? []), ..._fieldNames]; + }); + }); + + return FieldNamePattern.fromString( + Object.keys(expandedPattern) + .map(_typeName => { + const _fieldNames = expandedPattern[_typeName].map(fieldName => fieldName.value).join(); + return `${_typeName}.[${_fieldNames}];`; + }) + .join('') + ); + }; + + static regexpForFieldNamesOfTypeName = + /(?\w+\s*)(?(\w+,?\s*)*)\];/gim; + + static matchAndConfigureFieldNamesOfTypeName = ( + pattern: FieldNamePattern, + typeName: TypeName, + fieldName: FieldName + ): MatchAndConfigure => { + const regexp = FieldNamePattern.regexpForFieldNamesOfTypeName; + resetIndex(regexp); + + let result: RegExpExecArray | null; + const matchAndConfigure: MatchAndConfigure = {}; + + // typeName and fieldNames that were specified in the pattern + while ((result = regexp.exec(pattern.value)) !== null) { + const _typeName = result.groups.typeName; + const _fieldNames = strToList(result.groups.fieldNames); + + _fieldNames.forEach(_fieldName => { + const key = `${_typeName}.${_fieldName}`; + const matchFound = true; + const shouldBeConfigured = true; + + matchAndConfigure[key] = { matchFound, shouldBeConfigured }; + }); + } + + resetIndex(regexp); + return matchAndConfigure; + }; + //#endregion + + //#region `'TypeName.@*FieldNames;'` + static forAllFieldNamesOfTypeName = (typeNames: TypeNames): FieldNamePattern => { + const _typeNames = arrayWrap(typeNames); + + if (_typeNames.length < 1) { + throw new Error('Pattern cannot be created... No TypeNames were specified'); + } + + return FieldNamePattern.fromString( + _typeNames.map(_typeName => `${_typeName.value}.${FieldName.allFieldNames};`).join('') + ); + }; + + static regexpForAllFieldNamesOfTypeName = /(?\w+\s*)(? { + const regexp = FieldNamePattern.regexpForAllFieldNamesOfTypeName; + resetIndex(regexp); + + let result: RegExpExecArray | null; + const matchAndConfigure: MatchAndConfigure = {}; + + while ((result = regexp.exec(pattern.value)) !== null) { + const _typeName = result.groups.typeName; + + const key = `${_typeName}.${fieldName.value}`; + const matchFound = true; + const shouldBeConfigured = true; + + matchAndConfigure[key] = { matchFound, shouldBeConfigured }; + } + + resetIndex(regexp); + return matchAndConfigure; + }; + //#endregion + + //#region `'TypeName.@*FieldNames-[excludeFieldNames];'` + static forAllFieldNamesExcludeFieldNamesOfTypeName = ( + data: [typeNames: TypeNames, fieldNames: FieldNames][] + ): FieldNamePattern => { + const expandedPattern: Record = {}; + + if (data.length < 1) { + throw new Error('Pattern cannot be created... an empty array was passed as parameter'); + } + + data.forEach(([typeNames, fieldNames]) => { + const _typeNames = arrayWrap(typeNames); + const _fieldNames = arrayWrap(fieldNames); + + if (_typeNames.length < 1) { + throw new Error('Pattern cannot be created... No TypeNames were specified'); + } else if (_fieldNames.length < 1) { + throw new Error('Pattern cannot be created... No FieldNames were specified'); + } + + _typeNames.forEach(typeName => { + expandedPattern[typeName.value] = [...(expandedPattern[typeName.value] ?? []), ..._fieldNames]; + }); + }); + + return FieldNamePattern.fromString( + Object.keys(expandedPattern) + .map(_typeName => { + const _fieldNames = expandedPattern[_typeName].map(fieldName => fieldName.value).join(); + return `${_typeName}.${FieldName.allFieldNames}-[${_fieldNames}];`; + }) + .join('') + ); + }; + + static regexpForAllFieldNamesExcludeFieldNamesOfTypeName = + /(?\w+\s*)(?(\w+,?\s*)*)\];/gim; + + static matchAndConfigureAllFieldNamesExcludeFieldNamesOfTypeName = ( + pattern: FieldNamePattern, + typeName: TypeName, + fieldName: FieldName + ): MatchAndConfigure => { + const regexp = FieldNamePattern.regexpForAllFieldNamesExcludeFieldNamesOfTypeName; + resetIndex(regexp); + + let result: RegExpExecArray | null; + const matchAndConfigure: MatchAndConfigure = {}; + + // typeName.fieldName that was excluded... + while ((result = regexp.exec(pattern.value)) !== null) { + const _typeName = result.groups.typeName; + const _fieldNames = strToList(result.groups.fieldNames); + + _fieldNames.forEach(_fieldName => { + const key = `${_typeName}.${_fieldName}`; + const matchFound = true; + const shouldBeConfigured = false; + + matchAndConfigure[key] = { matchFound, shouldBeConfigured }; + }); + + // interpret global pattern: typeName.fieldName that was included + const key = `${_typeName}.${fieldName.value}`; + if (matchAndConfigure[key] === undefined) { + matchAndConfigure[key] = { matchFound: false, shouldBeConfigured: true }; + } + } + + resetIndex(regexp); + return matchAndConfigure; + }; + //#endregion + + //#region `'@*TypeNames.[fieldNames];'` + static forFieldNamesOfAllTypeNames = (fieldNames: FieldNames): FieldNamePattern => { + fieldNames = arrayWrap(fieldNames); + + if (fieldNames.length < 1) { + throw new Error('Pattern cannot be created... No FieldNames were specified'); + } + + const _fieldNames = fieldNames.map(fieldName => fieldName.value).join(); + + return FieldNamePattern.fromString(`${TypeName.allTypeNames}.[${_fieldNames}];`); + }; + + static regexpForFieldNamesOfAllTypeNames = /@\*TypeNames\.\[\s*(?(\w+,?\s*)*)\];/gim; + + static matchAndConfigureFieldNamesOfAllTypeNames = ( + pattern: FieldNamePattern, + typeName: TypeName, + fieldName: FieldName + ): MatchAndConfigure => { + const regexp = FieldNamePattern.regexpForFieldNamesOfAllTypeNames; + resetIndex(regexp); + + let result: RegExpExecArray | null; + + const matchAndConfigure: MatchAndConfigure = {}; + + while ((result = regexp.exec(pattern.value)) !== null) { + const _fieldNames = strToList(result.groups.fieldNames); + + _fieldNames.forEach(_fieldName => { + const key = `${typeName.value}.${_fieldName}`; + const matchFound = true; + const shouldBeConfigured = true; + matchAndConfigure[key] = { matchFound, shouldBeConfigured }; + }); + } + + resetIndex(regexp); + return matchAndConfigure; + }; + //#endregion + + //#region `'@*TypeNames.@*FieldNames;'` + static forAllFieldNamesOfAllTypeNames = (): FieldNamePattern => { + return FieldNamePattern.fromString(`${TypeName.allTypeNames}.${FieldName.allFieldNames};`); + }; + + static regexpForAllFieldNamesOfAllTypeNames = /@\*TypeNames\.@\*FieldNames;/gim; + + static matchAndConfigureAllFieldNamesOfAllTypeNames = ( + pattern: FieldNamePattern, + typeName: TypeName, + fieldName: FieldName + ): MatchAndConfigure => { + const regexp = FieldNamePattern.regexpForAllFieldNamesOfAllTypeNames; + resetIndex(regexp); + + const matchAndConfigure: MatchAndConfigure = {}; + + const key = `${typeName.value}.${fieldName.value}`; + const matchFound = regexp.test(pattern.value); + const shouldBeConfigured = matchFound; + + matchAndConfigure[key] = { matchFound, shouldBeConfigured }; + + resetIndex(regexp); + return matchAndConfigure; + }; + //#endregion + + //#region `'@*TypeNames.@*FieldNames-[excludeFieldNames];'` + static forAllFieldNamesExcludeFieldNamesOfAllTypeNames = (fieldNames: FieldNames): FieldNamePattern => { + fieldNames = arrayWrap(fieldNames); + + if (fieldNames.length < 1) { + throw new Error('Pattern cannot be created... No FieldNames were excluded'); + } + + const _fieldNames = fieldNames.map(fieldName => fieldName.value).join(); + + return FieldNamePattern.fromString(`${TypeName.allTypeNames}.${FieldName.allFieldNames}-[${_fieldNames}];`); + }; + + static regexpForAllFieldNamesExcludeFieldNamesOfAllTypeNames = + /@\*TypeNames\.@\*FieldNames-\[\s*(?(\w+,?\s*)*)\];/gim; + + static matchAndConfigureAllFieldNamesExcludeFieldNamesOfAllTypeNames = ( + pattern: FieldNamePattern, + typeName: TypeName, + fieldName: FieldName + ): MatchAndConfigure => { + const regexp = FieldNamePattern.regexpForAllFieldNamesExcludeFieldNamesOfAllTypeNames; + resetIndex(regexp); + + let result: RegExpExecArray | null; + const matchAndConfigure: MatchAndConfigure = {}; + + while ((result = regexp.exec(pattern.value)) !== null) { + const _fieldNames = strToList(result.groups.fieldNames); + + // typeName.fieldName that was excluded... + _fieldNames.forEach(_fieldName => { + const key = `${typeName.value}.${_fieldName}`; + const matchFound = true; + const shouldBeConfigured = false; + + matchAndConfigure[key] = { matchFound, shouldBeConfigured }; + }); + } + + // interpret global pattern: typeName.fieldName that was included... + const key = `${typeName.value}.${fieldName.value}`; + if (matchAndConfigure[key] === undefined) { + matchAndConfigure[key] = { matchFound: false, shouldBeConfigured: true }; + } + + resetIndex(regexp); + return matchAndConfigure; + }; + //#endregion + + //#region `'@*TypeNames-[excludeTypeNames].[fieldNames];'` + static forFieldNamesOfAllTypeNamesExcludeTypeNames = ( + typeNames: TypeNames, + fieldNames: FieldNames + ): FieldNamePattern => { + typeNames = arrayWrap(typeNames); + fieldNames = arrayWrap(fieldNames); + + if (typeNames.length < 1) { + throw new Error('Pattern cannot be created... No TypeNames were excluded'); + } else if (fieldNames.length < 1) { + throw new Error('Pattern cannot be created... No FieldNames were specified'); + } + + const _typeNames = typeNames.map(typeName => typeName.value).join(); + const _fieldNames = fieldNames.map(fieldName => fieldName.value).join(); + + return FieldNamePattern.fromString(`${TypeName.allTypeNames}-[${_typeNames}].[${_fieldNames}];`); + }; + + static regexpForFieldNamesOfAllTypeNamesExcludeTypeNames = + /@\*TypeNames-\[\s*(?(\w+,?\s*)*)\]\.\[\s*(?(\w+,?\s*)*)\];/gim; + + static matchAndConfigureFieldNamesOfAllTypeNamesExcludeTypeNames = ( + pattern: FieldNamePattern, + typeName: TypeName, + fieldName: FieldName + ): MatchAndConfigure => { + const regexp = FieldNamePattern.regexpForFieldNamesOfAllTypeNamesExcludeTypeNames; + resetIndex(regexp); + + let result: RegExpExecArray | null; + const matchAndConfigure: MatchAndConfigure = {}; + + while ((result = regexp.exec(pattern.value)) !== null) { + const _typeNames = strToList(result.groups.typeNames); + const _fieldNames = strToList(result.groups.fieldNames); + + // typeName.fieldName that was excluded + _typeNames.forEach(_typeName => + _fieldNames.forEach(_fieldName => { + const key = `${_typeName}.${_fieldName}`; + const matchFound = true; + const shouldBeConfigured = false; + + matchAndConfigure[key] = { matchFound, shouldBeConfigured }; + }) + ); + + // interpret the global pattern: + // for fieldNames specified in the list of fieldNames for other typeNames not specified in the exclusion list of TypeNames + _fieldNames.forEach(_fieldName => { + const key = `${typeName.value}.${_fieldName}`; + if (!_typeNames.includes(typeName.value) && matchAndConfigure[key] === undefined) { + const matchFound = false; + const shouldBeConfigured = true; + + matchAndConfigure[key] = { matchFound, shouldBeConfigured }; + } + }); + } + + resetIndex(regexp); + return matchAndConfigure; + }; + //#endregion + + //#region `'@*TypeNames-[excludeTypeNames].@*FieldNames;'` + static forAllFieldNamesOfAllTypeNamesExcludeTypeNames = (typeNames: TypeNames): FieldNamePattern => { + typeNames = arrayWrap(typeNames); + + if (typeNames.length < 1) { + throw new Error('Pattern cannot be created... No TypeNames were excluded'); + } + + const _typeNames = typeNames.map(typeName => typeName.value).join(); + + return FieldNamePattern.fromString(`${TypeName.allTypeNames}-[${_typeNames}].${FieldName.allFieldNames};`); + }; + + static regexpForAllFieldNamesOfAllTypeNamesExcludeTypeNames = + /@\*TypeNames-\[\s*(?(\w+,?\s*)*)\]\.@\*FieldNames;/gim; + + static matchAndConfigureAllFieldNamesOfAllTypeNamesExcludeTypeNames = ( + pattern: FieldNamePattern, + typeName: TypeName, + fieldName: FieldName + ): MatchAndConfigure => { + const regexp = FieldNamePattern.regexpForAllFieldNamesOfAllTypeNamesExcludeTypeNames; + resetIndex(regexp); + + let result: RegExpExecArray | null; + const matchAndConfigure: MatchAndConfigure = {}; + + while ((result = regexp.exec(pattern.value)) !== null) { + const _typeNames = strToList(result.groups.typeNames); + + _typeNames.forEach(_typeName => { + const key = `${_typeName}.${fieldName.value}`; + const matchFound = true; + const shouldBeConfigured = false; + + matchAndConfigure[key] = { matchFound, shouldBeConfigured }; + }); + + // interpret the global pattern: + // for all other typeName.fieldName combination where the typeName is not in the exclusion list of TypeNames + const key = `${typeName.value}.${fieldName.value}`; + if (matchAndConfigure[key] === undefined) { + matchAndConfigure[key] = { matchFound: false, shouldBeConfigured: true }; + } + } + + resetIndex(regexp); + return matchAndConfigure; + }; + //#endregion + + //#region `'@*TypeNames-[excludeTypeNames].@*FieldNames-[excludeFieldNames];'` + static forAllFieldNamesExcludeFieldNamesOfAllTypeNamesExcludeTypeNames = ( + typeNames: TypeNames, + fieldNames: FieldNames + ): FieldNamePattern => { + typeNames = arrayWrap(typeNames); + fieldNames = arrayWrap(fieldNames); + + if (typeNames.length < 1) { + throw new Error('Pattern cannot be created... No TypeNames were excluded'); + } else if (fieldNames.length < 1) { + throw new Error('Pattern cannot be created... No FieldNames were excluded'); + } + + const _typeNames = typeNames.map(typeName => typeName.value).join(); + const _fieldNames = fieldNames.map(fieldName => fieldName.value).join(); + + return FieldNamePattern.fromString( + `${TypeName.allTypeNames}-[${_typeNames}].${FieldName.allFieldNames}-[${_fieldNames}];` + ); + }; + + static regexpForAllFieldNamesExcludeFieldNamesOfAllTypeNamesExcludeTypeNames = + /@\*TypeNames-\[\s*(?(\w+,?\s*)*)\]\.@\*FieldNames-\[\s*(?(\w+,?\s*)*)\];/gim; + + static matchAndConfigureAllFieldNamesExcludeFieldNamesOfAllTypeNamesExcludeTypeNames = ( + pattern: FieldNamePattern, + typeName: TypeName, + fieldName: FieldName + ): MatchAndConfigure => { + const regexp = FieldNamePattern.regexpForAllFieldNamesExcludeFieldNamesOfAllTypeNamesExcludeTypeNames; + resetIndex(regexp); + + let result: RegExpExecArray | null; + const matchAndConfigure: MatchAndConfigure = {}; + + while ((result = regexp.exec(pattern.value)) !== null) { + const _typeNames = strToList(result.groups.typeNames); + const _fieldNames = strToList(result.groups.fieldNames); + + _typeNames.forEach(_typeName => + _fieldNames.forEach(_fieldName => { + const key = `${_typeName}.${_fieldName}`; + const matchFound = true; + const shouldBeConfigured = false; + + matchAndConfigure[key] = { matchFound, shouldBeConfigured }; + }) + ); + } + + // interpret the global pattern + // for any other typeName.fieldName combination which is not excluded in the pattern + const key = `${typeName.value}.${fieldName.value}`; + if (matchAndConfigure[key] === undefined) { + matchAndConfigure[key] = { matchFound: false, shouldBeConfigured: true }; + } + + resetIndex(regexp); + return matchAndConfigure; + }; + //#endregion +} + +export type MatchAndConfigure = Record; + +// type MatchAndConfigure = +// | 'matchAndConfigureTypeNames' +// | 'matchAndConfigureAllTypeNames' +// | 'matchAndConfigureAllTypeNamesExcludeTypeNames' +// | 'matchAndConfigureFieldNamesOfTypeName' +// | 'matchAndConfigureAllFieldNamesOfTypeName' +// | 'matchAndConfigureAllFieldNamesExcludeFieldNamesOfTypeName' +// | 'matchAndConfigureFieldNamesOfAllTypeNames' +// | 'matchAndConfigureAllFieldNamesOfAllTypeNames' +// | 'matchAndConfigureAllFieldNamesExcludeFieldNamesOfAllTypeNames' +// | 'matchAndConfigureFieldNamesOfAllTypeNamesExcludeTypeNames' +// | 'matchAndConfigureAllFieldNamesOfAllTypeNamesExcludeTypeNames' +// | 'matchAndConfigureAllFieldNamesExcludeFieldNamesOfAllTypeNamesExcludeTypeNames'; diff --git a/packages/plugins/dart/flutter-freezed/src/config/plugin-config.ts b/packages/plugins/dart/flutter-freezed/src/config/plugin-config.ts new file mode 100644 index 000000000..ac24a4d57 --- /dev/null +++ b/packages/plugins/dart/flutter-freezed/src/config/plugin-config.ts @@ -0,0 +1,802 @@ +import { + ObjectTypeDefinitionNode, + InputObjectTypeDefinitionNode, + UnionTypeDefinitionNode, + EnumTypeDefinitionNode, + FieldDefinitionNode, + InputValueDefinitionNode, +} from 'graphql'; +import { TypeNamePattern, TypeName, FieldNamePattern, Pattern } from './pattern.js'; + +//#region PluginConfig +/** + * @name FlutterFreezedPluginConfig + * @description configure the `flutter-freezed` plugin + */ +export type FlutterFreezedPluginConfig = { + /** + * @name camelCasedEnums + * @type {(boolean | DartIdentifierCasing)} + * @default true + * @summary Specify how Enum values should be cased. + * @description Setting this option to `true` will camelCase enum values as required by Dart's recommended linter. + * + * If set to false, the original casing as specified in the Graphql Schema is used + * + * You can also transform the casing by specifying your preferred casing for Enum values. + * + * Available options are: `'snake_case'`, `'camelCase'` and `'PascalCase'` + * + * For consistency, this option applies the same configuration to all Enum Types in the GraphQL Schema + * @exampleMarkdown + * ## Usage: + * ```ts filename='codegen.ts' + * import type { CodegenConfig } from '@graphql-codegen/cli'; + * + * const config: CodegenConfig = { + * // ... + * generates: { + * 'lib/data/models/app_models.dart': { + * plugins: { + * 'flutter-freezed': { + * // ... + * camelCasedEnums: true, // or false + * // OR: specify a DartIdentifierCasing + * camelCasedEnums: 'snake_case', + * }, + * }, + * }, + * }, + * }; + * export default config; + * ``` + */ + camelCasedEnums?: boolean | DartIdentifierCasing; + + /** + * @name copyWith + * @type {(boolean | TypeNamePattern)} + * @default undefined + * @see {@link https://pub.dev/packages/freezed#how-copywith-works How copyWith works} + * @see {@link https://pub.dev/documentation/freezed_annotation/latest/freezed_annotation/Freezed/copyWith.html Freezed annotation copyWith property} + * @summary enables Freezed copyWith helper method + * @description The [`freezed`](https://pub.dev/packages/freezed) library has this option enabled by default. + * Use this option to enable/disable this option completely. + * + * The plugin by default generates immutable Freezed models using the `@freezed` decorator. + * + * If this option is configured, the plugin will generate immutable Freezed models using the `@Freezed(copyWith: value)` instead. + * + * Setting a boolean value will enable/disable this option globally for every GraphQL Type + * but you can also set this option to `true` for one or more GraphQL Types using a `TypeNamePattern`. + * @exampleMarkdown + * ## Usage: + * ```ts filename='codegen.ts' + * import type { CodegenConfig } from '@graphql-codegen/cli'; + * + * const config: CodegenConfig = { + * // ... + * generates: { + * 'lib/data/models/app_models.dart': { + * plugins: { + * 'flutter-freezed': { + * // ... + * copyWith: true, + * // OR: enable it for only Droid and Starship GraphQL types + * copyWith: TypeNamePattern.forTypeNames([Droid, Starship]), + * }, + * }, + * }, + * }, + * }; + * export default config; + * ``` + */ + copyWith?: boolean | TypeNamePattern; + + /** + * @name customScalars + * @type {(Record)} + * @default {} + * @summary Maps GraphQL Scalar Types to Dart built-in types + * @description The `key` is the GraphQL Scalar Type and the `value` is the equivalent Dart Type + * + * The plugin automatically handles built-in GraphQL Scalar Types so only specify the custom Scalars in your Graphql Schema. + * @exampleMarkdown + * ## Usage + * ```ts filename='codegen.ts' + * import type { CodegenConfig } from '@graphql-codegen/cli'; + * + * const config: CodegenConfig = { + * // ... + * generates: { + * 'lib/data/models/app_models.dart': { + * plugins: { + * 'flutter-freezed': { + * // ... + * customScalars: { + * jsonb: 'Map', + * timestamp: 'DateTime', + * UUID: 'String', + * }, + * }, + * }, + * }, + * }, + * }; + * export default config; + * ``` + */ + customScalars?: Record; + + /** + * @name defaultValues + * @type {([pattern: FieldNamePattern, value: string, appliesOn: AppliesOnParameters[], directiveName?: string, directiveArgName?: string][])} + * @default undefined + * @see {@link https://pub.dev/packages/freezed#default-values Default values} + * @see {@link https://pub.dev/documentation/freezed_annotation/latest/freezed_annotation/Default-class.html Default class} + * @summary set the default value for a field. + * @description This will annotate the generated parameter with a `@Default(value: defaultValue)` decorator. + * + * The default value will be interpolated into the `@Default(value: ${value})` decorator so + * Use backticks for the value element so that you can use quotation marks for string values. + * E.g: `"I'm a string default value"` but `Episode.jedi` is not a string value. + * + * Use the `appliesOn` to specify where this option should be applied on + * + * If the `directiveName` and `directiveArgName` are passed, the value of the argument of the given directive specified in the Graphql Schema will be used as the defaultValue + * @exampleMarkdown + * ## Usage: + * ```ts filename='codegen.ts' + * import type { CodegenConfig } from '@graphql-codegen/cli'; + * + * const config: CodegenConfig = { + * // ... + * generates: { + * 'lib/data/models/app_models.dart': { + * plugins: { + * 'flutter-freezed': { + * // ... + * defaultValues: [ + * [FieldNamePattern.forFieldNamesOfTypeName(MovieCharacter, appearsIn), `Episode.jedi`, ['default_factory_parameter']], + * ], + * }, + * }, + * }, + * }, + * }; + * export default config; + * ``` + */ + defaultValues?: [ + pattern: FieldNamePattern, + value: string, // use backticks for string values + appliesOn: AppliesOnParameters[] + ][]; + + /** + * @name deprecated + * @type {([pattern: Pattern, appliesOn: (AppliesOnFactory | AppliesOnParameters)[]][])} + * @default undefined + * @see {@link https://pub.dev/packages/freezed#decorators-and-comments Decorators and comments} + * @summary a list of Graphql Types(factory constructors) or fields(parameters) to be marked as deprecated. + * @description Using a TypeNamePattern, you can mark an entire factory constructor for one or more GraphQL types as deprecated. + * + * Likewise, using a FieldNamePattern, you can mark one or more fields as deprecated + * + * Since the first element in the tuple has a type signature of `Pattern`, + * you can use either TypeNamePattern or FieldNamePattern or use both + * by composing them with `Pattern.compose(...)` + * + * Use the `appliesOn` to specify which block this option should be applied on + * @exampleMarkdown + * ## Usage: + * + * ```ts filename='codegen.ts' + * import type { CodegenConfig } from '@graphql-codegen/cli'; + * + * const config: CodegenConfig = { + * // ... + * generates: { + * 'lib/data/models/app_models.dart': { + * plugins: { + * 'flutter-freezed': { + * // ... + * deprecated: [ + * // using FieldNamePattern + * [FieldNamePattern.forFieldNamesOfTypeName(MovieCharacter, [appearsIn, name]), ['default_factory_parameter']], + * // using TypeNamePattern + * [TypeNamePattern.forTypeNames([Starship,Droid,Human]), ['union_factory']], + * ], + * }, + * }, + * }, + * }, + * }; + * export default config; + * ``` + */ + deprecated?: [pattern: Pattern, appliesOn: (AppliesOnFactory | AppliesOnParameters)[]][]; + + /** + * @name equal + * @type {(boolean | TypeNamePattern)} + * @default undefined + * @see {@link url Freezed equal helper method usage} + * @see {@link https://pub.dev/documentation/freezed_annotation/latest/freezed_annotation/Freezed/equal.html Freezed annotation equal property} + * @summary enables Freezed equal helper method + * @description The [`freezed`](https://pub.dev/packages/freezed) library has this option enabled by default. + * Use this option to enable/disable this option completely. + * + * The plugin by default generates immutable Freezed models using the `@freezed` decorator. + * + * If this option is configured, the plugin will generate immutable Freezed models using the `@Freezed(equal: value)` instead. + * + * Setting a boolean value will enable/disable this option globally for every GraphQL Type + * but you can also set this option to `true` for one or more GraphQL Types using a `TypeNamePattern`. + * @exampleMarkdown + * ## Usage: + * ```ts filename='codegen.ts' + * import type { CodegenConfig } from '@graphql-codegen/cli'; + * + * const config: CodegenConfig = { + * // ... + * generates: { + * 'lib/data/models/app_models.dart': { + * plugins: { + * 'flutter-freezed': { + * // ... + * equal: true, + * // OR: enable it for only Droid and Starship GraphQL types + * equal: TypeNamePattern.forTypeNames([Droid, Starship]), + * }, + * }, + * }, + * }, + * }; + * export default config; + * ``` + */ + equal?: boolean | TypeNamePattern; + + /** + * @name escapeDartKeywords + * @default true + * @see_also [dartKeywordEscapePrefix,dartKeywordEscapeSuffix] + * @summary ensures that the generated Freezed models doesn't use any of Dart's reserved keywords as identifiers + * @description Wraps the fields names that are valid Dart keywords with the prefix and suffix given and allows you to specify your preferred casing: "snake_case" | "camelCase" | "PascalCase" + * + * + * @exampleMarkdown + * ## Usage: + * + * ```ts filename='codegen.ts' + * import type { CodegenConfig } from '@graphql-codegen/cli'; + * + * const config: CodegenConfig = { + * // ... + * generates: { + * 'lib/data/models/app_models.dart': { + * plugins: { + * 'flutter-freezed': { + * // ... + * // WARNING: Setting this option to `false` might generate output that contains Dart keywords as identifiers. Defaults to `true` + * escapeDartKeywords: false, + * // OR configure how Dart keywords are handled for each type + * escapeDartKeywords: [ + * [ + * 'Episode.@*FieldNames', + * // `prefix`: defaults to an empty string `''` if undefined. + * // Note that using a underscore `_` as a prefix will make the field as private + * undefined, + * // `suffix`: defaults to an underscore `_` if undefined + * undefined, + * // `casing`: maintains the original casing if undefined. + * // Available options: `snake_cased`, `camelCase` or `PascalCase` + * undefined, + * // `appliesOn`: defaults to an ['enum', 'enum_value', 'class', 'factory', 'parameter'] if undefined. + * undefined, + * ], + * ], + * }, + * }, + * }, + * }, + * }; + * export default config; + * ``` + */ + escapeDartKeywords?: boolean | [pattern: Pattern, prefix?: string, suffix?: string, appliesOn?: AppliesOn[]][]; + + /** + * @name final + * @summary marks fields as final + * @description This will mark the specified parameters as final + * + * Requires a an array of tuples with the type signature below: + * + * ` [typeFieldName: TypeFieldName, appliesOn: AppliesOnParameters[]]` + * @default undefined + * @exampleMarkdown + * ## Usage: + * ```ts filename='codegen.ts' + * import type { CodegenConfig } from '@graphql-codegen/cli'; + * + * const config: CodegenConfig = { + * // ... + * generates: { + * 'lib/data/models/app_models.dart': { + * plugins: { + * 'flutter-freezed': { + * // ... + * final: [ + * ['Human.[name]', ['parameter']], + * ['Starship.[id],Droid.[id],Human.[id]', ['default_factory_parameter']], + * ], + * }, + * }, + * }, + * }, + * }; + * export default config; + * ``` + */ + final?: [pattern: FieldNamePattern, appliesOn: AppliesOnParameters[]][]; + + /** + * @name ignoreTypes + * @type {(TypeNamePattern)} + * @default undefined + * @description names of GraphQL types to ignore when generating Freezed classes + * + * @exampleMarkdown + * ## Usage: + * ```ts filename='codegen.ts' + * import type { CodegenConfig } from '@graphql-codegen/cli'; + * + * const config: CodegenConfig = { + * // ... + * generates: { + * 'lib/data/models/app_models.dart': { + * plugins: { + * 'flutter-freezed': { + * // ... + * ignoreTypes: ['PaginatorInfo'], + * }, + * }, + * }, + * }, + * }; + * export default config; + * ``` + */ + ignoreTypes?: TypeNamePattern; + + /** + * @name immutable + * @type {(boolean | TypeNamePattern)} + * @default undefined + * @see {@link https://pub.dev/packages/freezed#creating-a-model-using-freezed Creating a Model using Freezed} + * @summary enables Freezed immutable helper method + * @description set to true to use the `@freezed` decorator or false to use the `@unfreezed` decorator + * @description The [`freezed`](https://pub.dev/packages/freezed) library by default generates immutable models decorated with the `@freezed` decorator. + * This option if set to `false` the plugin will generate mutable Freezed models using the `@unfreezed` decorator instead. + * + * Setting a boolean value will enable/disable this option globally for every GraphQL Type + * but you can also set this option to `true` for one or more GraphQL Types using a `TypeNamePattern`. + * @exampleMarkdown + * ## Usage: + * ```ts filename='codegen.ts' + * import type { CodegenConfig } from '@graphql-codegen/cli'; + * + * const config: CodegenConfig = { + * // ... + * generates: { + * 'lib/data/models/app_models.dart': { + * plugins: { + * 'flutter-freezed': { + * // ... + * immutable: true, + * // OR: enable it for all GraphQL types except Droid and Starship types + * immutable: TypeNamePattern.forTypeNamesExcludeTypeNames([Droid, Starship]), + * }, + * }, + * }, + * }, + * }; + * export default config; + * ``` + */ + immutable?: boolean | TypeNamePattern; + + /** + * @name makeCollectionsUnmodifiable + * @description allows collections(lists/maps) to be modified even if class is immutable + * @default undefined + * + * @exampleMarkdown + * ## Usage: + * ```ts filename='codegen.ts' + * import type { CodegenConfig } from '@graphql-codegen/cli'; + * + * const config: CodegenConfig = { + * // ... + * generates: { + * 'lib/data/models/app_models.dart': { + * plugins: { + * 'flutter-freezed': { + * // ... + * makeCollectionsUnmodifiable: true, + * // OR: a comma-separated string + * makeCollectionsUnmodifiable: 'Droid,Starship', + * // OR: a list of GRaphQL Type names + * makeCollectionsUnmodifiable: ['Droid', 'Starship'], + * }, + * }, + * }, + * }, + * }; + * export default config; + * ``` + */ + makeCollectionsUnmodifiable?: boolean | TypeNamePattern; + + /** + * @name mergeTypes + * @default undefined + * @description merges other GraphQL Types as a named factory constructor inside a class generated for the target GraphQL ObjectType. + * This option takes an array of strings that are expected to be valid typeNames of GraphQL Types to be merged with the GraphQL Type used as the key. + * The array is mapped and each string is converted into a TypeName so please ensure that the strings are valid GraphQL TypeNames + * A string that contains any invalid characters will throw an exception. + * @exampleMarkdown + * ```yaml + * generates: + * flutter_app/lib/data/models/app_models.dart + * plugins: + * - flutter-freezed + * config: + * mergeTypes: ["Create$Input", "Update$Input", "Delete$Input"] + * ``` + */ + mergeTypes?: Record; + + /** + * @name mutableInputs + * @description since inputs will be used to collect data, it makes sense to make them mutable with Freezed's `@unfreezed` decorator. This overrides(in order words: has a higher precedence than) the `immutable` config value `ONLY` for GraphQL `input types`. + * @default true + * + * @exampleMarkdown + * ## Usage: + * ```ts filename='codegen.ts' + * import type { CodegenConfig } from '@graphql-codegen/cli'; + * + * const config: CodegenConfig = { + * // ... + * generates: { + * 'lib/data/models/app_models.dart': { + * plugins: { + * 'flutter-freezed': { + * // ... + * mutableInputs: true, + * // OR: a comma-separated string + * mutableInputs: 'Droid,Starship', + * // OR: a list of GRaphQL Type names + * mutableInputs: ['Droid', 'Starship'], + * }, + * }, + * }, + * }, + * }; + * export default config; + * ``` + */ + mutableInputs?: boolean | TypeNamePattern; + + /** + * @name privateEmptyConstructor + * @description if true, defines a private empty constructor to allow getter and methods to work on the class + * @default true + * + * @exampleMarkdown + * ## Usage: + * ```ts filename='codegen.ts' + * import type { CodegenConfig } from '@graphql-codegen/cli'; + * + * const config: CodegenConfig = { + * // ... + * generates: { + * 'lib/data/models/app_models.dart': { + * plugins: { + * 'flutter-freezed': { + * // ... + * privateEmptyConstructor: true, + * // OR: a comma-separated string + * privateEmptyConstructor: 'Droid,Starship', + * // OR: a list of GRaphQL Type names + * privateEmptyConstructor: ['Droid', 'Starship'], + * }, + * }, + * }, + * }, + * }; + * export default config; + * ``` + */ + privateEmptyConstructor?: boolean | TypeNamePattern; + + /** + * @name unionClass + * @description customize the key to be used for fromJson with multiple constructors + * @see {@link https://pub.dev/packages/freezed#fromjson---classes-with-multiple-constructors fromJSON - classes with multiple constructors} + * @default undefined + * @exampleMarkdown + * ## Usage: + * ```ts filename='codegen.ts' + * import type { CodegenConfig } from '@graphql-codegen/cli'; + * + * const config: CodegenConfig = { + * // ... + * generates: { + * 'lib/data/models/app_models.dart': { + * plugins: { + * 'flutter-freezed': { + * // ... + * unionClass: [ + * [ + * 'SearchResult', // <-- unionTypeName + * 'namedConstructor', // <-- unionKey + * 'FreezedUnionCase.pascal', // <-- unionValueCase + * [ // <-- unionValuesNameMap + * [ Droid, 'special droid'], + * [ Human, 'astronaut'], + * [ Starship, 'space_Shuttle'], + * ], + * ], + * ], + * }, + * }, + * }, + * }, + * }; + * export default config; + * ``` + */ + unionClass?: [ + /** + * The name of the Graphql Union Type (or in the case of merged types, the base type on which other types are merged with) + */ + unionTypeName: TypeNamePattern, + + /** + * in a fromJSON/toJson encoding a response/object({key:value}), you can specify what name should be used as the key ? + */ + unionKey?: string, + + /** + * normally in camelCase but you can change that to PascalCase + */ + unionValueCase?: UnionValueCase, + + /** + * just as the unionKey changes the key used, this changes the value for each union/sealed factories + */ + unionValuesNameMap?: [typeName: TypeName, unionValueKey: string][] + ][]; +}; + +//#endregion + +//#region type alias + +/** + * @name ApplyDecoratorOn + * @description Values that are passed to the `DecoratorToFreezed.applyOn` field that specifies where the custom decorator should be applied + */ +export type AppliesOn = + | 'enum' // applies on the Enum itself + | 'enum_value' // applies to the value of an Enum + | 'class' // applies on the class itself + | 'factory' // applies on all class factory constructor + | 'default_factory' // applies on the main default factory constructor + | 'named_factory' // applies on all of the named factory constructors in a class + | 'union_factory' // applies on the named factory constructors for a specified(or all when the `*` is used as the key) GraphQL Object Type when it appears in a class as a named factory constructor and that class was generated for a GraphQL Union Type. E.g: `Droid` in `SearchResult` in the StarWars Schema + | 'merged_factory' // applies on the named factory constructors for a GraphQL Input Type when it appears in a class as a named factory constructor and that class was generated for a GraphQL Object Type and it Type is to be merged with the GraphQL Object Type. E.g: `CreateMovieInput` merged with `Movie` in the StarWars Schema + | 'parameter' // applies on all parameters for both default constructors and named factory constructors + | 'default_factory_parameter' // applies on parameters for ONLY default constructors for a specified(or all when the `*` is used as the key) field on a GraphQL Object/Input Type + | 'named_factory_parameter' // applies on parameters for all named factory constructors for a specified(or all when the `*` is used as the key) field on a GraphQL Object/Input Type + | 'union_factory_parameter' // like `named_factory_parameters` but ONLY for a parameter in a named factory constructor which for a GraphQL Union Type + | 'merged_factory_parameter'; // like `named_factory_parameters` but ONLY for a parameter in a named factory constructor which for a GraphQL Input Type that is merged inside an a class generated for a GraphQL Object Type + +export const APPLIES_ON_ENUM = ['enum']; +export type AppliesOnEnum = (typeof APPLIES_ON_ENUM)[number]; + +export const APPLIES_ON_ENUM_VALUE = ['enum_value']; +export type AppliesOnEnumValue = (typeof APPLIES_ON_ENUM_VALUE)[number]; + +export const APPLIES_ON_CLASS = ['class']; +export type AppliesOnClass = (typeof APPLIES_ON_CLASS)[number]; + +export const APPLIES_ON_DEFAULT_FACTORY = ['factory', 'default_factory']; +export type AppliesOnDefaultFactory = (typeof APPLIES_ON_DEFAULT_FACTORY)[number]; + +export const APPLIES_ON_UNION_FACTORY = ['factory', 'named_factory', 'union_factory']; +export type AppliesOnUnionFactory = (typeof APPLIES_ON_UNION_FACTORY)[number]; + +export const APPLIES_ON_MERGED_FACTORY = ['factory', 'named_factory', 'merged_factory']; +export type AppliesOnMergedFactory = (typeof APPLIES_ON_MERGED_FACTORY)[number]; + +export type AppliesOnNamedFactory = AppliesOnUnionFactory | AppliesOnMergedFactory; + +export const APPLIES_ON_FACTORY = ['factory', 'default_factory', 'named_factory', 'merged_factory', 'union_factory']; +export type AppliesOnFactory = AppliesOnDefaultFactory | AppliesOnNamedFactory; + +export const APPLIES_ON_DEFAULT_FACTORY_PARAMETERS = ['parameter', 'default_factory_parameter']; +export type AppliesOnDefaultFactoryParameters = (typeof APPLIES_ON_DEFAULT_FACTORY_PARAMETERS)[number]; + +export const APPLIES_ON_UNION_FACTORY_PARAMETERS = [ + 'parameter', + 'named_factory_parameter', + 'union_factory_parameter', +]; +export type AppliesOnUnionFactoryParameters = (typeof APPLIES_ON_UNION_FACTORY_PARAMETERS)[number]; + +export const APPLIES_ON_MERGED_FACTORY_PARAMETERS = [ + 'parameter', + 'named_factory_parameter', + 'merged_factory_parameter', +]; +export type AppliesOnMergedFactoryParameters = (typeof APPLIES_ON_MERGED_FACTORY_PARAMETERS)[number]; + +export type AppliesOnNamedParameters = AppliesOnUnionFactoryParameters | AppliesOnMergedFactoryParameters; + +export const APPLIES_ON_PARAMETERS = [ + 'parameter', + 'default_factory_parameter', + 'named_factory_parameter', + 'union_factory_parameter', + 'merged_factory_parameter', +]; +export type AppliesOnParameters = AppliesOnDefaultFactoryParameters | AppliesOnNamedParameters; + +export type DartIdentifierCasing = 'snake_case' | 'camelCase' | 'PascalCase'; + +export type NodeType = + | ObjectTypeDefinitionNode + | InputObjectTypeDefinitionNode + | UnionTypeDefinitionNode + | EnumTypeDefinitionNode; + +export type FieldType = FieldDefinitionNode | InputValueDefinitionNode; + +export type ObjectType = ObjectTypeDefinitionNode | InputObjectTypeDefinitionNode; + +export type ConfigOption = keyof FlutterFreezedPluginConfig; +export type FreezedOption = Extract< + ConfigOption, + 'copyWith' | 'equal' | 'immutable' | 'makeCollectionsUnmodifiable' | 'mutableInputs' | 'privateEmptyConstructor' +>; + +export type TypeFieldNameOption = Extract< + ConfigOption, + 'defaultValues' | 'deprecated' | 'escapeDartKeywords' | 'final' | 'fromJsonToJson' +>; + +export type MultiConstructorOption = FlutterFreezedPluginConfig['unionClass']; + +export type UnionValueCase = 'FreezedUnionCase.camel' | 'FreezedUnionCase.pascal'; + +/** + * maps GraphQL scalar types to Dart's scalar types + */ +export const DART_SCALARS: Record = { + ID: 'String', + String: 'String', + Boolean: 'bool', + Int: 'int', + Float: 'double', + DateTime: 'DateTime', +}; + +export const DART_KEYWORDS = { + abstract: 'built-in', + else: 'reserved', + import: 'built-in', + show: 'context', + as: 'built-in', + enum: 'reserved', + in: 'reserved', + static: 'built-in', + assert: 'reserved', + export: 'built-in', + interface: 'built-in', + super: 'reserved', + async: 'context', + extends: 'reserved', + is: 'reserved', + switch: 'reserved', + await: 'async-reserved', + extension: 'built-in', + late: 'built-in', + sync: 'context', + break: 'reserved', + external: 'built-in', + library: 'built-in', + this: 'reserved', + case: 'reserved', + factory: 'built-in', + mixin: 'built-in', + throw: 'reserved', + catch: 'reserved', + false: 'reserved', + new: 'reserved', + true: 'reserved', + class: 'reserved', + final: 'reserved', + null: 'reserved', + try: 'reserved', + const: 'reserved', + finally: 'reserved', + on: 'context', + typedef: 'built-in', + continue: 'reserved', + for: 'reserved', + operator: 'built-in', + var: 'reserved', + covariant: 'built-in', + Function: 'built-in', + part: 'built-in', + void: 'reserved', + default: 'reserved', + get: 'built-in', + required: 'built-in', + while: 'reserved', + deferred: 'built-in', + hide: 'context', + rethrow: 'reserved', + with: 'reserved', + do: 'reserved', + if: 'reserved', + return: 'reserved', + yield: 'async-reserved', + dynamic: 'built-in', + implements: 'built-in', + set: 'built-in', + // built-in types + int: 'reserved', + double: 'reserved', + String: 'reserved', + bool: 'reserved', + List: 'reserved', + Set: 'reserved', + Map: 'reserved', + Runes: 'reserved', + Symbol: 'reserved', + Object: 'reserved', + Null: 'reserved', + Never: 'reserved', + Enum: 'reserved', + Future: 'reserved', + Iterable: 'reserved', +}; + +/** initializes a FreezedPluginConfig with the defaults values */ +export const defaultFreezedPluginConfig: FlutterFreezedPluginConfig = { + camelCasedEnums: true, + copyWith: undefined, + customScalars: {}, + defaultValues: undefined, + deprecated: undefined, + equal: undefined, + escapeDartKeywords: true, + final: undefined, + // fromJsonToJson: true, // TODO: @next-version + ignoreTypes: undefined, + immutable: true, + makeCollectionsUnmodifiable: undefined, + mergeTypes: undefined, + mutableInputs: true, + privateEmptyConstructor: true, + unionClass: undefined, +}; + +//#endregion diff --git a/packages/plugins/dart/flutter-freezed/src/freezed-declaration-blocks/class-block.ts b/packages/plugins/dart/flutter-freezed/src/freezed-declaration-blocks/class-block.ts index cd8cff32f..4dfc7609b 100644 --- a/packages/plugins/dart/flutter-freezed/src/freezed-declaration-blocks/class-block.ts +++ b/packages/plugins/dart/flutter-freezed/src/freezed-declaration-blocks/class-block.ts @@ -1,303 +1,120 @@ import { indent } from '@graphql-codegen/visitor-plugin-common'; -import { EnumValueDefinitionNode, FieldDefinitionNode, InputValueDefinitionNode, Kind, NamedTypeNode } from 'graphql'; -import { camelCase, pascalCase } from 'change-case-all'; -import { FlutterFreezedPluginConfig } from '../config.js'; -import { - getCustomDecorators, - transformCustomDecorators, - FreezedConfigValue, - FreezedFactoryBlockRepository, - NodeType, -} from '../utils.js'; -import { FreezedFactoryBlock } from './factory-block.js'; +import { Kind } from 'graphql'; +import { Config } from '../config/config-value.js'; +import { APPLIES_ON_CLASS, FlutterFreezedPluginConfig, NodeType } from '../config/plugin-config.js'; +import { FactoryBlock } from './factory-block.js'; +import { TypeName } from '../config/pattern.js'; +import { Block } from './index.js'; +import { nodeIsObjectType, stringIsNotEmpty } from '../utils.js'; + +export class ClassBlock { + public static build(config: FlutterFreezedPluginConfig, node: NodeType): string { + const typeName = TypeName.fromString(node.name.value); + const _className = Block.buildBlockName( + config, + APPLIES_ON_CLASS, + typeName.value, + typeName, + undefined, + 'PascalCase' + ); -export class FreezedDeclarationBlock { - /** document the class */ - _comment = ''; - - /** a list of decorators to copy paste to the generator */ - _decorators: string[] = []; - - /** the name of the class */ - _name: string | undefined; - - /** a list of default constructor and named Constructors used create a Freezed union/sealed class */ - _factoryBlocks: FreezedFactoryBlock[] = []; - - /** the shape is the content of the block */ - _shape: string | undefined; - - /** the block is the final structure that is generated */ - _block: string | undefined; - - private _freezedConfigValue: FreezedConfigValue; - - constructor( - private _config: FlutterFreezedPluginConfig, - private _freezedFactoryBlockRepository: FreezedFactoryBlockRepository, - private _node: NodeType - ) { - this._config = _config; - this._freezedFactoryBlockRepository = _freezedFactoryBlockRepository; - this._node = _node; - this._freezedConfigValue = new FreezedConfigValue(this._config, this._node.name.value); - } - - public init(): FreezedDeclarationBlock { - if (this._node.kind === Kind.ENUM_TYPE_DEFINITION) { - this.setDecorators().setName().setShape().setBlock(); - return this; - } - this.setComment().setDecorators().setName().setFactoryBlocks().setShape().setBlock(); - return this; - } - - private setComment(): FreezedDeclarationBlock { - const comment = this._node.description?.value; - - if (comment && comment !== null && comment !== '') { - this._comment = `/// ${comment} \n`; - } - - return this; - } - - private getEnumComment(value: EnumValueDefinitionNode): string { - const comment = value.description?.value; + let block = ''; - if (comment && comment !== null && comment !== '') { - return `/// ${comment} \n`; - } + // the comments should be on the factory block instead + // block += Block.buildComment(node); - return ''; - } + block += this.buildDecorators(config, node); - private setDecorators(): FreezedDeclarationBlock { - const name = this._node.name.value; - // determine if should mark as deprecated - const isDeprecated = this._config.typeSpecificFreezedConfig?.[name]?.deprecated; + block += this.buildHeader(config, typeName, _className); - this._decorators = - this._node.kind === Kind.ENUM_TYPE_DEFINITION - ? [...transformCustomDecorators(getCustomDecorators(this._config, ['enum'], name), this._node)] - : [ - this.getFreezedDecorator(), - ...transformCustomDecorators(getCustomDecorators(this._config, ['class'], name), this._node), - ]; + block += this.buildBody(config, node); - // @deprecated - // if this._decorators doesn't include an @deprecated decorator but the field is marked as @deprecated... - if (!this._decorators.includes('@deprecated') && isDeprecated) { - this._decorators = [...this._decorators, '@deprecated\n']; - } + block += this.buildFooter(config, typeName, _className); - return this; + return block; } - private getFreezedDecorator() { - const use_unfreezed = () => { - if ( - !this._freezedConfigValue.get('immutable') || - (this._node.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION && this._freezedConfigValue.get('mutableInputs')) - ) { - return '@unfreezed\n'; - } - return use_Freezed_or_freezed(); - }; - - const use_Freezed_or_freezed = () => { - if (isCustomizedFreezed()) { - const copyWith = this._freezedConfigValue.get('copyWith'); - const equal = this._freezedConfigValue.get('equal'); - const makeCollectionsUnmodifiable = this._freezedConfigValue.get('makeCollectionsUnmodifiable'); - const unionKey = this._freezedConfigValue.get('unionKey'); - const unionValueCase = this._freezedConfigValue.get<'FreezedUnionCase.camel' | 'FreezedUnionCase.pascal'>( - 'unionValueCase' - ); - - let atFreezed = '@Freezed(\n'; - - if (copyWith !== undefined) { - atFreezed += indent(`copyWith: ${copyWith},\n`); - } - - if (equal !== undefined) { - atFreezed += indent(`equal: ${equal},\n`); - } - - if (makeCollectionsUnmodifiable !== undefined) { - atFreezed += indent(`makeCollectionsUnmodifiable: ${makeCollectionsUnmodifiable},\n`); - } - - if (unionKey !== undefined) { - atFreezed += indent(`unionKey: ${unionKey},\n`); - } - - if (unionValueCase !== undefined) { - atFreezed += indent(`unionValueCase: '${unionValueCase}',\n`); - } - - atFreezed += ')\n'; - - return atFreezed; - } - // else fallback to the normal @freezed decorator - return '@freezed\n'; - }; - - const isCustomizedFreezed = () => { - return ( - this._freezedConfigValue.get('copyWith') !== undefined || - this._freezedConfigValue.get('equal') !== undefined || - this._freezedConfigValue.get('makeCollectionsUnmodifiable') !== undefined || - this._freezedConfigValue.get('unionKey') !== undefined || - this._freezedConfigValue.get<'FreezedUnionCase.camel' | 'FreezedUnionCase.pascal'>('unionValueCase') !== - undefined - ); - }; + public static buildDecorators = (config: FlutterFreezedPluginConfig, node: NodeType): string => { + const freezedDecorator = ClassBlock.buildFreezedDecorator(config, node); + // TODO: consider implementing custom decorators + return [freezedDecorator].join(''); + }; + static buildFreezedDecorator = (config: FlutterFreezedPluginConfig, node: NodeType): string => { // this is the start of the pipeline of decisions to determine which Freezed decorator to use - return use_unfreezed(); - } - - private setName(): FreezedDeclarationBlock { - this._name = pascalCase(this._node.name.value); - return this; - } - - private setFactoryBlocks(): FreezedDeclarationBlock { - if (this._node.kind === Kind.UNION_TYPE_DEFINITION) { - this._factoryBlocks = - this._node.types?.map((_type: NamedTypeNode) => new FreezedFactoryBlock(this._config, this._node).init()) ?? []; - } else if (this._node.kind !== Kind.ENUM_TYPE_DEFINITION) { - /* - for `ObjectTypeDefinitionNode` and `InputObjectTypeDefinitionNode` nodes, - we use the `ShapeRepository` - to register the `FreezedFactoryBlock` so that we can use it later - when we are merging inputs or generating freezed union/sealed classes - for GraphQL union types - */ - this._factoryBlocks = - this._node.fields?.map((_field: FieldDefinitionNode | InputValueDefinitionNode) => - this._freezedFactoryBlockRepository.register( - this._node.name.value, - new FreezedFactoryBlock(this._config, this._node).init() - ) - ) ?? []; - } - return this; - } - - private setShape(): FreezedDeclarationBlock { - let shape = ''; - // some helper variables - const name = this._node.name.value; - let namedConstructor: string | undefined; - let factoryBlockKey: string | undefined; - - // handle enums differently - if (this._node.kind === Kind.ENUM_TYPE_DEFINITION) { - this._shape = this._node.values - ?.map((value: EnumValueDefinitionNode) => { - shape = indent(this.getEnumComment(value)); - - if (this._config.camelCasedEnums ?? true) { - shape += `@JsonKey(name: ${value.name.value}) ${value.name.value.toLowerCase()}`; - } else { - shape += value.name.value; - } - return `${shape}\n`; + return ClassBlock.decorateAsFreezed(config, node); + }; + + static decorateAsFreezed = (config: FlutterFreezedPluginConfig, node: NodeType): string => { + const typeName = TypeName.fromString(node.name.value); + + const copyWith = Config.copyWith(config, typeName); + const equal = Config.equal(config, typeName); + const makeCollectionsUnmodifiable = Config.makeCollectionsUnmodifiable(config, typeName); + const unionKey = Config.unionKey(); + const unionValueCase = Config.unionValueCase(); + + const body = [ + copyWith !== undefined ? `copyWith: ${copyWith},\n` : undefined, + equal !== undefined ? `equal: ${equal},\n` : undefined, + makeCollectionsUnmodifiable !== undefined + ? `makeCollectionsUnmodifiable: ${makeCollectionsUnmodifiable},\n` + : undefined, + unionKey !== undefined ? `unionKey: ${unionKey},\n` : undefined, + unionValueCase !== undefined ? `unionValueCase: '${unionValueCase}',\n` : undefined, + ] + .filter(value => value !== undefined) + .map(value => indent(value)) + .join(''); + + return stringIsNotEmpty(body) ? `@Freezed(\n${body})\n` : ClassBlock.decorateAsUnfreezed(config, node); + }; + + static decorateAsUnfreezed = (config: FlutterFreezedPluginConfig, node: NodeType) => { + const typeName = TypeName.fromString(node.name.value); + const immutable = Config.immutable(config, typeName); + const mutableInputs = Config.mutableInputs(config, typeName); + const mutable = immutable !== true || (node.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION && mutableInputs); + + return mutable ? '@unfreezed\n' : '@freezed\n'; + }; + + public static buildHeader = (config: FlutterFreezedPluginConfig, typeName: TypeName, _className: string): string => { + const privateEmptyConstructor = Config.privateEmptyConstructor(config, typeName) + ? indent(`const ${_className}._();\n\n`) + : ''; + + return `class ${_className} with _$${_className} {\n${privateEmptyConstructor}`; + }; + + public static buildBody = (config: FlutterFreezedPluginConfig, node: NodeType): string => { + const className = TypeName.fromString(node.name.value); + + let body = ''; + + if (nodeIsObjectType(node)) { + body += FactoryBlock.serializeDefaultFactory(className); + } else if (node.kind === Kind.UNION_TYPE_DEFINITION) { + body += (node.types ?? []) + .map(value => { + const factoryName = TypeName.fromString(value.name.value); + return FactoryBlock.serializeUnionFactory(className, factoryName); }) .join(''); - return this; - } - - // append private empty constructor - if (this._freezedConfigValue.get('privateEmptyConstructor')) { - shape += indent(`const ${this._name}._();\n\n`); } - // decide whether to append an empty Union constructor - if (this._freezedConfigValue.get('defaultUnionConstructor') && this._node.kind === Kind.UNION_TYPE_DEFINITION) { - shape += indent(`const factory ${this._name}() = _${this._name};\n\n`); - } + body += Config.mergeTypes(config, className) + .map(value => { + const factoryName = TypeName.fromString(value); + return FactoryBlock.serializeMergedFactory(className, factoryName); + }) + .join(''); - // append tokens which will be used to retrieve the factory blocks - // from the FreezedFactoryBlockRepository - if (this._node.kind === Kind.UNION_TYPE_DEFINITION) { - this._node?.types?.forEach(type => { - namedConstructor = type.name.value; - factoryBlockKey = namedConstructor; - shape += `==>factory==>${factoryBlockKey}==>${'union_factory'}==>${name}==>${namedConstructor}\n`; - }); - } else { - factoryBlockKey = name; - // replace token for the ObjectType & InputType to be replaced with the default Freezed constructor - shape += `==>factory==>${factoryBlockKey}==>${'class_factory'}==>${name}\n`; - - const mergeInputs = this._freezedConfigValue.get('mergeInputs'); - - if (this._node.kind === Kind.OBJECT_TYPE_DEFINITION && mergeInputs) { - // replace token for the InputTypes(a.k.a namedConstructors) as a union/sealed class - mergeInputs.forEach(input => { - const separator = input.includes('$') ? '$' : input.includes(name) ? name : '*'; - namedConstructor = camelCase(input.split(separator).join('_')); - factoryBlockKey = input.replace('$', name); - shape += `==>factory==>${factoryBlockKey}==>${'merged_input_factory'}==>${name}==>${namedConstructor}\n`; - }); - } - } - - this._shape = shape; - - return this; - } - - /** - * returns the string output of the block - */ - private setBlock(): FreezedDeclarationBlock { - let block = ''; + return body; + }; - //append comment - block += this._comment; - - // append the decorators - block += this._decorators.join(''); - - // handle enums differently - if (this._node.kind === Kind.ENUM_TYPE_DEFINITION) { - block += `enum ${this._name}{\n${this._shape}}\n\n`; - - this._block = block; - - return this; - } - - // append start of class definition - block += `class ${this._name} with _$${this._name} {\n`; - - // append the shape - block += this._shape; - - // append fromJson - if (this._freezedConfigValue.get('fromJsonToJson')) { - block += indent(`factory ${this._name}.fromJson(Map json) => _${this._name}FromJson(json);\n`); - } - - //append end of class definition - block += '}\n\n'; - - this._block = block; - - return this; - } - - /** returns the block */ - public toString(): string { - if (!this._block) { - throw new Error('setShape must be called before calling toString()'); - } - return this._block; - } + public static buildFooter = (config: FlutterFreezedPluginConfig, typeName: TypeName, _className: string): string => { + return indent(`factory ${_className}.fromJson(Map json) => _$${_className}FromJson(json);\n}\n\n`); + }; } diff --git a/packages/plugins/dart/flutter-freezed/src/freezed-declaration-blocks/enum-block.ts b/packages/plugins/dart/flutter-freezed/src/freezed-declaration-blocks/enum-block.ts new file mode 100644 index 000000000..f9d7aa76a --- /dev/null +++ b/packages/plugins/dart/flutter-freezed/src/freezed-declaration-blocks/enum-block.ts @@ -0,0 +1,80 @@ +import { EnumTypeDefinitionNode, EnumValueDefinitionNode } from 'graphql'; +import { TypeName, FieldName } from '../config/pattern.js'; +import { APPLIES_ON_ENUM, APPLIES_ON_ENUM_VALUE, FlutterFreezedPluginConfig } from '../config/plugin-config.js'; +import { indent } from '@graphql-codegen/visitor-plugin-common'; +import { Block } from './index.js'; +import { Config } from '../config/config-value.js'; +import { atJsonKeyDecorator, stringIsNotEmpty } from '../utils.js'; + +export class EnumBlock { + // TODO: @next-version: Implement enhanced enums + public static build(config: FlutterFreezedPluginConfig, node: EnumTypeDefinitionNode): string { + const typeName = TypeName.fromString(node.name.value); + + let block = ''; + + block += Block.buildComment(node); + + block += this.buildDecorators(); + + block += this.buildHeader(config, typeName); + + block += this.buildBody(config, node); + + block += this.buildFooter(); + + return block; + } + + public static buildDecorators = (): string => { + // TODO: @next-version: @JsonEnum(valueField: 'code', fieldRename: 'new-name') + return ''; + }; + + public static buildHeader = (config: FlutterFreezedPluginConfig, typeName: TypeName): string => { + const enumTypeName = Block.buildBlockName( + config, + APPLIES_ON_ENUM, + typeName.value, + typeName, + undefined, + 'PascalCase' + ); + return `enum ${enumTypeName} {\n`; + }; + + public static buildBody = (config: FlutterFreezedPluginConfig, node: EnumTypeDefinitionNode): string => { + const typeName = TypeName.fromString(node.name.value); + return (node.values ?? []) + ?.map((enumValue: EnumValueDefinitionNode) => { + const fieldName = FieldName.fromString(enumValue.name.value); + const enumValueName = Block.buildBlockName( + config, + APPLIES_ON_ENUM_VALUE, + fieldName.value, + typeName, + fieldName, + Config.camelCasedEnums(config) + ); + + const comment = Block.buildComment(enumValue); + const jsonKey = atJsonKeyDecorator({ + name: fieldName.value !== enumValueName ? fieldName.value : undefined, + }); + //TODO: @next-version: const jsonValue = @JsonValue(String|int) + const decorators = [ + jsonKey, + // jsonValue + ].join(''); + + return [comment, decorators, `${enumValueName}\n`] + .map(block => (stringIsNotEmpty(block) ? indent(block) : block)) + .join(''); + }) + .join(''); + }; + + public static buildFooter = (): string => { + return '}\n\n'; + }; +} diff --git a/packages/plugins/dart/flutter-freezed/src/freezed-declaration-blocks/factory-block.ts b/packages/plugins/dart/flutter-freezed/src/freezed-declaration-blocks/factory-block.ts index 8dd08674f..98a6e3159 100644 --- a/packages/plugins/dart/flutter-freezed/src/freezed-declaration-blocks/factory-block.ts +++ b/packages/plugins/dart/flutter-freezed/src/freezed-declaration-blocks/factory-block.ts @@ -1,170 +1,205 @@ import { indent } from '@graphql-codegen/visitor-plugin-common'; -import { FieldDefinitionNode, InputValueDefinitionNode, Kind } from 'graphql'; -import { camelCase, pascalCase } from 'change-case-all'; -import { FreezedParameterBlock } from './parameter-block.js'; -import { ApplyDecoratorOn, FlutterFreezedPluginConfig } from '../config.js'; -import { FreezedConfigValue, getCustomDecorators, NodeType, transformCustomDecorators } from '../utils.js'; - -export class FreezedFactoryBlock { - /** document the constructor */ - _comment = ''; - - /** a list of decorators to copy paste to the generator */ - _decorators: string[] = []; - - /** the key of the original type name */ - _key: string | undefined; - - /** the name of the class */ - _name: string | undefined; - - /** the namedConstructor is used for GraphQL Union types or if mergeInput is true */ - _namedConstructor: string | undefined; - - /** a list of interfaces to implements */ - // _implements: string[] = []; +import { Config } from '../config/config-value.js'; +import { TypeName } from '../config/pattern.js'; +import { + FlutterFreezedPluginConfig, + ObjectType, + AppliesOnFactory, + AppliesOnParameters, + AppliesOnDefaultFactory, + AppliesOnNamedFactory, + APPLIES_ON_DEFAULT_FACTORY, + APPLIES_ON_UNION_FACTORY, + APPLIES_ON_MERGED_FACTORY, + APPLIES_ON_DEFAULT_FACTORY_PARAMETERS, + APPLIES_ON_UNION_FACTORY_PARAMETERS, + APPLIES_ON_MERGED_FACTORY_PARAMETERS, +} from '../config/plugin-config.js'; +import { NodeRepository } from './node-repository.js'; +import { Block } from './index.js'; +import { ParameterBlock } from './parameter-block.js'; +import { FieldDefinitionNode, InputValueDefinitionNode } from 'graphql'; +import { stringIsNotEmpty } from '../utils.js'; + +export class FactoryBlock { + public static build( + config: FlutterFreezedPluginConfig, + node: ObjectType, + blockAppliesOn: readonly AppliesOnFactory[], + className: TypeName, + factoryName?: TypeName + ): string { + let block = ''; - /** a list of class to mixin with */ - // _mixins: string[] = []; + block += Block.buildComment(node); - /** the parameters of this factory constructor */ - // TODO: handle other parameter types like positional parameters later. - // TODO: sticking to named parameters because GraphQL is a typed language - _parameters: FreezedParameterBlock[] = []; + block += this.buildDecorators(config, blockAppliesOn, className, factoryName); - /** the shape is the content of the block */ - _shape: string | undefined; + block += this.buildHeader(config, blockAppliesOn, className, factoryName); - /** the block is the final structure that is generated */ - _block: string | undefined; + block += this.buildBody(config, node, blockAppliesOn); - private _freezedConfigValue: FreezedConfigValue; + factoryName = blockAppliesOn.includes('default_factory') ? className : factoryName; + block += this.buildFooter(config, blockAppliesOn, factoryName); - constructor(private _config: FlutterFreezedPluginConfig, private _node: NodeType) { - this._config = _config; - this._node = _node; - this._freezedConfigValue = new FreezedConfigValue(_config, _node.name.value); + return block; } - public init(): FreezedFactoryBlock { - /* - setDecorators(), setName() and setType() will be called - when the factory is retrieved from the repository - */ - this.setComment().setParameters().setShape().setBlock(); - return this; - } - - private setComment(): FreezedFactoryBlock { - const comment = this._node.description?.value; - - if (comment && comment !== null && comment !== '') { - this._comment = indent(`/// ${comment} \n`); + public static buildDecorators = ( + config: FlutterFreezedPluginConfig, + blockAppliesOn: readonly AppliesOnFactory[], + className: TypeName, + factoryName?: TypeName + ): string => { + // TODO: @Assert + const typeName = factoryName ? TypeName.fromUnionOfTypeNames(className, factoryName) : className; + + const deprecatedDecorator = Config.deprecated(config, blockAppliesOn, typeName); + + const decorators = [deprecatedDecorator].join(''); + + return stringIsNotEmpty(decorators) ? indent(decorators) : decorators; + }; + + public static buildHeader = ( + config: FlutterFreezedPluginConfig, + blockAppliesOn: readonly AppliesOnFactory[], + className: TypeName, + factoryName?: TypeName + ) => { + const typeName = factoryName ? TypeName.fromUnionOfTypeNames(className, factoryName) : className; + + const immutable = Config.immutable(config, typeName); + // const mutableInputs = Config.mutableInputs(config, factoryName); + // const mutable = immutable !== true || (node.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION && mutableInputs); + const constFactory = immutable ? indent('const factory') : indent('factory'); + const _className = Block.buildBlockName( + config, + blockAppliesOn, + className.value, + className, + undefined, + 'PascalCase' + ); + + if (factoryName) { + const _factoryName = Block.buildBlockName( + config, + blockAppliesOn, + factoryName.value, + factoryName, + undefined, + 'camelCase' + ); + return `${constFactory} ${_className}.${_factoryName}({\n`; } - return this; - } - - setDecorators(appliesOn: string, nodeName: string): FreezedFactoryBlock { - this._decorators = [ - ...transformCustomDecorators( - getCustomDecorators(this._config, appliesOn.split(',') as ApplyDecoratorOn[], nodeName), - this._node - ), - ]; - return this; - } - - setKey(key: string): FreezedFactoryBlock { - this._key = pascalCase(key); - return this; - } - setName(name: string): FreezedFactoryBlock { - this._name = pascalCase(name); - return this; - } - - setNamedConstructor(namedConstructor: string | undefined): FreezedFactoryBlock { - if (namedConstructor) { - this._namedConstructor = camelCase(namedConstructor); + return `${constFactory} ${_className}({\n`; + }; + + public static buildBody = ( + config: FlutterFreezedPluginConfig, + node: ObjectType, + appliesOn: readonly AppliesOnFactory[] + ): string => { + let appliesOnParameters: readonly AppliesOnParameters[] = []; + if (appliesOn.includes('default_factory')) { + appliesOnParameters = APPLIES_ON_DEFAULT_FACTORY_PARAMETERS; + } else if (appliesOn.includes('union_factory')) { + appliesOnParameters = APPLIES_ON_UNION_FACTORY_PARAMETERS; + } else if (appliesOn.includes('merged_factory')) { + appliesOnParameters = APPLIES_ON_MERGED_FACTORY_PARAMETERS; } - return this; - } - private setParameters(): FreezedFactoryBlock { - // TODO: get this from config directly - const mergeInputs = this._freezedConfigValue.get('mergeInputs'); - const appliesOn: ApplyDecoratorOn[] = this._namedConstructor - ? ['union_factory_parameter'] - : mergeInputs ?? mergeInputs !== [] - ? ['merged_input_parameter'] - : ['class_factory_parameter']; - - if (this._node.kind !== Kind.UNION_TYPE_DEFINITION && this._node.kind !== Kind.ENUM_TYPE_DEFINITION) { - this._parameters = - this._node?.fields?.map((field: FieldDefinitionNode | InputValueDefinitionNode) => - new FreezedParameterBlock(this._config, appliesOn, this._node, field).init() - ) ?? []; + return ( + node.fields + ?.map((field: FieldDefinitionNode | InputValueDefinitionNode) => { + return ParameterBlock.build(config, node, field, appliesOnParameters); + }) + .join('') ?? '' + ); + }; + + public static buildFooter = ( + config: FlutterFreezedPluginConfig, + blockAppliesOn: readonly AppliesOnFactory[], + factoryName: TypeName + ) => { + const _ = blockAppliesOn.includes('default_factory') ? '_' : ''; + const _factoryName = Block.buildBlockName( + config, + blockAppliesOn, + factoryName.value, + factoryName, + undefined, + 'PascalCase' + ); + return indent(`}) = ${_}${_factoryName};\n\n`); + }; + + public static serializeDefaultFactory = (className: TypeName): string => { + return `${Block.tokens.defaultFactory}${className.value}==>${APPLIES_ON_DEFAULT_FACTORY.join(',')}\n`; + }; + + public static serializeUnionFactory = (className: TypeName, factoryName: TypeName): string => { + return `${Block.tokens.unionFactory}${className.value}==>${factoryName.value}==>${APPLIES_ON_UNION_FACTORY.join( + ',' + )}\n`; + }; + + public static serializeMergedFactory = (className: TypeName, factoryName: TypeName): string => { + return `${Block.tokens.mergedFactory}${className.value}==>${factoryName.value}==>${APPLIES_ON_MERGED_FACTORY.join( + ',' + )}\n`; + }; + + public static deserializeFactory = ( + config: FlutterFreezedPluginConfig, + nodeRepository: NodeRepository, + blockAppliesOn: readonly AppliesOnDefaultFactory[], + className: TypeName + ): string => { + const node = nodeRepository.get(className.value); + + if (node) { + return FactoryBlock.buildFromFactory(config, node, blockAppliesOn, className); } - return this; - } - private setShape(): FreezedFactoryBlock { - this._shape = this._parameters.map(p => p.toString()).join(''); - return this; - } - - private setBlock(): FreezedFactoryBlock { - let block = ''; - - //append comment - block += this._comment; - - // append the decorators - block += this._decorators.map(d => indent(d)).join(''); - - block += indent(''); - - // decide if to use const or not - if (this._freezedConfigValue.get('immutable')) { - block += 'const '; - } - - // append the factory keyword and the name - block += `factory ${this._name}`; + return ''; + }; - // append .namedConstructor is not null - if (this._namedConstructor && this._namedConstructor !== '') { - block += `.${this._namedConstructor}`; - } - - // append the parenthesis for the constructor and braces for the named parameters - block += '({\n'; - - //append the shape - block += this._shape; + public static deserializeNamedFactory = ( + config: FlutterFreezedPluginConfig, + nodeRepository: NodeRepository, + blockAppliesOn: readonly AppliesOnNamedFactory[], + className: TypeName, + factoryName: TypeName + ): string => { + const node = nodeRepository.get(factoryName.value); - // close the constructor and assign the key - block += indent(`}) = `); - - // but first decide whether prefix the key with an underscore - if (!this._namedConstructor) { - block += '_'; + if (node) { + return FactoryBlock.buildFromNamedFactory(config, node, blockAppliesOn, className, factoryName); } - // finally, append the key - block += `${this._key};\n`; - - // store it in the shape - this._block = block; - return this; - } - - /** returns the block */ - public toString(): string { - if (!this._block) { - throw new Error('FreezedFactoryBlock: setShape must be called before calling toString()'); - } - return this._block; - } + return ''; + }; + + public static buildFromFactory = ( + config: FlutterFreezedPluginConfig, + node: ObjectType, + blockAppliesOn: readonly AppliesOnDefaultFactory[], + className: TypeName + ): string => { + return FactoryBlock.build(config, node, blockAppliesOn, className); + }; + + public static buildFromNamedFactory = ( + config: FlutterFreezedPluginConfig, + node: ObjectType, + blockAppliesOn: readonly AppliesOnNamedFactory[], + className: TypeName, + factoryName: TypeName + ): string => { + return FactoryBlock.build(config, node, blockAppliesOn, className, factoryName); + }; } diff --git a/packages/plugins/dart/flutter-freezed/src/freezed-declaration-blocks/index.ts b/packages/plugins/dart/flutter-freezed/src/freezed-declaration-blocks/index.ts index 160c8dd83..3dac033f1 100644 --- a/packages/plugins/dart/flutter-freezed/src/freezed-declaration-blocks/index.ts +++ b/packages/plugins/dart/flutter-freezed/src/freezed-declaration-blocks/index.ts @@ -1,3 +1,145 @@ -export * from './parameter-block.js'; -export * from './factory-block.js'; -export * from './class-block.js'; +import { snakeCase } from 'change-case-all'; +import { Kind, EnumValueDefinitionNode } from 'graphql'; +import { FieldName, TypeName } from '../config/pattern.js'; +import { Config } from '../config/config-value.js'; +import { + AppliesOn, + AppliesOnDefaultFactory, + AppliesOnNamedFactory, + DartIdentifierCasing, + FieldType, + FlutterFreezedPluginConfig, + NodeType, +} from '../config/plugin-config.js'; +import { dartCasing, escapeDartKeyword, isDartKeyword, nodeIsObjectType } from '../utils.js'; +import { ClassBlock } from './class-block.js'; +import { EnumBlock } from './enum-block.js'; +import { FactoryBlock } from './factory-block.js'; +import { NodeRepository } from './node-repository.js'; + +export class Block { + static buildImportStatements = (fileName: string) => { + if (fileName.length < 1) { + throw new Error('fileName is required and must not be empty'); + } + const segments = fileName.split('/'); + const target = segments[segments.length - 1]; + const expectedFileName = snakeCase(target.replace(/\.dart/g, '')); + return [ + `import 'package:freezed_annotation/freezed_annotation.dart';\n`, + `import 'package:flutter/foundation.dart';\n\n`, + `part '${expectedFileName}.freezed.dart';\n`, + `part '${expectedFileName}.g.dart';\n\n`, + ].join(''); + }; + + /** + * Transforms the AST nodes into Freezed classes/models + * @param config The plugin configuration object + * @param node the AST node passed by the schema visitor + * @param nodeRepository A map that stores the name of the Graphql Type as the key and it AST node as the value. Used to build FactoryBlocks from placeholders for mergedInputs and Union Types + * @returns a string output of a `FreezedDeclarationBlock` which represents a Freezed class/model in Dart + */ + static build = (config: FlutterFreezedPluginConfig, node: NodeType, nodeRepository: NodeRepository) => { + // ignore these... + const typeName = TypeName.fromString(node.name.value); + if (['Query', 'Mutation', 'Subscription', ...Config.ignoreTypes(config, typeName)].includes(typeName.value)) { + return ''; + } + + // registers all the ObjectTypes + if (nodeIsObjectType(node)) { + nodeRepository.register(node); + } + + return node.kind === Kind.ENUM_TYPE_DEFINITION ? EnumBlock.build(config, node) : ClassBlock.build(config, node); + }; + + static buildComment = (node?: NodeType | FieldType | EnumValueDefinitionNode): string => { + const comment = node?.description?.value; + + return comment && comment?.length > 0 + ? `${comment + .trim() + .split(/\n/gm) + .map(c => `/// ${c.trim().replace(/^#/, '')}\n`) + .join('')}` + : ''; + }; + + static buildBlockName = ( + config: FlutterFreezedPluginConfig, + blockAppliesOn: readonly AppliesOn[], + identifier: string, + typeName: TypeName, + fieldName?: FieldName, + blockCasing?: DartIdentifierCasing + ): string => { + identifier = dartCasing(identifier, blockCasing); + + if (isDartKeyword(identifier)) { + return escapeDartKeyword(config, blockAppliesOn, identifier, typeName, fieldName); + } + return identifier; + }; + + static tokens = { + defaultFactory: '==>default_factory==>', + unionFactory: '==>union_factory==>', + mergedFactory: '==>merged_factory==>', + fromJsonToJson: '==>from_json_to_json==>', + }; + + static regexpForToken = (tokenName: 'defaultFactory' | 'unionFactory' | 'mergedFactory' | 'fromJsonToJson') => { + return RegExp(`${Block.tokens[tokenName as string]}.+\n`, 'gm'); + }; + + static replaceTokens = ( + config: FlutterFreezedPluginConfig, + nodeRepository: NodeRepository, + generatedBlocks: string[] + ): string => + generatedBlocks + .map(block => { + block = Block.replaceDefaultFactoryToken(block, config, nodeRepository); + block = Block.replaceNamedFactoryToken(block, config, nodeRepository, 'unionFactory'); + block = Block.replaceNamedFactoryToken(block, config, nodeRepository, 'mergedFactory'); + // TODO: one more for parameter fromJson and toJson tokens inside @JsonKey + return block; + }) + .join(''); + + static replaceDefaultFactoryToken = ( + block: string, + config: FlutterFreezedPluginConfig, + nodeRepository: NodeRepository + ) => + block.replace(Block.regexpForToken('defaultFactory'), token => { + const pattern = token.replace(Block.tokens.defaultFactory, '').trim(); + const [className, blockAppliesOn] = pattern.split('==>'); + return FactoryBlock.deserializeFactory( + config, + nodeRepository, + blockAppliesOn.split(',') as readonly AppliesOnDefaultFactory[], + TypeName.fromString(className) + ); + }); + + static replaceNamedFactoryToken = ( + block: string, + config: FlutterFreezedPluginConfig, + nodeRepository: NodeRepository, + blockType: 'unionFactory' | 'mergedFactory' + ) => + block.replace(Block.regexpForToken(blockType), token => { + const pattern = token.replace(Block.tokens[blockType], '').trim(); + const [className, factoryName, blockAppliesOn] = pattern.split('==>'); + return FactoryBlock.deserializeNamedFactory( + config, + nodeRepository, + blockAppliesOn.split(',') as readonly AppliesOnNamedFactory[], + TypeName.fromString(className), + TypeName.fromString(factoryName) + ); + }); +} diff --git a/packages/plugins/dart/flutter-freezed/src/freezed-declaration-blocks/node-repository.ts b/packages/plugins/dart/flutter-freezed/src/freezed-declaration-blocks/node-repository.ts new file mode 100644 index 000000000..5f80924f3 --- /dev/null +++ b/packages/plugins/dart/flutter-freezed/src/freezed-declaration-blocks/node-repository.ts @@ -0,0 +1,26 @@ +//#region NodeRepository classes + +import { ObjectType } from '../config/plugin-config.js'; +import { nodeIsObjectType } from '../utils.js'; + +/** + * stores an instance of `ObjectTypeDefinitionNode` or `InputObjectTypeDefinitionNode` using the node name as the key + * and returns that node when replacing placeholders + * */ +export class NodeRepository { + private _store: Record = {}; + + get(key: string): ObjectType | undefined { + return this._store[key]; + } + + register(node: ObjectType): ObjectType { + if (!nodeIsObjectType(node)) { + throw new Error('Node is not an ObjectTypeDefinitionNode or InputObjectTypeDefinitionNode'); + } + this._store[node.name.value] = node; + return node; + } +} + +//#endregion diff --git a/packages/plugins/dart/flutter-freezed/src/freezed-declaration-blocks/parameter-block.ts b/packages/plugins/dart/flutter-freezed/src/freezed-declaration-blocks/parameter-block.ts index 258f616b2..690954578 100644 --- a/packages/plugins/dart/flutter-freezed/src/freezed-declaration-blocks/parameter-block.ts +++ b/packages/plugins/dart/flutter-freezed/src/freezed-declaration-blocks/parameter-block.ts @@ -1,204 +1,103 @@ -import { indent } from '@graphql-codegen/visitor-plugin-common'; +// import { indent } from '@graphql-codegen/visitor-plugin-common'; import { ListTypeNode, NamedTypeNode, NonNullTypeNode, TypeNode } from 'graphql'; -import { camelCase } from 'change-case-all'; -import { ApplyDecoratorOn, FlutterFreezedPluginConfig } from '../config.js'; -import { getCustomDecorators, transformCustomDecorators, FieldType, FreezedConfigValue, NodeType } from '../utils.js'; - -/** - * maps GraphQL scalar types to Dart's scalar types - */ -export const DART_SCALARS: Record = { - ID: 'String', - String: 'String', - Boolean: 'bool', - Int: 'int', - Float: 'double', - DateTime: 'DateTime', -}; - -export class FreezedParameterBlock { - /** document the property */ - _comment = ''; - - /** a list of decorators to copy paste to the generator */ - _decorators: string[] = []; - - /** mark the property as required */ - _required: boolean | undefined; - - /** mark the property as required */ - // _type?: ParameterType = 'named'; - _type: string | undefined; - - /** the name of the property */ - _name: string | undefined; - - /** the shape is the content of the block */ - _shape: string | undefined; - - /** the block is the final structure that is generated */ - _block: string | undefined; - - private _freezedConfigValue: FreezedConfigValue; - - constructor( - private _config: FlutterFreezedPluginConfig, - private _appliesOn: ApplyDecoratorOn[], - private _node: NodeType, - private _field: FieldType - ) { - this._config = _config; - this._appliesOn = _appliesOn; - this._node = _node; - this._field = _field; - - this._freezedConfigValue = new FreezedConfigValue(_config, _node.name.value); - } - - public init(): FreezedParameterBlock { - this.setComment().setDecorators().setRequired().setType().setName().setShape().setBlock(); - return this; - } - - private setComment(): FreezedParameterBlock { - const comment = this._field.description?.value; - - if (comment && comment !== null && comment !== '') { - this._comment = indent(`/// ${comment}\n`, 2); - } - return this; - } - - private setDecorators(): FreezedParameterBlock { - const nodeName = this._node.name.value; - const fieldName = this._field.name.value; - - // determine if should mark as deprecated - const isDeprecated = this._config.typeSpecificFreezedConfig?.[nodeName]?.fields?.[fieldName]?.deprecated; - const defaultValue = this._config.typeSpecificFreezedConfig?.[nodeName]?.fields?.[fieldName]?.defaultValue; - - if (this._freezedConfigValue.get('alwaysUseJsonKeyName') || fieldName !== camelCase(fieldName)) { - this._decorators = [...this._decorators, `@JsonKey(name: '${fieldName}')\n`]; - } - - this._decorators = [ - ...this._decorators, - ...transformCustomDecorators( - getCustomDecorators(this._config, this._appliesOn, this._node.name.value, fieldName), - this._node, - this._field - ), - ]; - - // @deprecated - // if this._decorators doesn't include an @deprecated decorator but the field is marked as @deprecated... - if (!this._decorators.includes('@deprecated') && isDeprecated) { - this._decorators = [...this._decorators, '@deprecated\n']; - } - - // @Default - if (defaultValue) { - //overwrite the customDecorator's defaultValue - this._decorators = this._decorators.filter(d => !d.startsWith('@Default')); - this._decorators = [...this._decorators, `@Default(value: ${defaultValue})\n`]; - } - return this; - } - - private setRequired(): FreezedParameterBlock { - this._required = this.isNonNullType(this._field.type); - return this; - } - - private setType(): FreezedParameterBlock { - this._type = this.propertyType(this._field, this._field.type); - return this; - } +import { atJsonKeyDecorator, stringIsNotEmpty } from '../utils.js'; +import { Config } from '../config/config-value.js'; +import { FieldName, TypeName } from '../config/pattern.js'; +import { AppliesOnParameters, FieldType, FlutterFreezedPluginConfig, NodeType } from '../config/plugin-config.js'; +import { Block } from './index.js'; +import { indent } from '@graphql-codegen/visitor-plugin-common'; - private setName(): FreezedParameterBlock { - this._name = camelCase(this._field.name.value); - return this; +export class ParameterBlock { + public static build( + config: FlutterFreezedPluginConfig, + node: NodeType, + field: FieldType, + blockAppliesOn: readonly AppliesOnParameters[] + ): string { + const typeName = TypeName.fromString(node.name.value); + const fieldName = FieldName.fromString(field.name.value); + const parameterName = Block.buildBlockName( + config, + blockAppliesOn, + fieldName.value, + typeName, + fieldName, + 'camelCase' + ); + + let block = ''; + + block += Block.buildComment(field); + + block += this.buildDecorators(config, typeName, fieldName, parameterName, blockAppliesOn); + + block += this.buildBody(config, field, typeName, fieldName, parameterName, blockAppliesOn); + + // return indentMultiline(block, 2); + return block; } - /** compose the freezed constructor property */ - private setShape(): FreezedParameterBlock { - let shape = ''; - const nodeName = this._node.name.value; - const fieldName = this._field.name.value; - - // determine if should mark as final - const isFinal = - this._decorators.includes('final') || - this._config.typeSpecificFreezedConfig?.[nodeName]?.fields?.[fieldName]?.final; - - //append comment - shape += this._comment; - - // append the decorators - shape += this._decorators - .filter(d => d !== 'final') - .map(d => indent(d, 2)) + public static buildDecorators = ( + config: FlutterFreezedPluginConfig, + typeName: TypeName, + fieldName: FieldName, + parameterName: string, + blockAppliesOn: readonly AppliesOnParameters[] + ): string => { + const deprecatedDecorator = Config.deprecated(config, blockAppliesOn, typeName, fieldName); + + const defaultValueDecorator = Config.defaultValues(config, blockAppliesOn, typeName, fieldName); + + const jsonKeyDecorator = atJsonKeyDecorator({ + name: fieldName.value !== parameterName ? fieldName.value : undefined, + }); + + return [ + deprecatedDecorator, + defaultValueDecorator, + jsonKeyDecorator, + // TODO: add decorator for unionValueName + ] + .filter(decorator => stringIsNotEmpty(decorator)) + .map(decorator => indent(decorator, 2)) .join(''); + }; - // append required for non-nullable types - shape += indent(this._required ? 'required ' : '', 2); - - // append isFinal - shape += isFinal ? 'final ' : ''; - - // append the Dart Type, name and trailing comma - shape += `${this._type} ${this._name},\n`; - - // store it in the shape - this._shape = shape; - - return this; - } - - /** composes the full block */ - private setBlock(): FreezedParameterBlock { - this._block = this._shape; - return this; - } + public static buildBody = ( + config: FlutterFreezedPluginConfig, + field: FieldType, + typeName: TypeName, + fieldName: FieldName, + parameterName: string, + blockAppliesOn: readonly AppliesOnParameters[] + ): string => { + const required = this.isNonNullType(field.type) ? 'required ' : ''; + const final = Config.final(config, blockAppliesOn, typeName, fieldName) ? 'final ' : ''; + const dartType = this.parameterType(config, field.type); + + return indent(`${required}${final}${dartType} ${parameterName},\n`, 2); + }; - private propertyType = (field: FieldType, type: TypeNode, parentType?: TypeNode): string => { + public static parameterType = (config: FlutterFreezedPluginConfig, type: TypeNode, parentType?: TypeNode): string => { if (this.isNonNullType(type)) { - return this.propertyType(field, type.type, type); + return this.parameterType(config, type.type, type); } if (this.isListType(type)) { - const T = this.propertyType(field, type.type, type); + const T = this.parameterType(config, type.type, type); return `List<${T}>${this.isNonNullType(parentType) ? '' : '?'}`; } if (this.isNamedType(type)) { - return `${this.scalar(type.name.value)}${this.isNonNullType(parentType) ? '' : '?'}`; + return `${Config.customScalars(config, type.name.value)}${this.isNonNullType(parentType) ? '' : '?'}`; } return ''; }; - private isListType = (type?: TypeNode): type is ListTypeNode => type?.kind === 'ListType'; - - private isNonNullType = (type?: TypeNode): type is NonNullTypeNode => type?.kind === 'NonNullType'; - - private isNamedType = (type?: TypeNode): type is NamedTypeNode => type?.kind === 'NamedType'; + public static isListType = (type?: TypeNode): type is ListTypeNode => type?.kind === 'ListType'; - private scalar(_scalar: string): string { - if (this._config?.customScalars?.[_scalar]) { - return this._config.customScalars[_scalar]; - } - if (DART_SCALARS[_scalar]) { - return DART_SCALARS[_scalar]; - } - return _scalar; - } + public static isNonNullType = (type?: TypeNode): type is NonNullTypeNode => type?.kind === 'NonNullType'; - /** returns the block */ - public toString(): string { - if (!this._block) { - throw new Error('FreezedParameterBlock: setShape must be called before calling toString()'); - } - return this._block; - } + public static isNamedType = (type?: TypeNode): type is NamedTypeNode => type?.kind === 'NamedType'; } diff --git a/packages/plugins/dart/flutter-freezed/src/index.ts b/packages/plugins/dart/flutter-freezed/src/index.ts index 84a05d430..613e0d2bb 100644 --- a/packages/plugins/dart/flutter-freezed/src/index.ts +++ b/packages/plugins/dart/flutter-freezed/src/index.ts @@ -1,39 +1,32 @@ import { oldVisit, PluginFunction, Types } from '@graphql-codegen/plugin-helpers'; import { transformSchemaAST } from '@graphql-codegen/schema-ast'; import { GraphQLSchema } from 'graphql'; -import { FlutterFreezedPluginConfig } from './config.js'; -import { FreezedDeclarationBlock } from './freezed-declaration-blocks/index.js'; +import { defaultFreezedPluginConfig, FlutterFreezedPluginConfig } from './config/plugin-config.js'; +import { Block } from './freezed-declaration-blocks/index.js'; import { schemaVisitor } from './schema-visitor.js'; -import { addFreezedImportStatements, DefaultFreezedPluginConfig } from './utils.js'; export const plugin: PluginFunction = ( schema: GraphQLSchema, _documents: Types.DocumentFile[], - config: FlutterFreezedPluginConfig + _config: FlutterFreezedPluginConfig, + info ): string => { // sets the defaults for the config - config = { ...new DefaultFreezedPluginConfig(config) }; + const config = { ...defaultFreezedPluginConfig, ..._config }; const { schema: _schema, ast } = transformSchemaAST(schema, config); - const { freezedFactoryBlockRepository, ...visitor } = schemaVisitor(_schema, config); + const { nodeRepository, ...visitor } = schemaVisitor(_schema, config); const visitorResult = oldVisit(ast, { leave: visitor }); - const generated: FreezedDeclarationBlock[] = visitorResult.definitions.filter( - (def: any) => def instanceof FreezedDeclarationBlock - ); + const importStatements = Block.buildImportStatements(info?.outputFile ?? 'app_models'); - return ( - addFreezedImportStatements(config.fileName) + - generated - .map(freezedDeclarationBlock => - freezedDeclarationBlock.toString().replace(/==>factory==>.+\n/gm, s => { - const pattern = s.replace('==>factory==>', '').trim(); - const [key, appliesOn, name, typeName] = pattern.split('==>'); - return freezedFactoryBlockRepository.retrieve(key, appliesOn, name, typeName ?? null); - }) - ) - .join('') - .trim() + const generatedBlocks: string[] = visitorResult.definitions.filter( + (def: any) => typeof def === 'string' && def.length > 0 ); + // return [importStatements, ...generatedBlocks].join('').trim(); + + const output = Block.replaceTokens(config, nodeRepository, generatedBlocks); + + return [importStatements, output].join('').trim(); }; diff --git a/packages/plugins/dart/flutter-freezed/src/schema-visitor.ts b/packages/plugins/dart/flutter-freezed/src/schema-visitor.ts index 4f9d1ae91..3fcc01cf8 100644 --- a/packages/plugins/dart/flutter-freezed/src/schema-visitor.ts +++ b/packages/plugins/dart/flutter-freezed/src/schema-visitor.ts @@ -5,24 +5,21 @@ import { ObjectTypeDefinitionNode, UnionTypeDefinitionNode, } from 'graphql'; -import { FlutterFreezedPluginConfig } from './config.js'; -import { FreezedFactoryBlockRepository, transformDefinition } from './utils.js'; +import { FlutterFreezedPluginConfig } from './config/plugin-config.js'; +import { Block } from './freezed-declaration-blocks/index.js'; +import { NodeRepository } from './freezed-declaration-blocks/node-repository.js'; export const schemaVisitor = (_schema: GraphQLSchema, config: FlutterFreezedPluginConfig) => { - const freezedFactoryBlockRepository = new FreezedFactoryBlockRepository(); + const nodeRepository = new NodeRepository(); return { - freezedFactoryBlockRepository, + nodeRepository, - EnumTypeDefinition: (node: EnumTypeDefinitionNode) => - transformDefinition(config, freezedFactoryBlockRepository, node), + EnumTypeDefinition: (node: EnumTypeDefinitionNode) => Block.build(config, node, nodeRepository), - UnionTypeDefinition: (node: UnionTypeDefinitionNode) => - transformDefinition(config, freezedFactoryBlockRepository, node), + UnionTypeDefinition: (node: UnionTypeDefinitionNode) => Block.build(config, node, nodeRepository), - ObjectTypeDefinition: (node: ObjectTypeDefinitionNode) => - transformDefinition(config, freezedFactoryBlockRepository, node), + ObjectTypeDefinition: (node: ObjectTypeDefinitionNode) => Block.build(config, node, nodeRepository), - InputObjectTypeDefinition: (node: InputObjectTypeDefinitionNode) => - transformDefinition(config, freezedFactoryBlockRepository, node), + InputObjectTypeDefinition: (node: InputObjectTypeDefinitionNode) => Block.build(config, node, nodeRepository), }; }; diff --git a/packages/plugins/dart/flutter-freezed/src/utils.ts b/packages/plugins/dart/flutter-freezed/src/utils.ts index 84105173d..51836408b 100644 --- a/packages/plugins/dart/flutter-freezed/src/utils.ts +++ b/packages/plugins/dart/flutter-freezed/src/utils.ts @@ -1,311 +1,104 @@ -import { - ArgumentNode, - DirectiveNode, - EnumTypeDefinitionNode, - FieldDefinitionNode, - InputObjectTypeDefinitionNode, - InputValueDefinitionNode, - ObjectTypeDefinitionNode, - UnionTypeDefinitionNode, -} from 'graphql'; -import { - ApplyDecoratorOn, - CustomDecorator, - FreezedConfig, - FlutterFreezedPluginConfig, - TypeSpecificFreezedConfig, -} from './config.js'; -import { FreezedDeclarationBlock, FreezedFactoryBlock } from './freezed-declaration-blocks/index.js'; +//#region helpers -export type FieldType = FieldDefinitionNode | InputValueDefinitionNode; +import { camelCase, pascalCase, snakeCase } from 'change-case-all'; +import { DefinitionNode, ObjectTypeDefinitionNode, InputObjectTypeDefinitionNode, Kind } from 'graphql'; +import { Config } from './config/config-value.js'; +import { FieldName, TypeName } from './config/pattern.js'; +import { AppliesOn, DartIdentifierCasing, DART_KEYWORDS, FlutterFreezedPluginConfig } from './config/plugin-config.js'; -export type NodeType = - | ObjectTypeDefinitionNode - | InputObjectTypeDefinitionNode - | UnionTypeDefinitionNode - | EnumTypeDefinitionNode; +export const strToList = (str: string) => (str.length < 1 ? [] : str.split(/\s*,\s*/gim).filter(s => s.length > 0)); -export type OptionName = - // FreezedClassConfig - | 'alwaysUseJsonKeyName' - | 'copyWith' - | 'customDecorators' - | 'defaultUnionConstructor' - | 'equal' - | 'fromJsonToJson' - | 'immutable' - | 'makeCollectionsUnmodifiable' - | 'mergeInputs' - | 'mutableInputs' - | 'privateEmptyConstructor' - | 'unionKey' - | 'unionValueCase'; +export const arrayWrap = (value: T | T[]) => + value === undefined ? [] : Array.isArray(value) ? value : ([value] as T[]); -export function transformDefinition( - config: FlutterFreezedPluginConfig, - freezedFactoryBlockRepository: FreezedFactoryBlockRepository, - node: NodeType -) { - // ignore these... - if (['Query', 'Mutation', 'Subscription', ...(config?.ignoreTypes ?? [])].includes(node.name.value)) { - return ''; - } - - return new FreezedDeclarationBlock(config, freezedFactoryBlockRepository, node).init(); -} - -/** - * returns the value of the FreezedConfig option - * for a specific type if given typeName - * or else fallback to the global FreezedConfig value - */ -export function getFreezedConfigValue( - option: OptionName, - config: FlutterFreezedPluginConfig, - typeName?: string | undefined -): any { - if (typeName) { - return config?.typeSpecificFreezedConfig?.[typeName]?.config?.[option] ?? getFreezedConfigValue(option, config); - } - return config?.globalFreezedConfig?.[option]; -} +export const resetIndex = (regexp: RegExp) => (regexp.lastIndex = 0); -/** - * @description filters the customDirectives to return those that are applied on a list of blocks - */ -export function getCustomDecorators( - config: FlutterFreezedPluginConfig, - appliesOn: ApplyDecoratorOn[], - nodeName?: string | undefined, - fieldName?: string | undefined -): CustomDecorator { - const filteredCustomDecorators: CustomDecorator = {}; - const globalCustomDecorators = config?.globalFreezedConfig?.customDecorators ?? {}; - let customDecorators: CustomDecorator = { ...globalCustomDecorators }; +export const nodeIsObjectType = ( + node: DefinitionNode +): node is ObjectTypeDefinitionNode | InputObjectTypeDefinitionNode => + node.kind === Kind.OBJECT_TYPE_DEFINITION || node.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION; - if (nodeName) { - const typeConfig = config?.typeSpecificFreezedConfig?.[nodeName]; - const typeSpecificCustomDecorators = typeConfig?.config?.customDecorators ?? {}; - customDecorators = { ...customDecorators, ...typeSpecificCustomDecorators }; +export const appliesOnBlock = (configAppliesOn: T[], blockAppliesOn: readonly T[]) => + configAppliesOn.some(a => blockAppliesOn.includes(a)); - if (fieldName) { - const fieldSpecificCustomDecorators = typeConfig?.fields?.[fieldName]?.customDecorators ?? {}; - customDecorators = { ...customDecorators, ...fieldSpecificCustomDecorators }; - } +export const dartCasing = (name: string, casing?: DartIdentifierCasing): string => { + if (casing === 'camelCase') { + return camelCase(name); + } else if (casing === 'PascalCase') { + return pascalCase(name); + } else if (casing === 'snake_case') { + return snakeCase(name); } - - Object.entries(customDecorators).forEach(([key, value]) => - value?.applyOn?.forEach(a => { - if (appliesOn.includes(a)) { - filteredCustomDecorators[key] = value; - } - }) - ); - - return filteredCustomDecorators; -} - -export function transformCustomDecorators( - customDecorators: CustomDecorator, - node?: NodeType | undefined, - field?: FieldType | undefined -): string[] { - let result: string[] = []; - - result = [ - ...result, - ...(node?.directives ?? []) - .concat(field?.directives ?? []) - // extract only the directives whose names were specified as keys - // and have values that not undefined or null in the customDecorator record - .filter(d => { - const key = d.name.value; - const value = customDecorators[key] ?? customDecorators[`@${key}`]; - if (value && value.mapsToFreezedAs !== 'custom') { - return true; - } - return false; - }) - // transform each directive to string - .map(d => directiveToString(d, customDecorators)), - ]; - - // for decorators that mapsToFreezedAs === 'custom' - Object.entries(customDecorators).forEach(([key, value]) => { - if (value.mapsToFreezedAs === 'custom') { - const args = value?.arguments; - // if the custom directives have arguments, - if (args && args !== []) { - // join them with a comma in the parenthesis - result = [...result, `${key}(${args.join(', ')})\n`]; - } else { - // else return the customDecorator key just as it is - result = [...result, key + '\n']; - } - } - }); - - return result; -} - + return name; +}; /** - * transforms the directive into a decorator array - * this decorator array might contain a `final` string which would be filtered out - * and used to mark the parameter block as final - */ -function directiveToString(directive: DirectiveNode, customDecorators: CustomDecorator) { - const key = directive.name.value; - const value = customDecorators[key]; - if (value.mapsToFreezedAs === 'directive') { - // get the directive's arguments - const directiveArgs: readonly ArgumentNode[] = directive?.arguments ?? []; - // extract the directive's argument using the template index: ["$0", "$1", ...] - // specified in the customDecorator.arguments array - const args = value?.arguments - ?.filter(a => directiveArgs[argToInt(a)]) - // transform the template index: ["$0", "$1", ...] into the arguments - .map(a => directiveArgs[argToInt(a)]) - // transform the arguments into string array of ["name: value" , "name: value", ...] - .map(a => `${a.name}: ${a.value}`); - - // if the args is not empty - if (args !== []) { - // returns "@directiveName(argName: argValue, argName: argValue ...)" - return `@${directive.name.value}(${args?.join(', ')})\n`; - } - } else if (value.mapsToFreezedAs === '@Default') { - const defaultValue = directive?.arguments?.[argToInt(value?.arguments?.[0] ?? '0')]; - if (defaultValue) { - return `@Default(value: ${defaultValue})\n`; - } - } - // returns either "@deprecated" || "final". - // `final` to be filtered from the decorators array when applying the decorators - return value.mapsToFreezedAs + '\n'; -} - -/** transforms string template: "$0" into an integer: 1 */ -function argToInt(arg: string) { - const parsedIndex = Number.parseInt(arg.replace('$', '').trim() ?? '0'); // '$1 => 1 - return parsedIndex ? parsedIndex : 0; -} - -/** returns freezed import statements */ -export function addFreezedImportStatements(fileName: string) { - return [ - "import 'package:freezed_annotation/freezed_annotation.dart';\n", - "import 'package:flutter/foundation.dart';\n\n", - `part ${fileName.replace(/\.dart/g, '')}.dart;\n`, - `part '${fileName.replace(/\.dart/g, '')}.g.dart';\n\n`, - ].join(''); -} - -/** a class variant of the getFreezedConfigValue helper function - * - * returns the value of the FreezedConfig option - * for a specific type if given typeName - * or else fallback to the global FreezedConfig value + * checks whether name is a Dart Language keyword + * @param identifier The name or identifier to be checked + * @returns `true` if name is a Dart Language keyword, otherwise `false` */ -export class FreezedConfigValue { - constructor(private _config: FlutterFreezedPluginConfig, private _typeName: string | undefined) { - this._config = _config; - this._typeName = _typeName; - } - - /** - * returns the value of the FreezedConfig option - * for a specific type if given typeName - * or else fallback to the global FreezedConfig value - */ - get(option: OptionName): T { - return getFreezedConfigValue(option, this._config, this._typeName) as T; - } -} +export const isDartKeyword = (identifier: string) => DART_KEYWORDS[identifier] !== undefined; /** - * stores an instance of FreezedFactoryBlock using the node names as the key - * and returns that instance when replacing tokens - * */ -export class FreezedFactoryBlockRepository { - _store: Record = {}; - - get(key: string): FreezedFactoryBlock | undefined { - return this._store[key]; - } - - register(key: string, value: FreezedFactoryBlock): FreezedFactoryBlock { - this._store[key] = value; - return value; - } - - retrieve(key: string, appliesOn: string, name: string, typeName: string | undefined): string { - if (this._store[key]) { - return ( - this._store[key] - .setDecorators(appliesOn, key) - .setKey(key) - .setName(name) - .setNamedConstructor(typeName) - .init() - .toString() + '\n' - ); - } - return ''; - } -} - -/** initializes a FreezedPluginConfig with the defaults values */ -export class DefaultFreezedPluginConfig implements FlutterFreezedPluginConfig { - camelCasedEnums?: boolean; - customScalars?: { [name: string]: string }; - fileName?: string; - globalFreezedConfig?: FreezedConfig; - typeSpecificFreezedConfig?: Record; - ignoreTypes?: string[]; - - constructor(config: FlutterFreezedPluginConfig = {}) { - Object.assign(this, { - camelCasedEnums: config.camelCasedEnums ?? true, - customScalars: config.customScalars ?? {}, - fileName: config.fileName ?? 'app_models', - globalFreezedConfig: { ...new DefaultFreezedConfig(), ...(config.globalFreezedConfig ?? {}) }, - typeSpecificFreezedConfig: config.typeSpecificFreezedConfig ?? {}, - ignoreTypes: config.ignoreTypes ?? [], - }); - } -} - -/** initializes a FreezedConfig with the defaults values */ -export class DefaultFreezedConfig implements FreezedConfig { - alwaysUseJsonKeyName?: boolean; - copyWith?: boolean; - customDecorators?: CustomDecorator; - defaultUnionConstructor?: boolean; - equal?: boolean; - fromJsonToJson?: boolean; - immutable?: boolean; - makeCollectionsUnmodifiable?: boolean; - mergeInputs?: string[]; - mutableInputs?: boolean; - privateEmptyConstructor?: boolean; - unionKey?: string; - unionValueCase?: 'FreezedUnionCase.camel' | 'FreezedUnionCase.pascal'; - - constructor() { - Object.assign(this, { - alwaysUseJsonKeyName: false, - copyWith: undefined, - customDecorators: {}, - defaultUnionConstructor: true, - equal: undefined, - fromJsonToJson: true, - immutable: true, - makeCollectionsUnmodifiable: undefined, - mergeInputs: [], - mutableInputs: true, - privateEmptyConstructor: true, - unionKey: undefined, - unionValueCase: undefined, - }); + * Ensures that the blockName isn't a valid Dart language reserved keyword. + * It wraps the identifier with the prefix and suffix then transforms the casing as specified in the config + * @param config + * @param name + * @param typeName + * @returns + */ +export const escapeDartKeyword = ( + config: FlutterFreezedPluginConfig, + blockAppliesOn: readonly AppliesOn[], + identifier: string, + typeName?: TypeName, + fieldName?: FieldName +): string => { + if (isDartKeyword(identifier)) { + const [prefix, suffix] = Config.escapeDartKeywords(config, blockAppliesOn, typeName, fieldName); + return `${prefix}${identifier}${suffix}`; } -} + return identifier; +}; + +// TODO: Add this option to the plugin-config +type JsonKeyOptions = { + defaultValue?: string; + disallowNullValue?: boolean; + fromJson?: string; + ignore?: boolean; + includeIfNull?: boolean; + name?: string; + // readValue?: string, + required?: boolean; + toJson?: string; + // unknownEnumValue?: string +}; +export const atJsonKeyDecorator = ({ + defaultValue, + disallowNullValue, + fromJson, + ignore, + includeIfNull, + name, + required, + toJson, +}: JsonKeyOptions): string => { + const body = [ + stringIsNotEmpty(defaultValue) ? `defaultValue: ${defaultValue}` : undefined, + disallowNullValue ? `disallowNullValue: ${disallowNullValue}` : undefined, + stringIsNotEmpty(fromJson) ? `fromJson: ${fromJson}` : undefined, + ignore ? `ignore: ${ignore}` : undefined, + includeIfNull ? `includeIfNull: ${includeIfNull}` : undefined, + stringIsNotEmpty(name) ? `name: '${name}'` : undefined, + required ? `required: ${required}` : undefined, + stringIsNotEmpty(toJson) ? `toJson: ${toJson}` : undefined, + ] + .filter(value => value !== undefined) + .join(','); + + return stringIsNotEmpty(body) ? `@JsonKey(${body})\n` : ''; +}; + +export const stringIsNotEmpty = (str: string) => str?.length > 0; + +//#endregion diff --git a/packages/plugins/dart/flutter-freezed/tests/config.spec.ts b/packages/plugins/dart/flutter-freezed/tests/config.spec.ts index 5c36b475f..eb0e4acd0 100644 --- a/packages/plugins/dart/flutter-freezed/tests/config.spec.ts +++ b/packages/plugins/dart/flutter-freezed/tests/config.spec.ts @@ -1,427 +1,414 @@ -import { plugin } from '@graphql-codegen/flutter-freezed'; -import { DefaultFreezedPluginConfig, getFreezedConfigValue } from '../src/utils'; -import { defaultConfig, typeConfig } from './config'; import { - baseSchema, - cyclicSchema, - enumSchema, - extendedBaseSchema, - nonNullableListWithCustomScalars, - simpleUnionSchema, -} from './schema'; - -describe('flutter-freezed-plugin-config', () => { - it('should return the default plugin values', () => { - const config = defaultConfig; - expect(config.camelCasedEnums).toBe(true); - expect(config.customScalars).toMatchObject({}); - expect(config.fileName).toBe('app_models'); - expect(config.ignoreTypes).toMatchObject([]); + AppliesOnFactory, + AppliesOnParameters, + APPLIES_ON_DEFAULT_FACTORY_PARAMETERS, + APPLIES_ON_MERGED_FACTORY_PARAMETERS, + APPLIES_ON_PARAMETERS, + APPLIES_ON_UNION_FACTORY_PARAMETERS, + DART_KEYWORDS, + DART_SCALARS, + defaultFreezedPluginConfig, +} from '../src/config/plugin-config.js'; +import { Config } from '../src/config/config-value.js'; +import { FieldName, FieldNamePattern, Pattern, TypeName, TypeNamePattern } from '../src/config/pattern.js'; + +describe("integrity checks: ensures that these values don't change and if they do, they're updated accordingly", () => { + test('integrity check: DART_SCALARS contains corresponding Dart Types mapping for built-in Graphql Scalars', () => { + expect(DART_SCALARS).toMatchObject({ + ID: 'String', + String: 'String', + Boolean: 'bool', + Int: 'int', + Float: 'double', + DateTime: 'DateTime', + }); }); - it('should return the default globalFreezedConfig values', () => { - const config = defaultConfig; - expect(config.globalFreezedConfig.alwaysUseJsonKeyName).toBe(false); - expect(config.globalFreezedConfig.copyWith).toBeUndefined(); - expect(config.globalFreezedConfig.customDecorators).toMatchObject({}); - expect(config.globalFreezedConfig.defaultUnionConstructor).toBe(true); - expect(config.globalFreezedConfig.equal).toBeUndefined(); - expect(config.globalFreezedConfig.fromJsonToJson).toBe(true); - expect(config.globalFreezedConfig.immutable).toBe(true); - expect(config.globalFreezedConfig.makeCollectionsUnmodifiable).toBeUndefined(); - expect(config.globalFreezedConfig.mergeInputs).toMatchObject([]); - expect(config.globalFreezedConfig.mutableInputs).toBe(true); - expect(config.globalFreezedConfig.privateEmptyConstructor).toBe(true); - expect(config.globalFreezedConfig.unionKey).toBeUndefined(); - expect(config.globalFreezedConfig.unionValueCase).toBeUndefined(); + test('integrity check: All DART_KEYWORDS are accounted for', () => { + expect(DART_KEYWORDS).toMatchObject({ + abstract: 'built-in', + else: 'reserved', + import: 'built-in', + show: 'context', + as: 'built-in', + enum: 'reserved', + in: 'reserved', + static: 'built-in', + assert: 'reserved', + export: 'built-in', + interface: 'built-in', + super: 'reserved', + async: 'context', + extends: 'reserved', + is: 'reserved', + switch: 'reserved', + await: 'async-reserved', + extension: 'built-in', + late: 'built-in', + sync: 'context', + break: 'reserved', + external: 'built-in', + library: 'built-in', + this: 'reserved', + case: 'reserved', + factory: 'built-in', + mixin: 'built-in', + throw: 'reserved', + catch: 'reserved', + false: 'reserved', + new: 'reserved', + true: 'reserved', + class: 'reserved', + final: 'reserved', + null: 'reserved', + try: 'reserved', + const: 'reserved', + finally: 'reserved', + on: 'context', + typedef: 'built-in', + continue: 'reserved', + for: 'reserved', + operator: 'built-in', + var: 'reserved', + covariant: 'built-in', + Function: 'built-in', + part: 'built-in', + void: 'reserved', + default: 'reserved', + get: 'built-in', + required: 'built-in', + while: 'reserved', + deferred: 'built-in', + hide: 'context', + rethrow: 'reserved', + with: 'reserved', + do: 'reserved', + if: 'reserved', + return: 'reserved', + yield: 'async-reserved', + dynamic: 'built-in', + implements: 'built-in', + set: 'built-in', + // built-in types + int: 'reserved', + double: 'reserved', + String: 'reserved', + bool: 'reserved', + List: 'reserved', + Set: 'reserved', + Map: 'reserved', + Runes: 'reserved', + Symbol: 'reserved', + Object: 'reserved', + Null: 'reserved', + Never: 'reserved', + Enum: 'reserved', + Future: 'reserved', + Iterable: 'reserved', + }); }); - it('should return the typeSpecificFreezedConfig values', () => { - const config = typeConfig; - const Starship = 'Starship'; - expect(config.typeSpecificFreezedConfig[Starship].config.alwaysUseJsonKeyName).toBe(true); - expect(config.typeSpecificFreezedConfig[Starship].config.copyWith).toBe(false); - expect(config.typeSpecificFreezedConfig[Starship].config.unionValueCase).toBe('FreezedUnionCase.pascal'); + test('integrity check: plugin config defaults are set', () => { + expect(defaultFreezedPluginConfig).toMatchObject({ + camelCasedEnums: true, + copyWith: undefined, + customScalars: {}, + defaultValues: undefined, + deprecated: undefined, + equal: undefined, + escapeDartKeywords: true, + final: undefined, + // fromJsonToJson: true, + ignoreTypes: undefined, + immutable: true, + makeCollectionsUnmodifiable: undefined, + mergeTypes: undefined, + mutableInputs: true, + privateEmptyConstructor: true, + unionClass: undefined, + }); }); +}); - describe('getFreezedConfigValue() returns the expect config value', () => { - const config = typeConfig; - const Starship = 'Starship'; - test('without a typeName should return the globalFreezedConfig value', () => { - expect(getFreezedConfigValue('alwaysUseJsonKeyName', config)).toBe(false); - expect(getFreezedConfigValue('copyWith', config)).toBeUndefined(); - expect(getFreezedConfigValue('unionValueCase', config)).toBe('FreezedUnionCase.camel'); +const Droid = TypeName.fromString('Droid'); +const Starship = TypeName.fromString('Starship'); +const Human = TypeName.fromString('Human'); +const Movie = TypeName.fromString('Movie'); + +const id = FieldName.fromString('id'); +const name = FieldName.fromString('name'); +const friends = FieldName.fromString('friends'); +const friend = FieldName.fromString('friend'); +const title = FieldName.fromString('title'); +const episode = FieldName.fromString('episode'); +const length = FieldName.fromString('length'); + +describe('Config: has methods that returns a ready-to-use value for all the config options', () => { + describe('Config.camelCasedEnums(...): returns a `DartIdentifierCasing` or `undefined`', () => { + const config = Config.create({}); + it('config.camelCasedEnums: defaults to `true`', () => { + expect(config.camelCasedEnums).toBe(true); }); - test('given a typeName should return the typeSpecificFreezedConfig value', () => { - expect(getFreezedConfigValue('alwaysUseJsonKeyName', config, Starship)).toBe(true); - expect(getFreezedConfigValue('copyWith', config, Starship)).toBe(false); - expect(getFreezedConfigValue('unionValueCase', config, Starship)).toBe('FreezedUnionCase.pascal'); + it('returns `camelCase` if set to `true`. Defaults to `true`', () => { + expect(Config.camelCasedEnums(config)).toBe('camelCase'); + }); + + it('allow to you to specify your preferred casing', () => { + config.camelCasedEnums = 'PascalCase'; + expect(Config.camelCasedEnums(config)).toBe('PascalCase'); + }); + + it('can be disabled by setting it to `false` or `undefined`', () => { + config.camelCasedEnums = undefined; + expect(Config.camelCasedEnums(config)).toBeUndefined(); + config.camelCasedEnums = false; + expect(Config.camelCasedEnums(config)).toBeUndefined(); }); }); - describe('configuring the plugin', () => { - test('the imported packages', () => { - expect(plugin(baseSchema, [], new DefaultFreezedPluginConfig({ fileName: 'graphql_models' }))) - .toContain(`import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:flutter/foundation.dart'; + describe('Config.customScalars(...): returns an equivalent Dart Type for a given Graphql Scalar Type', () => { + const config = Config.create({}); + it('config.customScalars: defaults to an empty object `{}`', () => { + expect(config.customScalars).toMatchObject({}); + }); + + // tell Dart how to handle your custom Graphql Scalars by mapping them to an corresponding Dart type + config.customScalars = { + jsonb: 'Map', + timestamp: 'DateTime', + UUID: 'String', + }; + + describe.each([ + // [scalar, dart-type] + ['jsonb', 'Map'], + ['timestamp', 'DateTime'], + ['UUID', 'String'], + ])('returns the equivalent Dart Type from the config', (scalar, dartType) => { + it(`the Dart equivalent of scalar: '${scalar}' is Dart type: '${dartType}'`, () => { + expect(Config.customScalars(config, scalar)).toBe(dartType); + }); + }); + + describe.each([ + // [scalar, dart-type] + ['ID', 'String'], + ['String', 'String'], + ['Boolean', 'bool'], + ['Int', 'int'], + ['Float', 'double'], + ['DateTime', 'DateTime'], + ])('returns the equivalent Dart Type from the DART_SCALARS', (scalar, dartType) => { + it(`the Dart equivalent of scalar: '${scalar}' is Dart type: '${dartType}'`, () => { + expect(Config.customScalars(config, scalar)).toBe(dartType); + }); + }); + + test('you can override the DART_SCALARS with the config', () => { + expect(Config.customScalars(config, 'ID')).toBe('String'); + config.customScalars = { ...config.customScalars, ID: 'int' }; + expect(Config.customScalars(config, 'ID')).toBe('int'); + }); -part graphql_models.dart; -part 'graphql_models.g.dart'; -`); + it('returns the Graphql Scalar if no equivalent type is found', () => { + expect(Config.customScalars(config, 'NanoId')).toBe('NanoId'); + }); - expect(plugin(baseSchema, [], new DefaultFreezedPluginConfig({ fileName: 'my_file_name.dart' }))) - .toContain(`import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:flutter/foundation.dart'; + it('is case-sensitive: returns the Graphql Scalar if no equivalent type is found', () => { + expect(Config.customScalars(config, 'UUID')).toBe('String'); + expect(Config.customScalars(config, 'uuid')).toBe('uuid'); + }); -part my_file_name.dart; -part 'my_file_name.g.dart'; -`); + test('order of precedence: config > DART_SCALARS > graphql scalar', () => { + config.customScalars = undefined; + expect(Config.customScalars(config, 'ID')).toBe('String'); + expect(Config.customScalars(config, 'uuid')).toBe('uuid'); }); + }); - test('the casing of Enum fields ', () => { - expect(plugin(enumSchema, [], new DefaultFreezedPluginConfig({ camelCasedEnums: true }))).toContain(`enum Episode{ - @JsonKey(name: NEWHOPE) newhope - @JsonKey(name: EMPIRE) empire - @JsonKey(name: JEDI) jedi -}`); - - expect(plugin(enumSchema, [], new DefaultFreezedPluginConfig({ camelCasedEnums: false }))) - .toContain(`enum Episode{ - NEWHOPE - EMPIRE - JEDI -}`); + describe('Config.defaultValue(...): sets the default value of a parameter/field', () => { + describe('different values can be set for each block type:', () => { + const config = Config.create({ + defaultValues: [ + [FieldNamePattern.forFieldNamesOfTypeName([[[Human, Droid], id]]), `'id:1'`, ['merged_factory_parameter']], + [FieldNamePattern.forFieldNamesOfTypeName([[[Human, Droid], id]]), `'id:2'`, ['default_factory_parameter']], + [FieldNamePattern.forFieldNamesOfTypeName([[[Human, Droid], id]]), `'id:3'`, ['union_factory_parameter']], + ], + }); + + it.each([Human, Droid])('each block type returns a decorator with a different value:', typeName => { + expect(Config.defaultValues(config, APPLIES_ON_PARAMETERS, typeName, id)).toBe(`@Default('id:3')\n`); + expect(Config.defaultValues(config, APPLIES_ON_DEFAULT_FACTORY_PARAMETERS, typeName, id)).toBe( + `@Default('id:2')\n` + ); + expect(Config.defaultValues(config, APPLIES_ON_UNION_FACTORY_PARAMETERS, typeName, id)).toBe( + `@Default('id:3')\n` + ); + expect(Config.defaultValues(config, APPLIES_ON_MERGED_FACTORY_PARAMETERS, typeName, id)).toBe( + `@Default('id:1')\n` + ); + }); + + it.each([Movie, Starship])('the following will return an empty string', typeName => { + expect(Config.defaultValues(config, APPLIES_ON_PARAMETERS, typeName, id)).toBe(''); + expect(Config.defaultValues(config, APPLIES_ON_DEFAULT_FACTORY_PARAMETERS, typeName, id)).toBe(''); + expect(Config.defaultValues(config, APPLIES_ON_UNION_FACTORY_PARAMETERS, typeName, id)).toBe(''); + expect(Config.defaultValues(config, APPLIES_ON_MERGED_FACTORY_PARAMETERS, typeName, id)).toBe(''); + }); }); - it('ignores these types ', () => { - expect(plugin(baseSchema, [], new DefaultFreezedPluginConfig({ ignoreTypes: ['PersonType'] }))).not.toContain( - `PersonType` - ); + describe('the same value can be set for all block type:', () => { + const config = Config.create({ + defaultValues: [ + [ + FieldNamePattern.forFieldNamesOfTypeName([[[Human, Droid], id]]), + `'id:1'`, + ['merged_factory_parameter', 'default_factory_parameter'], + ], + ], + }); + + it.each([Human, Droid])('each block type returns a decorator with the same value:', typeName => { + expect(Config.defaultValues(config, APPLIES_ON_MERGED_FACTORY_PARAMETERS, typeName, id)).toBe( + `@Default('id:1')\n` + ); + expect(Config.defaultValues(config, APPLIES_ON_DEFAULT_FACTORY_PARAMETERS, typeName, id)).toBe( + `@Default('id:1')\n` + ); + }); + + it.each([Human, Droid])('the following will return an empty string', typeName => { + expect(Config.defaultValues(config, APPLIES_ON_UNION_FACTORY_PARAMETERS, typeName, id)).toBe(''); + }); + + it.each([Movie, Starship])('the following will return an empty string', typeName => { + expect(Config.defaultValues(config, APPLIES_ON_PARAMETERS, typeName, id)).toBe(''); + expect(Config.defaultValues(config, APPLIES_ON_DEFAULT_FACTORY_PARAMETERS, typeName, id)).toBe(''); + expect(Config.defaultValues(config, APPLIES_ON_UNION_FACTORY_PARAMETERS, typeName, id)).toBe(''); + }); }); + }); + + describe('Config.deprecated(...): marks a factory or a parameter as deprecated', () => { + const config = Config.create({ + deprecated: [ + [TypeNamePattern.forTypeNames([Movie, Starship]), ['merged_factory', 'default_factory']], + [FieldNamePattern.forAllFieldNamesOfTypeName([Movie, Droid]), ['union_factory_parameter']], + [FieldNamePattern.forAllFieldNamesOfTypeName([Droid]), ['default_factory_parameter']], + ], + }); + type T = (AppliesOnFactory | AppliesOnParameters)[]; + + describe('some parameters can be marked as deprecated', () => { + it.each([ + [Movie, id, 'union_factory_parameter'], + [Movie, name, 'union_factory_parameter'], + [Movie, friends, 'union_factory_parameter'], + [Movie, friends, 'union_factory_parameter'], + [Movie, title, 'union_factory_parameter'], + [Movie, episode, 'union_factory_parameter'], + [Movie, length, 'union_factory_parameter'], + + [Droid, id, 'union_factory_parameter'], + [Droid, name, 'union_factory_parameter'], + [Droid, friends, 'union_factory_parameter'], + [Droid, friends, 'union_factory_parameter'], + [Droid, title, 'union_factory_parameter'], + [Droid, episode, 'union_factory_parameter'], + [Droid, length, 'union_factory_parameter'], + + [Droid, id, 'default_factory_parameter'], + [Droid, name, 'default_factory_parameter'], + [Droid, friends, 'default_factory_parameter'], + [Droid, friends, 'default_factory_parameter'], + [Droid, title, 'default_factory_parameter'], + [Droid, episode, 'default_factory_parameter'], + [Droid, length, 'default_factory_parameter'], + ])('%s.%s is deprecated:', (typeName, fieldName, configAppliesOn) => { + expect(Config.deprecated(config, [configAppliesOn] as T, typeName, fieldName)).toBe('@deprecated\n'); + expect(Config.deprecated(config, [configAppliesOn] as T, typeName, fieldName)).toBe('@deprecated\n'); + }); + }); + + describe('the whole factory block can be marked as deprecated', () => { + it.each([Movie, Starship])('%s is deprecated:', typeName => { + expect(Config.deprecated(config, ['merged_factory'], typeName)).toBe('@deprecated\n'); + expect(Config.deprecated(config, ['default_factory'], typeName)).toBe('@deprecated\n'); + }); + + it.each([Movie, Starship])('%s is not deprecated:', typeName => { + expect(Config.deprecated(config, ['union_factory'], typeName)).toBe(''); + }); + }); + }); - it('should handle custom scalars', () => { - expect( - plugin( - nonNullableListWithCustomScalars, - [], - new DefaultFreezedPluginConfig({ - customScalars: { - jsonb: 'Map', - timestamptz: 'DateTime', - UUID: 'String', - }, - }) - ) - ).toBe(`import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:flutter/foundation.dart'; - -part app_models.dart; -part 'app_models.g.dart'; - -@freezed -class ComplexType with _$ComplexType { - const ComplexType._(); - - const factory ComplexType({ - List? a, - List? b, - required List c, - List?>? d, - List>? e, - required List> f, - Map? g, - required DateTime h, - required String i, - }) = _ComplexType; - - factory ComplexType.fromJson(Map json) => _ComplexTypeFromJson(json); -}`); + describe('Config.findLastConfiguration(...): runs through the pattern given and returns true if the typeName and/or fieldName should be configured:`', () => { + const pattern = Pattern.compose([ + FieldNamePattern.forFieldNamesOfTypeName([ + [ + [Droid, Human], + [name, friends], + ], + ]), + TypeNamePattern.forAllTypeNamesExcludeTypeNames([Starship, Droid]), + FieldNamePattern.forFieldNamesOfAllTypeNames([id, title]), + ]); + + describe.each([Human, Movie])('the following typeNames will be configured:', typeName => { + it(`${typeName.value} will be configured`, () => { + expect(Pattern.findLastConfiguration(pattern, typeName)).toBe(true); + }); }); - it('using @JsonKey(name: "fieldName") for fields that are not camelCased', () => { - expect( - plugin( - extendedBaseSchema, - [], - new DefaultFreezedPluginConfig({ - globalFreezedConfig: { - alwaysUseJsonKeyName: true, - }, - typeSpecificFreezedConfig: { - PersonType: { - config: { - alwaysUseJsonKeyName: false, - }, - }, - }, - }) - ) - ).toBe(`import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:flutter/foundation.dart'; - -part app_models.dart; -part 'app_models.g.dart'; - -@freezed -class BaseType with _$BaseType { - const BaseType._(); - - const factory BaseType({ - @JsonKey(name: 'id') - String? id, - @JsonKey(name: 'primaryKey') - required String primaryKey, - @JsonKey(name: 'CompositeForeignKey') - required String compositeForeignKey, - }) = _BaseType; - - factory BaseType.fromJson(Map json) => _BaseTypeFromJson(json); -} - -@freezed -class PersonType with _$PersonType { - const PersonType._(); - - const factory PersonType({ - String? id, - required String name, - required String primaryKey, - @JsonKey(name: 'CompositeForeignKey') - required String compositeForeignKey, - }) = _PersonType; - - factory PersonType.fromJson(Map json) => _PersonTypeFromJson(json); -}`); + describe.each([Droid, Starship])('the following typeNames will not be configured:', typeName => { + it(`${typeName.value} will not be configured`, () => { + expect(Pattern.findLastConfiguration(pattern, typeName)).toBe(false); + }); }); - describe('using defaultUnionConstructor & privateEmptyConstructor ', () => { - it('generates empty constructors for Union Types and mergedInputs and a private empty constructor to allow getter and methods to work on the class', () => {}); - // this is enabled by default - let result = plugin(simpleUnionSchema, [], new DefaultFreezedPluginConfig({})); - - // contains defaultUnionConstructor - expect(result).toContain('const factory AuthWithOtpInput() = _AuthWithOtpInput;'); - // contains privateEmptyConstructor - expect(result).toContain('const VerifyOtpInput._();'); - // expected output - expect(result).toBe(`import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:flutter/foundation.dart'; - -part app_models.dart; -part 'app_models.g.dart'; - -@unfreezed -class RequestOtpInput with _$RequestOtpInput { - const RequestOtpInput._(); - - const factory RequestOtpInput({ - String? email, - String? phoneNumber, - }) = _RequestOtpInput; - - factory RequestOtpInput.fromJson(Map json) => _RequestOtpInputFromJson(json); -} - -@unfreezed -class VerifyOtpInput with _$VerifyOtpInput { - const VerifyOtpInput._(); - - const factory VerifyOtpInput({ - String? email, - String? phoneNumber, - required String otpCode, - }) = _VerifyOtpInput; - - factory VerifyOtpInput.fromJson(Map json) => _VerifyOtpInputFromJson(json); -} - -@freezed -class AuthWithOtpInput with _$AuthWithOtpInput { - const AuthWithOtpInput._(); - - const factory AuthWithOtpInput() = _AuthWithOtpInput; - - const factory AuthWithOtpInput.requestOtpInput({ - String? email, - String? phoneNumber, - }) = RequestOtpInput; - - const factory AuthWithOtpInput.verifyOtpInput({ - String? email, - String? phoneNumber, - required String otpCode, - }) = VerifyOtpInput; - - factory AuthWithOtpInput.fromJson(Map json) => _AuthWithOtpInputFromJson(json); -}`); - - // disabling the default config - result = plugin( - simpleUnionSchema, - [], - new DefaultFreezedPluginConfig({ - globalFreezedConfig: { - defaultUnionConstructor: false, - }, - typeSpecificFreezedConfig: { - VerifyOTPInput: { - config: { - privateEmptyConstructor: false, - }, - }, - }, - }) - ); - - // does NOT contain defaultUnionConstructor - expect(result).not.toContain('const factory AuthWithOtpInput() = _AuthWithOtpInput;'); - // does NOT contain privateEmptyConstructor - expect(result).not.toContain('const VerifyOtpInput._();'); - // expected output - expect(result).toBe(`import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:flutter/foundation.dart'; - -part app_models.dart; -part 'app_models.g.dart'; - -@unfreezed -class RequestOtpInput with _$RequestOtpInput { - const RequestOtpInput._(); - - const factory RequestOtpInput({ - String? email, - String? phoneNumber, - }) = _RequestOtpInput; - - factory RequestOtpInput.fromJson(Map json) => _RequestOtpInputFromJson(json); -} - -@unfreezed -class VerifyOtpInput with _$VerifyOtpInput { - const factory VerifyOtpInput({ - String? email, - String? phoneNumber, - required String otpCode, - }) = _VerifyOtpInput; - - factory VerifyOtpInput.fromJson(Map json) => _VerifyOtpInputFromJson(json); -} - -@freezed -class AuthWithOtpInput with _$AuthWithOtpInput { - const AuthWithOtpInput._(); - - const factory AuthWithOtpInput.requestOtpInput({ - String? email, - String? phoneNumber, - }) = RequestOtpInput; - - const factory AuthWithOtpInput.verifyOtpInput({ - String? email, - String? phoneNumber, - required String otpCode, - }) = VerifyOtpInput; - - factory AuthWithOtpInput.fromJson(Map json) => _AuthWithOtpInputFromJson(json); -}`); + describe.each([ + [Droid, id], + [Droid, name], + [Droid, friends], + [Droid, title], + + [Starship, id], + [Starship, title], + + [Human, id], + [Human, name], + [Human, friends], + [Human, title], + + [Movie, id], + [Movie, title], + ])('the following typeName.fieldName will be configured:', (typeName, fieldName) => { + it(`${typeName.value}.${fieldName.value} will be configured`, () => { + expect(Pattern.findLastConfiguration(pattern, typeName, fieldName)).toBe(true); + }); }); - it('to be immutable OR immutable but configurable OR mutable ', () => { - expect( - plugin( - cyclicSchema, - [], - new DefaultFreezedPluginConfig({ - globalFreezedConfig: { - immutable: true, - mutableInputs: true, - }, - typeSpecificFreezedConfig: { - BaseAInput: { - config: { - immutable: true, - mutableInputs: false, // takes precedence - }, - }, - BaseBInput: { - config: { - immutable: false, - mutableInputs: false, // takes precedence - }, - }, - BaseCInput: { - config: { - immutable: false, - mutableInputs: true, // takes precedence - }, - }, - Base: { - config: { - copyWith: false, - fromJsonToJson: false, - makeCollectionsUnmodifiable: true, - unionValueCase: 'FreezedUnionCase.pascal', - }, - }, - }, - }) - ) - ).toBe(`import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:flutter/foundation.dart'; - -part app_models.dart; -part 'app_models.g.dart'; - -@freezed -class BaseAInput with _$BaseAInput { - const BaseAInput._(); - - const factory BaseAInput({ - required BaseBInput b, - }) = _BaseAInput; - - factory BaseAInput.fromJson(Map json) => _BaseAInputFromJson(json); -} - -@unfreezed -class BaseBInput with _$BaseBInput { - const BaseBInput._(); - - factory BaseBInput({ - required BaseCInput c, - }) = _BaseBInput; - - factory BaseBInput.fromJson(Map json) => _BaseBInputFromJson(json); -} - -@unfreezed -class BaseCInput with _$BaseCInput { - const BaseCInput._(); - - factory BaseCInput({ - required BaseAInput a, - }) = _BaseCInput; - - factory BaseCInput.fromJson(Map json) => _BaseCInputFromJson(json); -} - -@Freezed( - copyWith: false, - makeCollectionsUnmodifiable: true, - unionValueCase: 'FreezedUnionCase.pascal', -) -class Base with _$Base { - const Base._(); - - const factory Base({ - String? id, - }) = _Base; - -}`); + describe.each([ + [Droid, friend], + [Droid, episode], + [Droid, length], + + [Starship, friend], + [Starship, episode], + [Starship, length], + + [Human, friend], + [Human, episode], + [Human, length], + + [Movie, friend], + [Movie, episode], + [Movie, length], + ])('the following typeNames will not be configured:', (typeName, fieldName) => { + it(`${typeName.value}.${fieldName.value} will not be configured`, () => { + expect(Pattern.findLastConfiguration(pattern, typeName, fieldName)).toBe(false); + }); }); }); }); -/* it('should ', () => {}); - it('should ', () => {}); - it('should ', () => {}) */ diff --git a/packages/plugins/dart/flutter-freezed/tests/config.ts b/packages/plugins/dart/flutter-freezed/tests/config.ts deleted file mode 100644 index 3c6d0ada4..000000000 --- a/packages/plugins/dart/flutter-freezed/tests/config.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { DefaultFreezedPluginConfig } from '../src/utils'; - -export const defaultConfig = new DefaultFreezedPluginConfig(); - -export const typeConfig = new DefaultFreezedPluginConfig({ - globalFreezedConfig: { - unionValueCase: 'FreezedUnionCase.camel', - }, - typeSpecificFreezedConfig: { - Starship: { - config: { - alwaysUseJsonKeyName: true, - copyWith: false, - unionValueCase: 'FreezedUnionCase.pascal', - }, - }, - }, -}); - -export const customDecoratorsConfig = new DefaultFreezedPluginConfig({ - globalFreezedConfig: { - customDecorators: { - '@JsonSerializable(explicitToJson: true)': { - applyOn: ['class'], - mapsToFreezedAs: 'custom', - }, - }, - }, - typeSpecificFreezedConfig: { - Droid: { - config: { - customDecorators: { - '@FreezedUnionValue': { - applyOn: ['union_factory'], - arguments: ["'BestDroid'"], - mapsToFreezedAs: 'custom', - }, - }, - }, - fields: { - id: { - customDecorators: { - '@NanoId': { - applyOn: ['union_factory_parameter'], - arguments: ['size: 16', 'alphabets: NanoId.ALPHA_NUMERIC'], - mapsToFreezedAs: 'custom', - }, - }, - }, - }, - }, - }, -}); - -export const fullDemoConfig = new DefaultFreezedPluginConfig({ - camelCasedEnums: true, - fileName: 'app_models', - customScalars: { - jsonb: 'Map', - timestamptz: 'DateTime', - UUID: 'String', - }, - ignoreTypes: [], - globalFreezedConfig: { - immutable: true, - privateEmptyConstructor: true, - mergeInputs: ['Create$Input', 'Upsert$Input', 'Delete$Input'], - defaultUnionConstructor: true, - mutableInputs: true, - customDecorators: { - '@JsonSerializable(explicitToJson: true)': { - applyOn: ['class'], - mapsToFreezedAs: 'custom', - }, - }, - }, - typeSpecificFreezedConfig: { - Base: { - config: { - mergeInputs: ['$AInput', '$BInput', 'BaseCInput', 'CreateMovieInput'], - }, - }, - Starship: { - config: { - alwaysUseJsonKeyName: true, - copyWith: false, - equal: false, - privateEmptyConstructor: false, - unionValueCase: 'FreezedUnionCase.pascal', - }, - }, - Droid: { - config: { - immutable: false, - fromJsonToJson: false, - customDecorators: { - '@FreezedUnionValue': { - applyOn: ['union_factory'], - arguments: ["'BestDroid'"], - mapsToFreezedAs: 'custom', - }, - }, - }, - fields: { - id: { - customDecorators: { - '@NanoId': { - applyOn: ['union_factory_parameter'], - arguments: ['size: 16', 'alphabets: NanoId.ALPHA_NUMERIC'], - mapsToFreezedAs: 'custom', - }, - }, - }, - }, - }, - }, -}); diff --git a/packages/plugins/dart/flutter-freezed/tests/node-repository.spec.ts b/packages/plugins/dart/flutter-freezed/tests/node-repository.spec.ts new file mode 100644 index 000000000..f65eed554 --- /dev/null +++ b/packages/plugins/dart/flutter-freezed/tests/node-repository.spec.ts @@ -0,0 +1,23 @@ +import { transformSchemaAST } from '@graphql-codegen/schema-ast'; +import { defaultFreezedPluginConfig, ObjectType } from '../src/config/plugin-config.js'; +import { NodeRepository } from '../src/freezed-declaration-blocks/node-repository.js'; +import { unionSchema } from './schema.js'; + +const { + ast: { definitions: nodes }, +} = transformSchemaAST(unionSchema, defaultFreezedPluginConfig); + +describe('NodeRepository can store and retrieve Object Types', () => { + const nodeRepository = new NodeRepository(); + it('returns node or undefined', () => { + expect(nodeRepository.get('Human')).toBeUndefined(); + + const objNode = nodeRepository.register(nodes[4] as ObjectType); + expect(nodeRepository.get('Human')).toBe(objNode); + + expect(() => nodeRepository.register(nodes[6] as ObjectType)).toThrow( + 'Node is not an ObjectTypeDefinitionNode or InputObjectTypeDefinitionNode' + ); + expect(nodeRepository.get('SearchResult')).toBeUndefined(); + }); +}); diff --git a/packages/plugins/dart/flutter-freezed/tests/pattern.spec.ts b/packages/plugins/dart/flutter-freezed/tests/pattern.spec.ts new file mode 100644 index 000000000..e1faa025c --- /dev/null +++ b/packages/plugins/dart/flutter-freezed/tests/pattern.spec.ts @@ -0,0 +1,1431 @@ +import { TypeName, FieldName, TypeNamePattern, FieldNamePattern, Pattern } from '../src/config/pattern.js'; + +//#region helper functions + +//#region arrayIndexed(...) +/** + * helper function that indexes the array passed to `.each `method of `describe`, `test` and `it` + * @param arr The array of tuples to be indexed + * @returns array of tuples where the first element in the tuple is the index of the tuple + */ +export const arrayIndexed = (arr: T[]): [index: number, ...rest: T][] => + arr.map((el, i) => [i, ...el]); + +test('helper method: indexArray(arr[][]): returns a new array where the first element it the index of the old array element', () => { + expect(arrayIndexed([['a'], ['b']])).toMatchObject([ + [0, 'a'], + [1, 'b'], + ]); + + expect(arrayIndexed([['buildTypeNames', ['Droid,Starship'], 'Droid;Starship;']])).toMatchObject([ + [0, 'buildTypeNames', ['Droid,Starship'], 'Droid;Starship;'], + ]); +}); +//#endregion + +//#region tests regexps against patterns +/** + * helper function that tests a RegExp against a list of patterns and returns true if the test passed, false otherwise + * + * @param regexpFor The RegExp to be tested + * @param patternIndex index of pattern in validPattern where the test is expected to pass + */ +const testRegexpAgainstPatterns = (regexpFor: RegExp, patternIndex: number) => { + describe.each(validPatterns)(`regexp.test(pattern): using regexp: '${regexpFor.source}'`, (index, pattern) => { + if (index === patternIndex) { + it(`passed: returned 'true' at index: ${index} when tested on pattern: ${pattern}`, () => { + expect(regexpFor.test(pattern)).toBe(true); + }); + } else { + it(`failed: returned 'false' at index: ${index} when tested on pattern: ${pattern}`, () => { + expect(regexpFor.test(pattern)).toBe(false); + }); + } + }); +}; +//#endregion + +//#endregion + +//#region global variables +const Droid = TypeName.fromString('Droid'); +const Starship = TypeName.fromString('Starship'); +const Human = TypeName.fromString('Human'); +const Movie = TypeName.fromString('Movie'); + +const id = FieldName.fromString('id'); +const name = FieldName.fromString('name'); +const friends = FieldName.fromString('friends'); +const friend = FieldName.fromString('friend'); +const title = FieldName.fromString('title'); +const episode = FieldName.fromString('episode'); +const length = FieldName.fromString('length'); + +const validPatterns = arrayIndexed([ + // [index, pattern] + ['Droid;Starship;'], + ['@*TypeNames;'], + ['@*TypeNames-[Droid,Starship];'], + ['Droid.[id,name,friends];Human.[id,name,title];Starship.[name,length];'], + ['Droid.@*FieldNames;Movie.@*FieldNames;'], + ['Droid.@*FieldNames-[id,name,friends];Human.@*FieldNames-[id,name,title];Starship.@*FieldNames-[name,length];'], + ['@*TypeNames.[id,name,friends];'], + ['@*TypeNames.@*FieldNames;'], + ['@*TypeNames.@*FieldNames-[id,name,friends];'], + ['@*TypeNames-[Droid,Human].[id,name,friends];'], + ['@*TypeNames-[Droid,Human].@*FieldNames;'], + ['@*TypeNames-[Droid,Human].@*FieldNames-[id,name,friends];'], +]); +//#endregion + +//#region integrity checks +describe('integrity checks: ensures that the following are not modified accidentally', () => { + // Hard coded for integrity purposes. Update this if more Regexp are added + const expectedTypeNamePatternCount = 3; + const expectedFieldNamePatternCount = 9; + const expectedCount = expectedTypeNamePatternCount + expectedFieldNamePatternCount; + + const definedTypeNamePatternBuilders = Object.getOwnPropertyNames(TypeNamePattern).filter( + method => method.startsWith('for') && typeof TypeNamePattern[method] === 'function' + ); + + const definedFieldNamePatternBuilders = Object.getOwnPropertyNames(FieldNamePattern).filter( + method => method.startsWith('for') && typeof FieldNamePattern[method] === 'function' + ); + + const definedBuilders = definedTypeNamePatternBuilders.concat(definedFieldNamePatternBuilders); + + const definedTypeNamePatternRegexps = Object.getOwnPropertyNames(TypeNamePattern).filter( + property => TypeNamePattern[property] instanceof RegExp + ); + + const definedFieldNamePatternRegexps = Object.getOwnPropertyNames(FieldNamePattern).filter( + property => FieldNamePattern[property] instanceof RegExp + ); + + const definedRegExps = definedTypeNamePatternRegexps.concat(definedFieldNamePatternRegexps); + + const definedTypeNamePatternMatchAndConfigureMethods = Object.getOwnPropertyNames(TypeNamePattern).filter( + method => method.startsWith('matchAndConfigure') && typeof TypeNamePattern[method] === 'function' + ); + + const definedFieldNamePatternMatchAndConfigureMethods = Object.getOwnPropertyNames(FieldNamePattern).filter( + method => method.startsWith('matchAndConfigure') && typeof FieldNamePattern[method] === 'function' + ); + + const definedMatchAndConfigureMethods = definedTypeNamePatternMatchAndConfigureMethods.concat( + definedFieldNamePatternMatchAndConfigureMethods + ); + + // hard-coded baseNames + const baseNames = [ + 'TypeNames', + 'AllTypeNames', + 'AllTypeNamesExcludeTypeNames', + 'FieldNamesOfTypeName', + 'AllFieldNamesOfTypeName', + 'AllFieldNamesExcludeFieldNamesOfTypeName', + 'FieldNamesOfAllTypeNames', + 'AllFieldNamesOfAllTypeNames', + 'AllFieldNamesExcludeFieldNamesOfAllTypeNames', + 'FieldNamesOfAllTypeNamesExcludeTypeNames', + 'AllFieldNamesOfAllTypeNamesExcludeTypeNames', + 'AllFieldNamesExcludeFieldNamesOfAllTypeNamesExcludeTypeNames', + ]; + // dynamically generated baseNames + const matchList: string[] = Pattern.getMatchList('Pattern'); + describe('pattern builders:', () => { + it(`all ${expectedCount} pattern builders are accounted for`, () => { + expect(definedBuilders.length).toBe(expectedCount); + }); + + it(`all ${expectedCount} pattern builders are defined in order`, () => { + expect(definedBuilders).toMatchObject(baseNames.map(baseName => `for${baseName}`)); + }); + }); + + describe('Regular Expressions:', () => { + it(`all ${expectedCount} Regular Expressions are accounted for`, () => { + expect(definedRegExps.length).toBe(expectedCount); + }); + + it(`all ${expectedCount} Regular Expression are defined in order`, () => { + expect(definedRegExps).toMatchObject(baseNames.map(baseName => `regexpFor${baseName}`)); + }); + }); + + describe('matchAndConfigure Methods:', () => { + it(`all ${expectedCount} matchAndConfigure methods are accounted for and defined in order`, () => { + expect(definedMatchAndConfigureMethods.length).toBe(expectedCount); + }); + + it(`all ${expectedCount} matchAndConfigure methods are defined in order`, () => { + expect(definedMatchAndConfigureMethods).toMatchObject(baseNames.map(baseName => `matchAndConfigure${baseName}`)); + }); + }); + + describe('baseNames(hard-coded) vrs matchList(dynamically-generated):', () => { + it('baseNames is equal to matchList', () => { + expect(baseNames).toMatchObject(matchList); + }); + }); + + describe(`TypeName, FieldName and Pattern: Value Objects that ensures that the value set is valid and can only be set using special methods that initialize the class with a valid value `, () => { + describe('Exception Throwers:', () => { + it('TypeName.fromString: throws when TypeName is created from an empty string', () => { + expect(() => TypeName.fromString('')).toThrow(); + }); + + it('FieldName.fromString: throws when FieldName is created from an empty string', () => { + expect(() => FieldName.fromString('')).toThrow(); + }); + + it('TypeNamePattern.forTypeNames: throws when it receives an empty array as parameter', () => { + expect(() => TypeNamePattern.forTypeNames([])).toThrow(); + }); + + it('TypeNamePattern.forAllTypeNamesExcludeTypeNames: throws when it receives an empty array as parameter', () => { + expect(() => TypeNamePattern.forAllTypeNamesExcludeTypeNames([])).toThrow(); + }); + + it('FieldPattern.forAllFieldNamesOfTypeName: throws when it receives an empty array as parameter', () => { + expect(() => FieldNamePattern.forAllFieldNamesOfTypeName([])).toThrow(); + }); + + it('FieldNamePattern.forAllFieldNamesExcludeFieldNamesOfTypeName: throws when it receives an empty array as parameter', () => { + expect(() => FieldNamePattern.forAllFieldNamesExcludeFieldNamesOfTypeName([])).toThrow(); + expect(() => FieldNamePattern.forAllFieldNamesExcludeFieldNamesOfTypeName([[Droid, []]])).toThrow(); + expect(() => FieldNamePattern.forAllFieldNamesExcludeFieldNamesOfTypeName([[[], id]])).toThrow(); + expect(() => FieldNamePattern.forAllFieldNamesExcludeFieldNamesOfTypeName([[[], []]])).toThrow(); + }); + + it('FieldNamePattern.forFieldNamesOfAllTypeNamesExcludeTypeNames: throws when it receives an empty array as parameter', () => { + expect(() => FieldNamePattern.forFieldNamesOfAllTypeNamesExcludeTypeNames([], [id])).toThrow(); + expect(() => FieldNamePattern.forFieldNamesOfAllTypeNamesExcludeTypeNames([Droid], [])).toThrow(); + expect(() => FieldNamePattern.forFieldNamesOfAllTypeNamesExcludeTypeNames([], [])).toThrow(); + }); + + it('FieldNamePattern.forAllFieldNamesOfAllTypeNamesExcludeTypeNames: throws when it receives an empty array as parameter', () => { + expect(() => FieldNamePattern.forAllFieldNamesOfAllTypeNamesExcludeTypeNames([])).toThrow(); + }); + + it('FieldNamePattern.forFieldNamesOfAllTypeNames: throws when it receives an empty array as parameter', () => { + expect(() => FieldNamePattern.forFieldNamesOfAllTypeNames([])).toThrow(); + }); + + it('FieldNamePattern.forFieldNamesOfTypeName: throws when it receives an empty array as parameter', () => { + expect(() => FieldNamePattern.forFieldNamesOfTypeName([])).toThrow(); + expect(() => FieldNamePattern.forFieldNamesOfTypeName([[Droid, []]])).toThrow(); + expect(() => FieldNamePattern.forFieldNamesOfTypeName([[[], id]])).toThrow(); + expect(() => FieldNamePattern.forFieldNamesOfTypeName([[[], []]])).toThrow(); + }); + + it('FieldNamePattern.forAllFieldNamesExcludeFieldNamesOfAllTypeNames: throws when it receives an empty array as parameter', () => { + expect(() => FieldNamePattern.forAllFieldNamesExcludeFieldNamesOfAllTypeNames([])).toThrow(); + }); + + it('FieldNamePattern.forAllFieldNamesExcludeFieldNamesOfAllTypeNamesExcludeTypeNames: throws when it receives an empty array as parameter', () => { + expect(() => + FieldNamePattern.forAllFieldNamesExcludeFieldNamesOfAllTypeNamesExcludeTypeNames([], [id]) + ).toThrow(); + expect(() => + FieldNamePattern.forAllFieldNamesExcludeFieldNamesOfAllTypeNamesExcludeTypeNames([Droid], []) + ).toThrow(); + expect(() => + FieldNamePattern.forAllFieldNamesExcludeFieldNamesOfAllTypeNamesExcludeTypeNames([], []) + ).toThrow(); + }); + }); + + describe.each([ + ',', + '.', + '/', + '<', + '>', + '?', + ':', + "'", + '"', + '[', + ']', + '{', + '}', + '\\', + '|', + '+', + '=', + '`', + '~', + '!', + '@', + '#', + '$', + '%', + '^', + '&', + '*', + '(', + ')', + // '_', // underscore is a valid character + '-', + '+', + '=', + 'space ', + 'tab\t', + 'newline\n', + ])('throws an error if value is not AlphaNumeric', invalidCharacter => { + const invalidName = `Invalid${invalidCharacter}Name`; + it(`TypeName and FieldName throws an error when the name contains: ${invalidCharacter}`, () => { + expect(() => TypeName.fromString(invalidName)).toThrow(); + expect(() => FieldName.fromString(invalidName)).toThrow(); + }); + }); + }); +}); +//#endregion + +//#region builders, RegExp and matchers + +//#region TypeNamePattern +//#region `'TypeName;AnotherTypeName;'` +describe('Configuring specific Graphql Types:', () => { + const [patternIndex, expectedPattern] = validPatterns[0]; + const pattern = TypeNamePattern.forTypeNames([Droid, Starship]); + + describe('Pattern.forTypeNames:', () => { + it('builds the expected pattern', () => { + expect(pattern.value).toBe(expectedPattern); + }); + }); + + describe('Pattern.regexpForTypeNames:', () => { + const regexpForTypeNames = TypeNamePattern.regexpForTypeNames; + + test('integrity check: the RegExp is not modified accidentally', () => { + expect(regexpForTypeNames.source).toBe(/\b(?!TypeNames|FieldNames\b)(?\w+;)/gim.source); + }); + + testRegexpAgainstPatterns(regexpForTypeNames, patternIndex); + }); + + describe(`Pattern.matchAndConfigureTypeNames: using pattern: '${expectedPattern}'`, () => { + describe.each([Droid, Starship])( + '%s will match and it will be configured because it was specified in the pattern', + typeName => { + const key = typeName.value; + const result = TypeNamePattern.matchAndConfigureTypeNames(pattern, typeName); + + it(`will match because '${typeName.value}' was specified in the pattern`, () => { + expect(result[key]?.matchFound).toBe(true); + }); + + it(`will be configured because '${typeName.value}' was specified in the pattern`, () => { + expect(result[key]?.shouldBeConfigured).toBe(true); + }); + } + ); + + describe.each([Human, Movie])( + '%s will not match neither will it be configured because it was not specified in the pattern', + typeName => { + const key = typeName.value; + const result = TypeNamePattern.matchAndConfigureTypeNames(pattern, typeName); + + it(`will not match because '${typeName.value}' was not specified in the pattern`, () => { + expect(result[key]?.matchFound).toBeUndefined(); + }); + + it(`will not be configured because '${typeName.value}' was not specified in the pattern`, () => { + expect(result[key]?.shouldBeConfigured).toBeUndefined(); + }); + } + ); + }); +}); +//#endregion + +//#region `'@*TypeNames;'` +describe('Configuring all Graphql Types:', () => { + const [patternIndex, expectedPattern] = validPatterns[1]; + const pattern = TypeNamePattern.forAllTypeNames(); + + describe('Patterns.forAllTypeNames:', () => { + it('builds the expected pattern', () => { + expect(pattern.value).toBe(expectedPattern); + }); + }); + + describe('Pattern.regexpForAllTypeNames:', () => { + const regexpForAllTypeNames = TypeNamePattern.regexpForAllTypeNames; + + test('integrity check: the RegExp is not modified accidentally', () => { + expect(regexpForAllTypeNames.source).toBe(/(?@\*TypeNames;)/gim.source); + }); + + testRegexpAgainstPatterns(regexpForAllTypeNames, patternIndex); + }); + + describe(`Patterns.matchAndConfigureAllTypeNames: using pattern: '${expectedPattern}'`, () => { + describe.each([Droid, Starship, Human, Movie])( + `will match and it will be configured: using pattern: '${expectedPattern}'`, + typeName => { + const key = typeName.value; + const result = TypeNamePattern.matchAndConfigureAllTypeNames(pattern, typeName); + + it(`will match because ${pattern} includes '${typeName.value}'`, () => { + expect(result[key]?.matchFound).toBe(true); + }); + + it(`will be configured because ${pattern} includes '${typeName.value}'`, () => { + expect(result[key]?.shouldBeConfigured).toBe(true); + }); + } + ); + }); +}); +//#endregion + +//#region `'@*TypeNames-[excludeTypeNames];'` +describe('Configuring all Graphql Types except those specified in the exclusion list of TypeNames:', () => { + const [patternIndex, expectedPattern] = validPatterns[2]; + const pattern = TypeNamePattern.forAllTypeNamesExcludeTypeNames([Droid, Starship]); + + describe('Pattern.forAllTypeNamesExcludeTypeNames:', () => { + it('builds the expected pattern', () => { + expect(pattern.value).toBe(expectedPattern); + }); + }); + + describe('Pattern.regexpForAllTypeNamesExcludeTypeNames:', () => { + const regexpForAllTypeNamesExcludeTypeNames = TypeNamePattern.regexpForAllTypeNamesExcludeTypeNames; + + test('integrity check: the RegExp is not modified accidentally', () => { + expect(regexpForAllTypeNamesExcludeTypeNames.source).toBe( + /@\*TypeNames-\[\s*(?(\w+,?\s*)*)\];/gim.source + ); + }); + + testRegexpAgainstPatterns(regexpForAllTypeNamesExcludeTypeNames, patternIndex); + }); + + describe(`Patterns.matchAndConfigureAllTypeNamesExcludeTypeNames: using pattern: '${expectedPattern}'`, () => { + describe.each([Droid, Starship])( + `%s will match but it will not be configured: using pattern: '${expectedPattern}'`, + typeName => { + const key = typeName.value; + const result = TypeNamePattern.matchAndConfigureAllTypeNamesExcludeTypeNames(pattern, typeName); + it(`will match because '${typeName.value}' was specified in the pattern`, () => { + expect(result[key]?.matchFound).toBe(true); + }); + + it(`will not be configured because the pattern excludes '${typeName.value}'`, () => { + expect(result[key]?.shouldBeConfigured).toBe(false); + }); + } + ); + + describe.each([Human, Movie])( + `%s will not match but it will be configured: using pattern: '${expectedPattern}'`, + typeName => { + const key = typeName.value; + const result = TypeNamePattern.matchAndConfigureAllTypeNamesExcludeTypeNames(pattern, typeName); + it(`will not match because '${typeName.value}' was not specified in the pattern`, () => { + expect(result[key]?.matchFound).toBe(false); + }); + + it(`will be configured because the pattern includes '${typeName.value}'`, () => { + expect(result[key]?.shouldBeConfigured).toBe(true); + }); + } + ); + }); +}); +//#endregion +//#endregion + +//#region FieldNamePattern +//#region `'TypeName.[fieldNames];'` +describe('Configuring specific fields of a specific Graphql Type:', () => { + const [patternIndex, expectedPattern] = validPatterns[3]; + + const pattern = FieldNamePattern.forFieldNamesOfTypeName([ + [Droid, [id, name, friends]], // individual + [Human, [id, name, title]], // individual + [Starship, [name, length]], // individual + ]); + + describe('Pattern.forFieldNamesOfTypeName:', () => { + describe('builds the expected pattern given an array where each element is a tuple allowing you to compose:', () => { + it('1. individually: where the first tuple element is the TypeName and the second contains a list of FieldNames of that TypeName', () => { + expect(pattern.value).toBe(expectedPattern); + }); + + it('2. shared: where the first tuple element is a list of TypeNames and the second is a list of FieldNames common to all the TypeNames in the first element', () => { + expect( + FieldNamePattern.forFieldNamesOfTypeName([ + [[Droid, Human], id], // shared + [[Droid, Human, Starship], [name]], // shared + [Starship, [length]], //shared,just with nobody + [Droid, friends], //shared,just with nobody + [Human, title], //shared,just with nobody + ]).value + ).toBe(expectedPattern); + }); + + it('3. combined: where the first tuple element is a single/list of TypeNames and the second is a list of FieldNames common to only/all of the TypeNames in the first element', () => { + expect( + FieldNamePattern.forFieldNamesOfTypeName([ + [Droid, [id, name, friends]], // individual + [Human, id], // individual + [[Human, Starship], [name]], // shared + [Human, [title]], // individual + [Starship, length], // individual + ]).value + ).toBe(expectedPattern); + }); + }); + }); + + describe('Pattern.regexpForFieldNamesOfTypeName:', () => { + const regexpForFieldNamesOfTypeName = FieldNamePattern.regexpForFieldNamesOfTypeName; + + test('integrity check: the RegExp is not modified accidentally', () => { + expect(regexpForFieldNamesOfTypeName.source).toBe( + /(?\w+\s*)(?(\w+,?\s*)*)\];/gim.source + ); + }); + + testRegexpAgainstPatterns(regexpForFieldNamesOfTypeName, patternIndex); + }); + + describe(`Pattern.matchAndConfigureFieldNamesOfTypeName: using pattern: '${expectedPattern}'`, () => { + describe.each([ + [Droid, id], + [Droid, name], + [Droid, friends], + + [Human, id], + [Human, name], + [Human, title], + + [Starship, name], + [Starship, length], + ])(`%s.%s will match and it will be configured: using pattern: '${expectedPattern}'`, (typeName, fieldName) => { + const key = `${typeName.value}.${fieldName.value}`; + const result = FieldNamePattern.matchAndConfigureFieldNamesOfTypeName(pattern, typeName, fieldName); + + it(`will match because when expanded, '${typeName.value}.${fieldName.value}' was specified in the pattern`, () => { + expect(result[key]?.matchFound).toBe(true); + }); + + it(`will be configured because when expanded, '${typeName.value}.${fieldName.value}' was specified in the pattern`, () => { + expect(result[key]?.shouldBeConfigured).toBe(true); + }); + }); + + describe.each([ + [Droid, friend], + [Droid, title], + [Droid, episode], + [Droid, length], + + [Starship, id], + [Starship, friends], + [Starship, friend], + [Starship, title], + [Starship, episode], + + [Human, friends], + [Human, friend], + [Human, episode], + [Human, length], + + [Movie, id], + [Movie, name], + [Movie, friends], + [Movie, friend], + [Movie, title], + [Movie, episode], + [Movie, length], + ])( + `%s.%s will not match neither will it be configured: using pattern: '${expectedPattern}'`, + (typeName, fieldName) => { + const key = `${typeName.value}.${fieldName.value}`; + const result = FieldNamePattern.matchAndConfigureFieldNamesOfTypeName(pattern, typeName, fieldName); + + it(`will not match because when expanded, '${typeName.value}.${fieldName.value}' was not specified in the pattern`, () => { + expect(result[key]?.matchFound).toBeUndefined(); + }); + + it(`will not be configured because when expanded, '${typeName.value}.${fieldName.value}' was not specified in the pattern`, () => { + expect(result[key]?.shouldBeConfigured).toBeUndefined(); + }); + } + ); + }); +}); +//#endregion + +//#region `'TypeName.@*FieldNames;'` +describe('Configuring all fields of a specific Graphql Type:', () => { + const [patternIndex, expectedPattern] = validPatterns[4]; + const pattern = FieldNamePattern.forAllFieldNamesOfTypeName([Droid, Movie]); + + describe('Pattern.forAllFieldNamesOfTypeName:', () => { + it('builds the expected pattern for each TypeName in a list of TypeNames:', () => { + expect(pattern.value).toBe(expectedPattern); + }); + }); + + describe('Pattern.regexpForAllFieldNamesOfTypeName:', () => { + const regexpForAllFieldNamesOfTypeName = FieldNamePattern.regexpForAllFieldNamesOfTypeName; + + test('integrity check: the RegExp is not modified accidentally', () => { + expect(regexpForAllFieldNamesOfTypeName.source).toBe( + /(?\w+\s*)(? { + describe.each([ + [Droid, id], + [Droid, name], + [Droid, friends], + [Droid, friend], + [Droid, title], + [Droid, episode], + [Droid, length], + + [Movie, id], + [Movie, name], + [Movie, friends], + [Movie, friend], + [Movie, title], + [Movie, episode], + [Movie, length], + ])(`%s will match and it will be configured: using pattern: '${expectedPattern}'`, (typeName, fieldName) => { + const key = `${typeName.value}.${fieldName.value}`; + const result = FieldNamePattern.matchAndConfigureAllFieldNamesOfTypeName(pattern, typeName, fieldName); + + it(`will match because '${typeName.value}' was specified in the pattern and the pattern includes '${typeName.value}.${fieldName.value}'`, () => { + expect(result[key]?.matchFound).toBe(true); + }); + + it(`will be configured because '${typeName.value}' was specified in the pattern and the pattern includes '${typeName.value}.${fieldName.value}'`, () => { + expect(result[key]?.shouldBeConfigured).toBe(true); + }); + }); + + describe.each([ + // Starship, Human + [Starship, id], + [Starship, name], + [Starship, friends], + [Starship, friend], + [Starship, title], + [Starship, episode], + [Starship, length], + + [Human, id], + [Human, name], + [Human, friends], + [Human, friend], + [Human, title], + [Human, episode], + [Human, length], + ])( + `%s will not match neither will it be configured: using pattern: '${expectedPattern}'`, + (typeName, fieldName) => { + const key = `${typeName.value}.${fieldName.value}`; + const result = FieldNamePattern.matchAndConfigureAllFieldNamesOfTypeName(pattern, typeName, fieldName); + + it(`will not match because '${typeName.value}' was not specified in the pattern`, () => { + expect(result[key]?.matchFound).toBeUndefined(); + }); + + it(`will not be configured because '${typeName.value}' was not specified in the pattern`, () => { + expect(result[key]?.shouldBeConfigured).toBeUndefined(); + }); + } + ); + }); +}); +//#endregion + +//#region `'TypeName.@*FieldNames-[excludeFieldNames];'` +describe('Configuring all fields except those specified in the exclusion list of FieldNames for a specific GraphQL Type:', () => { + const [patternIndex, expectedPattern] = validPatterns[5]; + const pattern = FieldNamePattern.forAllFieldNamesExcludeFieldNamesOfTypeName([ + [Droid, [id, name, friends]], // individual + [Human, [id, name, title]], // individual + [Starship, [name, length]], // individual + ]); + describe('Pattern.forAllFieldNamesExcludeFieldNamesOfTypeName:', () => { + describe('builds the expected pattern given an array where each element is a tuple allowing you to compose:', () => { + it('1. individually: where the first tuple element is the TypeName and the second contains a list of FieldNames of that TypeName', () => { + expect(pattern.value).toBe(expectedPattern); + }); + + it('2. shared: where the first tuple element is a list of TypeNames and the second is a list of FieldNames common to all the TypeNames in the first element', () => { + expect( + FieldNamePattern.forAllFieldNamesExcludeFieldNamesOfTypeName([ + [[Droid, Human], id], // shared + [[Droid, Human, Starship], [name]], // shared + [Starship, [length]], //shared,just with nobody + [Droid, friends], //shared,just with nobody + [Human, title], //shared,just with nobody + ]).value + ).toBe(expectedPattern); + }); + + it('3. combined: where the first tuple element is a single/list of TypeNames and the second is a list of FieldNames common to only/all of the TypeNames in the first element', () => { + expect( + FieldNamePattern.forAllFieldNamesExcludeFieldNamesOfTypeName([ + [Droid, [id, name, friends]], // individual + [Human, id], // individual + [[Human, Starship], [name]], // shared + [Human, [title]], // individual + [Starship, length], // individual + ]).value + ).toBe(expectedPattern); + }); + }); + }); + + describe('Pattern.regexpForAllFieldNamesExcludeFieldNamesOfTypeName:', () => { + const regexpForAllFieldNamesExcludeFieldNamesOfTypeName = + FieldNamePattern.regexpForAllFieldNamesExcludeFieldNamesOfTypeName; + + test('integrity check: the RegExp is not modified accidentally', () => { + expect(regexpForAllFieldNamesExcludeFieldNamesOfTypeName.source).toBe( + /(?\w+\s*)(?(\w+,?\s*)*)\];/gim.source + ); + }); + + testRegexpAgainstPatterns(regexpForAllFieldNamesExcludeFieldNamesOfTypeName, patternIndex); + }); + + describe(`Pattern.matchAndConfigureAllFieldNamesExcludeFieldNamesOfTypeName: using pattern: '${expectedPattern}'`, () => { + describe.each([ + [Droid, id], + [Droid, name], + [Droid, friends], + + [Human, id], + [Human, name], + [Human, title], + + [Starship, name], + [Starship, length], + ])(`%s.%s will match but it will not be configured: using pattern: '${expectedPattern}'`, (typeName, fieldName) => { + const key = `${typeName.value}.${fieldName.value}`; + const result = FieldNamePattern.matchAndConfigureAllFieldNamesExcludeFieldNamesOfTypeName( + pattern, + typeName, + fieldName + ); + it(`will match because when expanded, '${typeName.value}.${fieldName.value}' was specified in the pattern`, () => { + expect(result[key]?.matchFound).toBe(true); + }); + + it(`will not be because when expanded, the pattern excludes '${typeName.value}.${fieldName.value}'`, () => { + expect(result[key]?.shouldBeConfigured).toBe(false); + }); + }); + + describe.each([ + [Droid, friend], + [Droid, title], + [Droid, episode], + [Droid, length], + + [Starship, id], + [Starship, friends], + [Starship, friend], + [Starship, title], + [Starship, episode], + + [Human, friends], + [Human, friend], + [Human, episode], + [Human, length], + ])(`%s.%s will not match but it will be configured: using pattern: '${expectedPattern}'`, (typeName, fieldName) => { + const key = `${typeName.value}.${fieldName.value}`; + const result = FieldNamePattern.matchAndConfigureAllFieldNamesExcludeFieldNamesOfTypeName( + pattern, + typeName, + fieldName + ); + + it(`will not match because when expanded, '${typeName.value}.${fieldName.value}' was not specified in the pattern`, () => { + expect(result[key]?.matchFound).toBe(false); + }); + + it(`will be configured because when expanded, the pattern includes '${typeName.value}.${fieldName.value}'`, () => { + expect(result[key]?.shouldBeConfigured).toBe(true); + }); + }); + + describe.each([ + [Movie, id], + [Movie, name], + [Movie, friends], + [Movie, friend], + [Movie, title], + [Movie, episode], + [Movie, length], + ])( + `%s.%s will not match neither will it be configured using pattern: '${expectedPattern}'`, + (typeName, fieldName) => { + const key = `${typeName.value}.${fieldName.value}`; + const result = FieldNamePattern.matchAndConfigureAllFieldNamesExcludeFieldNamesOfTypeName( + pattern, + typeName, + fieldName + ); + + it(`will match because when expanded, '${typeName.value}.${fieldName.value}' was not specified in the pattern`, () => { + expect(result[key]?.matchFound).toBeUndefined(); + }); + + it(`will not be configured because when expanded, '${typeName.value}.${fieldName.value}' was not specified in the pattern`, () => { + expect(result[key]?.shouldBeConfigured).toBeUndefined(); + }); + } + ); + }); +}); +//#endregion + +//#region `'@*TypeNames.[fieldNames];'` +describe('Configuring specific fields for all Graphql Types:', () => { + const [patternIndex, expectedPattern] = validPatterns[6]; + const pattern = FieldNamePattern.forFieldNamesOfAllTypeNames([id, name, friends]); + + describe('Pattern.forFieldNamesOfAllTypeNames:', () => { + describe('builds the expected pattern for the list of FieldNames:', () => { + expect(pattern.value).toBe(expectedPattern); + }); + }); + + describe('Pattern.regexpForFieldNamesOfAllTypeNames:', () => { + const regexpForFieldNamesOfAllTypeNames = FieldNamePattern.regexpForFieldNamesOfAllTypeNames; + + test('integrity check: the RegExp is not modified accidentally', () => { + expect(regexpForFieldNamesOfAllTypeNames.source).toBe( + /@\*TypeNames\.\[\s*(?(\w+,?\s*)*)\];/gim.source + ); + }); + + testRegexpAgainstPatterns(regexpForFieldNamesOfAllTypeNames, patternIndex); + }); + + describe(`Pattern.matchAndConfigureFieldNamesOfAllTypeNames: using pattern: '${expectedPattern}'`, () => { + describe.each([ + [Droid, id], + [Droid, name], + [Droid, friends], + + [Starship, id], + [Starship, name], + [Starship, friends], + + [Human, id], + [Human, name], + [Human, friends], + + [Movie, id], + [Movie, name], + [Movie, friends], + ])(`%s.%s will match and it will be configured: using pattern: '${expectedPattern}'`, (typeName, fieldName) => { + const key = `${typeName.value}.${fieldName.value}`; + const result = FieldNamePattern.matchAndConfigureFieldNamesOfAllTypeNames(pattern, typeName, fieldName); + + it(`will match because '${fieldName.value}' was specified in the pattern`, () => { + expect(result[key]?.matchFound).toBe(true); + }); + + it(`will be configured because '${fieldName.value}' was specified in the pattern`, () => { + expect(result[key]?.shouldBeConfigured).toBe(true); + }); + }); + + describe.each([ + [Droid, friend], + [Droid, title], + [Droid, episode], + [Droid, length], + + [Starship, friend], + [Starship, title], + [Starship, episode], + [Starship, length], + + [Human, friend], + [Human, title], + [Human, episode], + [Human, length], + + [Movie, friend], + [Movie, title], + [Movie, episode], + [Movie, length], + ])( + `%s will not match neither will it be configured: using pattern: '${expectedPattern}'`, + (typeName, fieldName) => { + const key = `${typeName.value}.${fieldName.value}`; + const result = FieldNamePattern.matchAndConfigureFieldNamesOfAllTypeNames(pattern, typeName, fieldName); + + it(`will not match because '${fieldName.value}' was not specified in the pattern`, () => { + expect(result[key]?.matchFound).toBeUndefined(); + }); + + it(`will not not be configured because '${fieldName.value}' was not specified in the pattern`, () => { + expect(result[key]?.shouldBeConfigured).toBeUndefined(); + }); + } + ); + }); +}); +//#endregion + +//#region `'@*TypeNames.@*FieldNames;'` +describe('Configuring all fields for all Graphql Types:', () => { + const [patternIndex, expectedPattern] = validPatterns[7]; + const pattern = FieldNamePattern.forAllFieldNamesOfAllTypeNames(); + + describe('Pattern.forAllFieldNamesOfAllTypeNames:', () => { + describe('builds the expected pattern:', () => { + expect(pattern.value).toBe(expectedPattern); + }); + }); + + describe('Pattern.regexpForAllFieldNamesOfAllTypeNames:', () => { + const regexpForAllFieldNamesOfAllTypeNames = FieldNamePattern.regexpForAllFieldNamesOfAllTypeNames; + + test('integrity check: the RegExp is not modified accidentally', () => { + expect(regexpForAllFieldNamesOfAllTypeNames.source).toBe(/@\*TypeNames\.@\*FieldNames;/gim.source); + }); + + testRegexpAgainstPatterns(regexpForAllFieldNamesOfAllTypeNames, patternIndex); + }); + + describe(`Pattern.matchAndConfigureAllFieldNamesOfAllTypeNames: using pattern: '${expectedPattern}'`, () => { + describe.each([ + [Droid, id], + [Droid, name], + [Droid, friends], + [Droid, friend], + [Droid, title], + [Droid, episode], + [Droid, length], + + [Starship, id], + [Starship, name], + [Starship, friends], + [Starship, friend], + [Starship, title], + [Starship, episode], + [Starship, length], + + [Human, id], + [Human, name], + [Human, friends], + [Human, friend], + [Human, title], + [Human, episode], + [Human, length], + + [Movie, id], + [Movie, name], + [Movie, friends], + [Movie, friend], + [Movie, title], + [Movie, episode], + [Movie, length], + ])(`will match and it will be configured: using pattern: '${expectedPattern}'`, (typeName, fieldName) => { + const key = `${typeName.value}.${fieldName.value}`; + const result = FieldNamePattern.matchAndConfigureAllFieldNamesOfAllTypeNames(pattern, typeName, fieldName); + + it(`will match because ${pattern} includes '${typeName.value}.${fieldName.value}'`, () => { + expect(result[key]?.matchFound).toBe(true); + }); + + it(`will be configured because ${pattern} includes '${typeName.value}.${fieldName.value}'`, () => { + expect(result[key]?.shouldBeConfigured).toBe(true); + }); + }); + }); +}); +//#endregion + +//#region `'@*TypeNames.@*FieldNames-[excludeFieldNames];'` +describe('Configuring all fields except those specified in the exclusion list of FieldNames for all GraphQL Types:', () => { + const [patternIndex, expectedPattern] = validPatterns[8]; + const pattern = FieldNamePattern.forAllFieldNamesExcludeFieldNamesOfAllTypeNames([id, name, friends]); + + describe('Pattern.forAllFieldNamesExcludeFieldNamesOfAllTypeNames:', () => { + describe('builds the expected pattern:', () => { + expect(pattern.value).toBe(expectedPattern); + }); + }); + + describe('Pattern.regexpForAllFieldNamesExcludeFieldNamesOfAllTypeNames:', () => { + const regexpForAllFieldNamesExcludeFieldNamesOfAllTypeNames = + FieldNamePattern.regexpForAllFieldNamesExcludeFieldNamesOfAllTypeNames; + + test('integrity check: the RegExp is not modified accidentally', () => { + expect(regexpForAllFieldNamesExcludeFieldNamesOfAllTypeNames.source).toBe( + /@\*TypeNames\.@\*FieldNames-\[\s*(?(\w+,?\s*)*)\];/gim.source + ); + }); + + testRegexpAgainstPatterns(regexpForAllFieldNamesExcludeFieldNamesOfAllTypeNames, patternIndex); + }); + + describe(`Pattern.matchAndConfigureAllFieldNamesExcludeFieldNamesOfAllTypeNames: using pattern: '${expectedPattern}'`, () => { + describe.each([ + [Droid, id], + [Droid, name], + [Droid, friends], + + [Starship, id], + [Starship, name], + [Starship, friends], + + [Human, id], + [Human, name], + [Human, friends], + + [Movie, id], + [Movie, name], + [Movie, friends], + ])(`%s.%s will match but it will not be configured: using pattern: '${expectedPattern}'`, (typeName, fieldName) => { + const key = `${typeName.value}.${fieldName.value}`; + const result = FieldNamePattern.matchAndConfigureAllFieldNamesExcludeFieldNamesOfAllTypeNames( + pattern, + typeName, + fieldName + ); + + it(`it will match because '${fieldName.value}' was specified in the exclusion list of FieldNames`, () => { + expect(result[key]?.matchFound).toBe(true); + }); + + it(`it will not be configured because the pattern excludes '${fieldName.value}'`, () => { + expect(result[key]?.shouldBeConfigured).toBe(false); + }); + }); + + describe.each([ + [Droid, friend], + [Droid, title], + [Droid, episode], + [Droid, length], + + [Starship, friend], + [Starship, title], + [Starship, episode], + [Starship, length], + + [Human, friend], + [Human, title], + [Human, episode], + [Human, length], + + [Movie, friend], + [Movie, title], + [Movie, episode], + [Movie, length], + ])( + '%s will not match but it will be configured because it was not specified in the exclusion list of FieldNames', + (typeName, fieldName) => { + const key = `${typeName.value}.${fieldName.value}`; + const result = FieldNamePattern.matchAndConfigureAllFieldNamesExcludeFieldNamesOfAllTypeNames( + pattern, + typeName, + fieldName + ); + + it(`it will not match because '${fieldName.value}' was not specified in the pattern`, () => { + expect(result[key]?.matchFound).toBe(false); + }); + + it(`it will be configured because the pattern includes '${fieldName.value}'`, () => { + expect(result[key]?.shouldBeConfigured).toBe(true); + }); + } + ); + }); +}); +//#endregion + +//#region `'@*TypeNames-[excludeTypeNames].[fieldNames];'` +describe('Configuring specific fields of all GraphQL Types except those specified in the exclusion list of TypeNames:', () => { + const [patternIndex, expectedPattern] = validPatterns[9]; + const pattern = FieldNamePattern.forFieldNamesOfAllTypeNamesExcludeTypeNames([Droid, Human], [id, name, friends]); + + describe('Pattern.forFieldNamesOfAllTypeNamesExcludeTypeNames:', () => { + describe('builds the expected pattern for the list of FieldNames:', () => { + expect(pattern.value).toBe(expectedPattern); + }); + }); + + describe('Pattern.regexpForFieldNamesOfAllTypeNamesExcludeTypeNames:', () => { + const regexpForFieldNamesOfAllTypeNamesExcludeTypeNames = + FieldNamePattern.regexpForFieldNamesOfAllTypeNamesExcludeTypeNames; + + test('integrity check: the RegExp is not modified accidentally', () => { + expect(regexpForFieldNamesOfAllTypeNamesExcludeTypeNames.source).toBe( + /@\*TypeNames-\[\s*(?(\w+,?\s*)*)\]\.\[\s*(?(\w+,?\s*)*)\];/gim.source + ); + }); + + testRegexpAgainstPatterns(regexpForFieldNamesOfAllTypeNamesExcludeTypeNames, patternIndex); + }); + + describe(`Pattern.matchAndConfigureFieldNamesOfAllTypeNamesExcludeTypeNames: using pattern: '${expectedPattern}'`, () => { + describe.each([ + [Droid, id], + [Droid, name], + [Droid, friends], + + [Human, id], + [Human, name], + [Human, friends], + ])(`%s.%s will match but it will not be configured: using pattern: '${pattern}'`, (typeName, fieldName) => { + const key = `${typeName.value}.${fieldName.value}`; + const result = FieldNamePattern.matchAndConfigureFieldNamesOfAllTypeNamesExcludeTypeNames( + pattern, + typeName, + fieldName + ); + + it(`will match because when expanded, '${typeName.value}.${fieldName.value}' was specified in the pattern`, () => { + expect(result[key]?.matchFound).toBe(true); + }); + + it(`will not be configured because when expanded, the pattern excludes '${typeName.value}.${fieldName.value}'`, () => { + expect(result[key]?.shouldBeConfigured).toBe(false); + }); + }); + + describe.each([ + [Starship, id], + [Starship, name], + [Starship, friends], + + [Movie, id], + [Movie, name], + [Movie, friends], + ])( + `%s.%s will not match but it will it be configured: using pattern: '${expectedPattern}'`, + (typeName, fieldName) => { + const key = `${typeName.value}.${fieldName.value}`; + const result = FieldNamePattern.matchAndConfigureFieldNamesOfAllTypeNamesExcludeTypeNames( + pattern, + typeName, + fieldName + ); + + it(`will not match because when expanded, '${typeName.value}.${fieldName.value}' was not specified in the pattern`, () => { + expect(result[key]?.matchFound).toBe(false); + }); + + it(`will be configured because when expanded, the pattern includes '${typeName.value}.${fieldName.value}'`, () => { + expect(result[key]?.shouldBeConfigured).toBe(true); + }); + } + ); + + describe.each([ + [Droid, friend], + [Droid, title], + [Droid, episode], + [Droid, length], + + [Starship, friend], + [Starship, title], + [Starship, episode], + [Starship, length], + + [Human, friend], + [Human, title], + [Human, episode], + [Human, length], + + [Movie, friend], + [Movie, title], + [Movie, episode], + [Movie, length], + ])( + `%s.%s will not match neither will it be configured: using pattern: '${expectedPattern}'`, + (typeName, fieldName) => { + const key = `${typeName.value}.${fieldName.value}`; + const result = FieldNamePattern.matchAndConfigureFieldNamesOfAllTypeNamesExcludeTypeNames( + pattern, + typeName, + fieldName + ); + + it(`will not match because when expanded, '${typeName.value}.${fieldName.value}' was not specified in the pattern`, () => { + expect(result[key]?.matchFound).toBeUndefined(); + }); + + it(`will be not configured because when expanded, '${typeName.value}.${fieldName.value}' was not specified in the pattern`, () => { + expect(result[key]?.shouldBeConfigured).toBeUndefined(); + }); + } + ); + }); +}); +//#endregion + +//#region `'@*TypeNames-[excludeTypeNames].@*FieldNames;'` +describe('Configuring all fields of all GraphQL Types except those specified in the exclusion list of TypeNames:', () => { + const [patternIndex, expectedPattern] = validPatterns[10]; + const pattern = FieldNamePattern.forAllFieldNamesOfAllTypeNamesExcludeTypeNames([Droid, Human]); + + describe('Pattern.forAllFieldNamesExcludeFieldNamesOfAllTypeNamesExcludeTypeNames:', () => { + describe('builds the expected pattern for the list of FieldNames:', () => { + expect(pattern.value).toBe(expectedPattern); + }); + }); + + describe('Pattern.regexpForAllFieldNamesOfAllTypeNamesExcludeTypeNames:', () => { + const regexpForAllFieldNamesOfAllTypeNamesExcludeTypeNames = + FieldNamePattern.regexpForAllFieldNamesOfAllTypeNamesExcludeTypeNames; + + test('integrity check: the RegExp is not modified accidentally', () => { + expect(regexpForAllFieldNamesOfAllTypeNamesExcludeTypeNames.source).toBe( + /@\*TypeNames-\[\s*(?(\w+,?\s*)*)\]\.@\*FieldNames;/gim.source + ); + }); + + testRegexpAgainstPatterns(regexpForAllFieldNamesOfAllTypeNamesExcludeTypeNames, patternIndex); + }); + + describe(`Pattern.matchAndConfigureAllFieldNamesOfAllTypeNamesExcludeTypeNames: using pattern: '${expectedPattern}'`, () => { + describe.each([ + [Droid, id], + [Droid, name], + [Droid, friends], + [Droid, friend], + [Droid, title], + [Droid, episode], + [Droid, length], + + [Human, id], + [Human, name], + [Human, friends], + [Human, friend], + [Human, title], + [Human, episode], + [Human, length], + ])(`%s will match but it will not be configured: using pattern: '${expectedPattern}'`, (typeName, fieldName) => { + const key = `${typeName.value}.${fieldName.value}`; + const result = FieldNamePattern.matchAndConfigureAllFieldNamesOfAllTypeNamesExcludeTypeNames( + pattern, + typeName, + fieldName + ); + + it(`will match because '${typeName.value}' was specified in the pattern`, () => { + expect(result[key]?.matchFound).toBe(true); + }); + + it(`will not be configured match because the pattern excludes '${typeName.value}'`, () => { + expect(result[key]?.shouldBeConfigured).toBe(false); + }); + }); + + describe.each([ + [Starship, id], + [Starship, name], + [Starship, friends], + [Starship, friend], + [Starship, title], + [Starship, episode], + [Starship, length], + + [Movie, id], + [Movie, name], + [Movie, friends], + [Movie, friend], + [Movie, title], + [Movie, episode], + [Movie, length], + ])(`%s will not match but it will be configured: using pattern: '${expectedPattern}'`, (typeName, fieldName) => { + const key = `${typeName.value}.${fieldName.value}`; + const result = FieldNamePattern.matchAndConfigureAllFieldNamesOfAllTypeNamesExcludeTypeNames( + pattern, + typeName, + fieldName + ); + + it(`will not match because '${typeName.value}' was not specified in the pattern`, () => { + expect(result[key]?.matchFound).toBe(false); + }); + + it(`will be configured match because the pattern includes '${typeName.value}'`, () => { + expect(result[key]?.shouldBeConfigured).toBe(true); + }); + }); + }); +}); +//#endregion + +//#region `'@*TypeNames-[excludeTypeNames].@*FieldNames-[excludeFieldNames];'` +describe('Configuring all fields except those specified in the exclusion list of FieldNames of all GraphQL Types except those specified in the exclusion list of TypeNames:', () => { + const [patternIndex, expectedPattern] = validPatterns[11]; + const pattern = FieldNamePattern.forAllFieldNamesExcludeFieldNamesOfAllTypeNamesExcludeTypeNames( + [Droid, Human], + [id, name, friends] + ); + + describe('Pattern.forAllFieldNamesExcludeFieldNamesOfAllTypeNamesExcludeTypeNames:', () => { + describe('builds the expected pattern for the list of FieldNames:', () => { + expect(pattern.value).toBe(expectedPattern); + }); + }); + + describe('Pattern.regexpForAllFieldNamesExcludeFieldNamesOfAllTypeNamesExcludeTypeNames:', () => { + const regexpForAllFieldNamesExcludeFieldNamesOfAllTypeNamesExcludeTypeNames = + FieldNamePattern.regexpForAllFieldNamesExcludeFieldNamesOfAllTypeNamesExcludeTypeNames; + + test('integrity check: the RegExp is not modified accidentally', () => { + expect(regexpForAllFieldNamesExcludeFieldNamesOfAllTypeNamesExcludeTypeNames.source).toBe( + /@\*TypeNames-\[\s*(?(\w+,?\s*)*)\]\.@\*FieldNames-\[\s*(?(\w+,?\s*)*)\];/gim.source + ); + }); + + testRegexpAgainstPatterns(regexpForAllFieldNamesExcludeFieldNamesOfAllTypeNamesExcludeTypeNames, patternIndex); + }); + + describe(`Pattern.matchAndConfigureAllFieldNamesExcludeFieldNamesOfAllTypeNamesExcludeTypeNames: using pattern: '${expectedPattern}'`, () => { + describe.each([ + [Droid, id], + [Droid, name], + [Droid, friends], + + [Human, id], + [Human, name], + [Human, friends], + ])(`%s.%s will match but it will not be configured: using pattern: '${expectedPattern}'`, (typeName, fieldName) => { + const key = `${typeName.value}.${fieldName.value}`; + const result = FieldNamePattern.matchAndConfigureAllFieldNamesExcludeFieldNamesOfAllTypeNamesExcludeTypeNames( + pattern, + typeName, + fieldName + ); + + it(`will match because when expanded, '${typeName.value}.${fieldName.value}' was specified in the pattern`, () => { + expect(result[key]?.matchFound).toBe(true); + }); + + it(`will not be configured because when expanded, the pattern excludes '${typeName.value}.${fieldName.value}'`, () => { + expect(result[key]?.shouldBeConfigured).toBe(false); + }); + }); + + describe.each([ + [Droid, friend], + [Droid, title], + [Droid, episode], + [Droid, length], + + [Starship, id], + [Starship, name], + [Starship, friends], + [Starship, friend], + [Starship, title], + [Starship, episode], + [Starship, length], + + [Human, friend], + [Human, title], + [Human, episode], + [Human, length], + + [Movie, id], + [Movie, name], + [Movie, friends], + [Movie, friend], + [Movie, title], + [Movie, episode], + [Movie, length], + ])(`%s.%s will not match but it will be configured: using pattern: ${expectedPattern}`, (typeName, fieldName) => { + const key = `${typeName.value}.${fieldName.value}`; + const result = FieldNamePattern.matchAndConfigureAllFieldNamesExcludeFieldNamesOfAllTypeNamesExcludeTypeNames( + pattern, + typeName, + fieldName + ); + + it(`will not match because when expanded, '${typeName.value}.${fieldName.value}' was not specified in the pattern`, () => { + expect(result[key]?.matchFound).toBe(false); + }); + + it(`will be configured because when expanded, the pattern includes '${typeName.value}.${fieldName.value}'`, () => { + expect(result[key]?.shouldBeConfigured).toBe(true); + }); + }); + }); +}); +//#endregion +//#endregion + +//#endregion + +//#region attemptMatchAndConfigure +describe('attemptMatchAndConfigure: runs through the matchList and attempt to match and configure a TypeName and/or a FieldName using a pattern', () => { + it('will return the result of Pattern.matchAndConfigureAllFieldNamesExcludeFieldNamesOfAllTypeNames:', () => { + const pattern = FieldNamePattern.forAllFieldNamesExcludeFieldNamesOfAllTypeNames([id, name, friends]); + expect(Pattern.attemptMatchAndConfigure(pattern, Droid, id)).toMatchObject( + FieldNamePattern.matchAndConfigureAllFieldNamesExcludeFieldNamesOfAllTypeNames(pattern, Droid, id) + ); + }); + + it('will throw an error if the pattern contains multiple patterns', () => { + const pattern = TypeNamePattern.forTypeNames([Droid, Human]); + expect(() => Pattern.attemptMatchAndConfigure(pattern, Droid)).toThrow(); + }); + + it('will return undefined if the RegExp.test(pattern) fails meaning that the Pattern is not valid:', () => { + const invalidPattern = { value: '@*TypeName' } as Pattern; //TypeNames not TypeName + expect(Pattern.attemptMatchAndConfigure(invalidPattern, Droid, id)).toBeUndefined(); + }); +}); +//#endregion + +//#region helper methods +describe('Pattern helper methods:', () => { + const pattern1 = TypeNamePattern.forTypeNames([Droid, Movie]); + const pattern2 = FieldNamePattern.forAllFieldNamesExcludeFieldNamesOfAllTypeNames([id, title]); + const expected = { value: pattern1.value + pattern2.value } as Pattern; + describe('Pattern.compose: takes a list of Patterns and joins them into one single valid pattern:', () => { + it('throws an error if an empty array is passed as a parameter', () => { + expect(() => Pattern.compose([])).toThrow(); + }); + + it('returns a new valid pattern', () => { + console.log(expected.value); + expect(Pattern.compose([pattern1, pattern2]).value).toBe(expected.value); + }); + }); + + describe('Pattern.split: splits a pattern into individual patterns', () => { + it('returns a list of patterns', () => { + expect(Pattern.split(expected)).toMatchObject([ + TypeNamePattern.forTypeNames(Droid), + TypeNamePattern.forTypeNames(Movie), + pattern2, + ]); + }); + }); +}); +//#endregion diff --git a/packages/plugins/dart/flutter-freezed/tests/plugin.spec.ts b/packages/plugins/dart/flutter-freezed/tests/plugin.spec.ts index 553d80fb9..1551c73f7 100644 --- a/packages/plugins/dart/flutter-freezed/tests/plugin.spec.ts +++ b/packages/plugins/dart/flutter-freezed/tests/plugin.spec.ts @@ -1,284 +1,770 @@ -import { plugin } from '../src'; -import { fullDemoConfig } from './config'; -import { fullSchema } from './schema'; - -/** plugin test */ -describe('flutter-freezed: plugin config', () => { - test('full plugin test: expect generated code to be as configured', () => { - const result = plugin(fullSchema, [], fullDemoConfig); - - expect(result).toBe(`import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:flutter/foundation.dart'; - -part app_models.dart; -part 'app_models.g.dart'; - -enum Episode{ - @JsonKey(name: NEWHOPE) newhope - @JsonKey(name: EMPIRE) empire - @JsonKey(name: JEDI) jedi -} - -@freezed -@JsonSerializable(explicitToJson: true) -class Movie with _$Movie { - const Movie._(); - - const factory Movie({ - required String id, - required String title, - }) = _Movie; - - const factory Movie.createInput({ - required String title, - }) = CreateMovieInput; - - const factory Movie.upsertInput({ - required String id, - required String title, - }) = UpsertMovieInput; - - const factory Movie.deleteInput({ - required String id, - }) = DeleteMovieInput; - - factory Movie.fromJson(Map json) => _MovieFromJson(json); -} - -@unfreezed -@JsonSerializable(explicitToJson: true) -class CreateMovieInput with _$CreateMovieInput { - const CreateMovieInput._(); - - const factory CreateMovieInput.createInput({ - required String title, - }) = CreateMovieInput; - - factory CreateMovieInput.fromJson(Map json) => _CreateMovieInputFromJson(json); -} - -@unfreezed -@JsonSerializable(explicitToJson: true) -class UpsertMovieInput with _$UpsertMovieInput { - const UpsertMovieInput._(); - - const factory UpsertMovieInput.upsertInput({ - required String id, - required String title, - }) = UpsertMovieInput; - - factory UpsertMovieInput.fromJson(Map json) => _UpsertMovieInputFromJson(json); -} - -@unfreezed -@JsonSerializable(explicitToJson: true) -class UpdateMovieInput with _$UpdateMovieInput { - const UpdateMovieInput._(); - - const factory UpdateMovieInput({ - required String id, - String? title, - }) = _UpdateMovieInput; - - factory UpdateMovieInput.fromJson(Map json) => _UpdateMovieInputFromJson(json); -} - -@unfreezed -@JsonSerializable(explicitToJson: true) -class DeleteMovieInput with _$DeleteMovieInput { - const DeleteMovieInput._(); - - const factory DeleteMovieInput.deleteInput({ - required String id, - }) = DeleteMovieInput; - - factory DeleteMovieInput.fromJson(Map json) => _DeleteMovieInputFromJson(json); -} - -@Freezed( - copyWith: false, - equal: false, - unionValueCase: 'FreezedUnionCase.pascal', -) -@JsonSerializable(explicitToJson: true) -class Starship with _$Starship { - const factory Starship({ - @JsonKey(name: 'id') - required String id, - @JsonKey(name: 'name') - required String name, - @JsonKey(name: 'length') - double? length, - }) = _Starship; - - factory Starship.fromJson(Map json) => _StarshipFromJson(json); -} - -@freezed -@JsonSerializable(explicitToJson: true) -class MovieCharacter with _$MovieCharacter { - const MovieCharacter._(); - - const factory MovieCharacter({ - required String name, - required List appearsIn, - }) = _MovieCharacter; - - factory MovieCharacter.fromJson(Map json) => _MovieCharacterFromJson(json); -} - -@freezed -@JsonSerializable(explicitToJson: true) -class Human with _$Human { - const Human._(); - - const factory Human({ - required String id, - required String name, - List? friends, - required List appearsIn, - List? starships, - int? totalCredits, - }) = _Human; - - factory Human.fromJson(Map json) => _HumanFromJson(json); -} - -@unfreezed -@JsonSerializable(explicitToJson: true) -class Droid with _$Droid { - const Droid._(); - - factory Droid({ - required String id, - required String name, - List? friends, - required List appearsIn, - String? primaryFunction, - }) = _Droid; - -} - -@freezed -@JsonSerializable(explicitToJson: true) -class SearchResult with _$SearchResult { - const SearchResult._(); - - const factory SearchResult() = _SearchResult; - - const factory SearchResult.human({ - required String id, - required String name, - List? friends, - required List appearsIn, - List? starships, - int? totalCredits, - }) = Human; - - @FreezedUnionValue('BestDroid') - factory SearchResult.droid({ - @NanoId(size: 16, alphabets: NanoId.ALPHA_NUMERIC) - required String id, - required String name, - List? friends, - required List appearsIn, - String? primaryFunction, - }) = Droid; - - const factory SearchResult.starship({ - @JsonKey(name: 'id') - required String id, - @JsonKey(name: 'name') - required String name, - @JsonKey(name: 'length') - double? length, - }) = Starship; - - factory SearchResult.fromJson(Map json) => _SearchResultFromJson(json); -} - -@freezed -@JsonSerializable(explicitToJson: true) -class ComplexType with _$ComplexType { - const ComplexType._(); - - const factory ComplexType({ - List? a, - List? b, - required List c, - List?>? d, - List>? e, - required List> f, - Map? g, - required DateTime h, - required String i, - }) = _ComplexType; - - factory ComplexType.fromJson(Map json) => _ComplexTypeFromJson(json); -} - -@unfreezed -@JsonSerializable(explicitToJson: true) -class BaseAInput with _$BaseAInput { - const BaseAInput._(); - - const factory BaseAInput({ - required BaseBInput b, - }) = _BaseAInput; - - factory BaseAInput.fromJson(Map json) => _BaseAInputFromJson(json); -} - -@unfreezed -@JsonSerializable(explicitToJson: true) -class BaseBInput with _$BaseBInput { - const BaseBInput._(); - - const factory BaseBInput({ - required BaseCInput c, - }) = _BaseBInput; - - factory BaseBInput.fromJson(Map json) => _BaseBInputFromJson(json); -} - -@unfreezed -@JsonSerializable(explicitToJson: true) -class BaseCInput with _$BaseCInput { - const BaseCInput._(); - - const factory BaseCInput({ - required BaseAInput a, - }) = _BaseCInput; - - factory BaseCInput.fromJson(Map json) => _BaseCInputFromJson(json); -} - -@freezed -@JsonSerializable(explicitToJson: true) -class Base with _$Base { - const Base._(); - - const factory Base({ - String? id, - }) = _Base; - - const factory Base.aInput({ - required BaseBInput b, - }) = BaseAInput; - - const factory Base.bInput({ - required BaseCInput c, - }) = BaseBInput; - - const factory Base.cInput({ - required BaseAInput a, - }) = BaseCInput; - - const factory Base.createMovieInput({ - required String title, - }) = CreateMovieInput; - - factory Base.fromJson(Map json) => _BaseFromJson(json); -}`); +import { plugin } from '../src/index.js'; +import { + cyclicSchema, + enumSchema, + escapedSchema, + mergeSchema, + nonNullableListWithCustomScalars, + simpleSchema, + unionSchema, +} from './schema.js'; +import { Config } from '../src/config/config-value.js'; +import { FieldName, FieldNamePattern, TypeName, TypeNamePattern } from '../src/config/pattern.js'; + +const Droid = TypeName.fromString('Droid'); +// const Starship = TypeName.fromString('Starship'); +const Human = TypeName.fromString('Human'); +// const Movie = TypeName.fromString('Movie'); +const Actor = TypeName.fromString('Actor'); +const SearchResult = TypeName.fromString('SearchResult'); +const SearchResultDroid = TypeName.fromUnionOfTypeNames(SearchResult, Droid); + +const id = FieldName.fromString('id'); +const name = FieldName.fromString('name'); +const friends = FieldName.fromString('friends'); +const appearsIn = FieldName.fromString('appearsIn'); +// const title = FieldName.fromString('title'); +// const episode = FieldName.fromString('episode'); +// const length = FieldName.fromString('length'); + +describe('The Flutter Freezed plugin produces Freezed models using a GraphQL Schema:', () => { + describe('Enum Block: will generate a valid Enum block', () => { + it('using the default plugin configuration: Enum values are camelCased and values that are keywords are escaped by suffixing the value with an `_`', () => { + const output = plugin(enumSchema, [], Config.create()); + expect(output).toMatchInlineSnapshot(` + "import 'package:freezed_annotation/freezed_annotation.dart'; + import 'package:flutter/foundation.dart'; + + part 'app_models.freezed.dart'; + part 'app_models.g.dart'; + + enum Episode { + @JsonKey(name: 'NEWHOPE') + newhope + @JsonKey(name: 'EMPIRE') + empire + @JsonKey(name: 'JEDI') + jedi + @JsonKey(name: 'VOID') + void_ + @JsonKey(name: 'void') + void_ + @JsonKey(name: 'IN') + in_ + @JsonKey(name: 'in') + in_ + @JsonKey(name: 'String') + string + @JsonKey(name: 'ELSE') + else_ + @JsonKey(name: 'else') + else_ + @JsonKey(name: 'SWITCH') + switch_ + @JsonKey(name: 'switch') + switch_ + @JsonKey(name: 'FACTORY') + factory_ + @JsonKey(name: 'factory') + factory_ + }" + `); + }); + + it('when config.camelCasedEnums === undefined: original casing is preserved, keywords are escaped', () => { + expect(plugin(enumSchema, [], Config.create({ camelCasedEnums: undefined }))).toMatchInlineSnapshot(` + "import 'package:freezed_annotation/freezed_annotation.dart'; + import 'package:flutter/foundation.dart'; + + part 'app_models.freezed.dart'; + part 'app_models.g.dart'; + + enum Episode { + NEWHOPE + EMPIRE + JEDI + VOID + @JsonKey(name: 'void') + void_ + IN + @JsonKey(name: 'in') + in_ + @JsonKey(name: 'String') + String_ + ELSE + @JsonKey(name: 'else') + else_ + SWITCH + @JsonKey(name: 'switch') + switch_ + FACTORY + @JsonKey(name: 'factory') + factory_ + }" + `); + }); + + it('when config.camelCasedEnums === DartIdentifierCasing: Enum values are cased as configured, keywords are escaped', () => { + const output = plugin(enumSchema, [], Config.create({ camelCasedEnums: 'PascalCase' })); + expect(output).toMatchInlineSnapshot(` + "import 'package:freezed_annotation/freezed_annotation.dart'; + import 'package:flutter/foundation.dart'; + + part 'app_models.freezed.dart'; + part 'app_models.g.dart'; + + enum Episode { + @JsonKey(name: 'NEWHOPE') + Newhope + @JsonKey(name: 'EMPIRE') + Empire + @JsonKey(name: 'JEDI') + Jedi + @JsonKey(name: 'VOID') + Void + @JsonKey(name: 'void') + Void + @JsonKey(name: 'IN') + In + @JsonKey(name: 'in') + In + @JsonKey(name: 'String') + String_ + @JsonKey(name: 'ELSE') + Else + @JsonKey(name: 'else') + Else + @JsonKey(name: 'SWITCH') + Switch + @JsonKey(name: 'switch') + Switch + @JsonKey(name: 'FACTORY') + Factory + @JsonKey(name: 'factory') + Factory + }" + `); + }); + }); + + describe('applying config:', () => { + it('@freezed: using the default plugin configuration: generates the expected output', () => { + const output = plugin(simpleSchema, [], Config.create()); + expect(output).toMatchInlineSnapshot(` + "import 'package:freezed_annotation/freezed_annotation.dart'; + import 'package:flutter/foundation.dart'; + + part 'app_models.freezed.dart'; + part 'app_models.g.dart'; + + @freezed + class Person with _$Person { + const Person._(); + + const factory Person({ + String? id, + required String name, + }) = _Person; + + factory Person.fromJson(Map json) => _$PersonFromJson(json); + }" + `); + }); + + it('@Freezed: generates the expected output', () => { + const output = plugin( + simpleSchema, + [], + Config.create({ + copyWith: false, + equal: true, + makeCollectionsUnmodifiable: true, + }) + ); + expect(output).toMatchInlineSnapshot(` + "import 'package:freezed_annotation/freezed_annotation.dart'; + import 'package:flutter/foundation.dart'; + + part 'app_models.freezed.dart'; + part 'app_models.g.dart'; + + @Freezed( + copyWith: false, + equal: true, + makeCollectionsUnmodifiable: true, + ) + class Person with _$Person { + const Person._(); + + const factory Person({ + String? id, + required String name, + }) = _Person; + + factory Person.fromJson(Map json) => _$PersonFromJson(json); + }" + `); + }); + + it('unfreeze: generates the expected output', () => { + const output = plugin( + simpleSchema, + [], + Config.create({ + immutable: false, + }) + ); + expect(output).toMatchInlineSnapshot(` + "import 'package:freezed_annotation/freezed_annotation.dart'; + import 'package:flutter/foundation.dart'; + + part 'app_models.freezed.dart'; + part 'app_models.g.dart'; + + @unfreezed + class Person with _$Person { + const Person._(); + + factory Person({ + String? id, + required String name, + }) = _Person; + + factory Person.fromJson(Map json) => _$PersonFromJson(json); + }" + `); + }); + + it('@Freezed has precedence over @unfreezed over @freezed: generates the expected output', () => { + const output = plugin( + simpleSchema, + [], + Config.create({ + immutable: false, + copyWith: false, + }) + ); + expect(output).toMatchInlineSnapshot(` + "import 'package:freezed_annotation/freezed_annotation.dart'; + import 'package:flutter/foundation.dart'; + + part 'app_models.freezed.dart'; + part 'app_models.g.dart'; + + @Freezed( + copyWith: false, + ) + class Person with _$Person { + const Person._(); + + factory Person({ + String? id, + required String name, + }) = _Person; + + factory Person.fromJson(Map json) => _$PersonFromJson(json); + }" + `); + }); + + it('using mergedTypes: generates the expected output', () => { + const output = plugin( + mergeSchema, + [], + Config.create({ + mergeTypes: { + Movie: ['CreateMovieInput', 'UpdateMovieInput', 'UpsertMovieInput'], + }, + }) + ); + expect(output).toMatchInlineSnapshot(` + "import 'package:freezed_annotation/freezed_annotation.dart'; + import 'package:flutter/foundation.dart'; + + part 'app_models.freezed.dart'; + part 'app_models.g.dart'; + + @freezed + class Movie with _$Movie { + const Movie._(); + + const factory Movie({ + required String id, + required String title, + }) = _Movie; + + const factory Movie.createMovieInput({ + required String title, + }) = CreateMovieInput; + + const factory Movie.updateMovieInput({ + required String id, + String? title, + }) = UpdateMovieInput; + + const factory Movie.upsertMovieInput({ + required String id, + required String title, + }) = UpsertMovieInput; + + factory Movie.fromJson(Map json) => _$MovieFromJson(json); + } + + @unfreezed + class CreateMovieInput with _$CreateMovieInput { + const CreateMovieInput._(); + + const factory CreateMovieInput({ + required String title, + }) = _CreateMovieInput; + + factory CreateMovieInput.fromJson(Map json) => _$CreateMovieInputFromJson(json); + } + + @unfreezed + class UpsertMovieInput with _$UpsertMovieInput { + const UpsertMovieInput._(); + + const factory UpsertMovieInput({ + required String id, + required String title, + }) = _UpsertMovieInput; + + factory UpsertMovieInput.fromJson(Map json) => _$UpsertMovieInputFromJson(json); + } + + @unfreezed + class UpdateMovieInput with _$UpdateMovieInput { + const UpdateMovieInput._(); + + const factory UpdateMovieInput({ + required String id, + String? title, + }) = _UpdateMovieInput; + + factory UpdateMovieInput.fromJson(Map json) => _$UpdateMovieInputFromJson(json); + } + + @unfreezed + class DeleteMovieInput with _$DeleteMovieInput { + const DeleteMovieInput._(); + + const factory DeleteMovieInput({ + required String id, + }) = _DeleteMovieInput; + + factory DeleteMovieInput.fromJson(Map json) => _$DeleteMovieInputFromJson(json); + }" + `); + }); + + it('using unionTypes: generates the expected output', () => { + const output = plugin(unionSchema, [], Config.create({})); + expect(output).toMatchInlineSnapshot(` + "import 'package:freezed_annotation/freezed_annotation.dart'; + import 'package:flutter/foundation.dart'; + + part 'app_models.freezed.dart'; + part 'app_models.g.dart'; + + enum Episode { + @JsonKey(name: 'NEWHOPE') + newhope + @JsonKey(name: 'EMPIRE') + empire + @JsonKey(name: 'JEDI') + jedi + } + + @freezed + class Actor with _$Actor { + const Actor._(); + + const factory Actor({ + required String name, + required List appearsIn, + }) = _Actor; + + factory Actor.fromJson(Map json) => _$ActorFromJson(json); + } + + @freezed + class Starship with _$Starship { + const Starship._(); + + const factory Starship({ + required String id, + required String name, + double? length, + }) = _Starship; + + factory Starship.fromJson(Map json) => _$StarshipFromJson(json); + } + + @freezed + class Human with _$Human { + const Human._(); + + const factory Human({ + required String id, + required String name, + List? friends, + required List appearsIn, + int? totalCredits, + }) = _Human; + + factory Human.fromJson(Map json) => _$HumanFromJson(json); + } + + @freezed + class Droid with _$Droid { + const Droid._(); + + const factory Droid({ + required String id, + required String name, + List? friends, + required List appearsIn, + String? primaryFunction, + }) = _Droid; + + factory Droid.fromJson(Map json) => _$DroidFromJson(json); + } + + @freezed + class SearchResult with _$SearchResult { + const SearchResult._(); + + const factory SearchResult.human({ + required String id, + required String name, + List? friends, + required List appearsIn, + int? totalCredits, + }) = Human; + + const factory SearchResult.droid({ + required String id, + required String name, + List? friends, + required List appearsIn, + String? primaryFunction, + }) = Droid; + + const factory SearchResult.starship({ + required String id, + required String name, + double? length, + }) = Starship; + + factory SearchResult.fromJson(Map json) => _$SearchResultFromJson(json); + }" + `); + }); + + it('works with cyclic schema: generates the expected output', () => { + const output = plugin(cyclicSchema, [], Config.create({})); + expect(output).toMatchInlineSnapshot(` + "import 'package:freezed_annotation/freezed_annotation.dart'; + import 'package:flutter/foundation.dart'; + + part 'app_models.freezed.dart'; + part 'app_models.g.dart'; + + @unfreezed + class BaseAInput with _$BaseAInput { + const BaseAInput._(); + + const factory BaseAInput({ + required BaseBInput b, + }) = _BaseAInput; + + factory BaseAInput.fromJson(Map json) => _$BaseAInputFromJson(json); + } + + @unfreezed + class BaseBInput with _$BaseBInput { + const BaseBInput._(); + + const factory BaseBInput({ + required BaseCInput c, + }) = _BaseBInput; + + factory BaseBInput.fromJson(Map json) => _$BaseBInputFromJson(json); + } + + @unfreezed + class BaseCInput with _$BaseCInput { + const BaseCInput._(); + + const factory BaseCInput({ + required BaseAInput a, + }) = _BaseCInput; + + factory BaseCInput.fromJson(Map json) => _$BaseCInputFromJson(json); + } + + @freezed + class Base with _$Base { + const Base._(); + + const factory Base({ + String? id, + }) = _Base; + + factory Base.fromJson(Map json) => _$BaseFromJson(json); + }" + `); + }); + + it('escapes types with Dart keywords: generates the expected output', () => { + const output = plugin(escapedSchema, [], Config.create({})); + expect(output).toMatchInlineSnapshot(` + "import 'package:freezed_annotation/freezed_annotation.dart'; + import 'package:flutter/foundation.dart'; + + part 'app_models.freezed.dart'; + part 'app_models.g.dart'; + + @unfreezed + class Enum_ with _$Enum_ { + const Enum_._(); + + const factory Enum_({ + @JsonKey(name: 'is') + String? is_, + @JsonKey(name: 'in') + String? in_, + }) = _Enum_; + + factory Enum_.fromJson(Map json) => _$Enum_FromJson(json); + } + + @unfreezed + class List_ with _$List_ { + const List_._(); + + const factory List_({ + String? map, + @JsonKey(name: 'implements') + String? implements_, + @JsonKey(name: 'extends') + required String extends_, + }) = _List_; + + factory List_.fromJson(Map json) => _$List_FromJson(json); + } + + @freezed + class Object_ with _$Object_ { + const Object_._(); + + const factory Object_.enum_({ + @JsonKey(name: 'is') + String? is_, + @JsonKey(name: 'in') + String? in_, + }) = Enum_; + + const factory Object_.list({ + String? map, + @JsonKey(name: 'implements') + String? implements_, + @JsonKey(name: 'extends') + required String extends_, + }) = List_; + + factory Object_.fromJson(Map json) => _$Object_FromJson(json); + }" + `); + }); + + it('handles custom scalars and nested lists: generates the expected output', () => { + const output = plugin(nonNullableListWithCustomScalars, [], Config.create({})); + expect(output).toMatchInlineSnapshot(` + "import 'package:freezed_annotation/freezed_annotation.dart'; + import 'package:flutter/foundation.dart'; + + part 'app_models.freezed.dart'; + part 'app_models.g.dart'; + + @freezed + class ComplexType with _$ComplexType { + const ComplexType._(); + + const factory ComplexType({ + List? a, + List? b, + required List c, + List?>? d, + List>? e, + required List> f, + jsonb? g, + required timestamp h, + required UUID i, + }) = _ComplexType; + + factory ComplexType.fromJson(Map json) => _$ComplexTypeFromJson(json); + }" + `); + }); + + it('using unionTypes: applying config: generates the expected output', () => { + const output = plugin( + unionSchema, + [], + Config.create({ + defaultValues: [ + [FieldNamePattern.forFieldNamesOfAllTypeNames([friends]), '[]', ['union_factory_parameter']], + [FieldNamePattern.forFieldNamesOfAllTypeNames([appearsIn]), '[]', ['default_factory_parameter']], + ], + deprecated: [ + [FieldNamePattern.forAllFieldNamesOfTypeName([Actor]), ['default_factory_parameter']], + [TypeNamePattern.forTypeNames(SearchResultDroid), ['union_factory']], + ], + final: [[FieldNamePattern.forFieldNamesOfAllTypeNames([id, name]), ['parameter']]], + // fromJsonToJson: [ + // [ + // FieldNamePattern.forFieldNamesOfTypeName([[Starship, length]]), + // 'imperialUnit', + // false, + // ['default_factory_parameter'], + // ], + // [ + // FieldNamePattern.forFieldNamesOfTypeName([[Starship, length]]), + // 'metricUnit', + // true, + // ['union_factory_parameter'], + // ], + // ], + mergeTypes: { + Human: ['Actor'], + Actor: ['Human'], + }, + immutable: TypeNamePattern.forAllTypeNamesExcludeTypeNames([Actor, Human]), + }) + ); + expect(output).toMatchInlineSnapshot(` + "import 'package:freezed_annotation/freezed_annotation.dart'; + import 'package:flutter/foundation.dart'; + + part 'app_models.freezed.dart'; + part 'app_models.g.dart'; + + enum Episode { + @JsonKey(name: 'NEWHOPE') + newhope + @JsonKey(name: 'EMPIRE') + empire + @JsonKey(name: 'JEDI') + jedi + } + + @unfreezed + class Actor with _$Actor { + const Actor._(); + + factory Actor({ + @deprecated + required final String name, + @deprecated + @Default([]) + required List appearsIn, + }) = _Actor; + + const factory Actor.human({ + required final String id, + required final String name, + List? friends, + required List appearsIn, + int? totalCredits, + }) = Human; + + factory Actor.fromJson(Map json) => _$ActorFromJson(json); + } + + @freezed + class Starship with _$Starship { + const Starship._(); + + const factory Starship({ + required final String id, + required final String name, + double? length, + }) = _Starship; + + factory Starship.fromJson(Map json) => _$StarshipFromJson(json); + } + + @unfreezed + class Human with _$Human { + const Human._(); + + factory Human({ + required final String id, + required final String name, + List? friends, + @Default([]) + required List appearsIn, + int? totalCredits, + }) = _Human; + + const factory Human.actor({ + required final String name, + required List appearsIn, + }) = Actor; + + factory Human.fromJson(Map json) => _$HumanFromJson(json); + } + + @freezed + class Droid with _$Droid { + const Droid._(); + + const factory Droid({ + required final String id, + required final String name, + List? friends, + @Default([]) + required List appearsIn, + String? primaryFunction, + }) = _Droid; + + factory Droid.fromJson(Map json) => _$DroidFromJson(json); + } + + @freezed + class SearchResult with _$SearchResult { + const SearchResult._(); + + const factory SearchResult.human({ + required final String id, + required final String name, + @Default([]) + List? friends, + required List appearsIn, + int? totalCredits, + }) = Human; + + @deprecated + const factory SearchResult.droid({ + required final String id, + required final String name, + @Default([]) + List? friends, + required List appearsIn, + String? primaryFunction, + }) = Droid; + + const factory SearchResult.starship({ + required final String id, + required final String name, + double? length, + }) = Starship; + + factory SearchResult.fromJson(Map json) => _$SearchResultFromJson(json); + }" + `); + }); }); }); diff --git a/packages/plugins/dart/flutter-freezed/tests/plugin.ts b/packages/plugins/dart/flutter-freezed/tests/plugin.ts deleted file mode 100644 index 3b79d9714..000000000 --- a/packages/plugins/dart/flutter-freezed/tests/plugin.ts +++ /dev/null @@ -1,211 +0,0 @@ -// import { buildSchema } from 'graphql'; -// import { plugin } from '../src'; -// import { DefaultFreezedPluginConfig, getFreezedConfigValue } from '../src/utils'; - -// describe('flutter-freezed', () => { -// const schema = buildSchema(/* GraphQL */ ` -// type Movie { -// id: ID! -// title: String! -// } - -// input CreateMovieInput { -// title: String! -// } - -// input UpsertMovieInput { -// id: ID! -// title: String! -// } - -// input UpdateMovieInput { -// id: ID! -// title: String -// } - -// input DeleteMovieInput { -// id: ID! -// } - -// enum Episode { -// NEWHOPE -// EMPIRE -// JEDI -// } - -// type Starship { -// id: ID! -// name: String! -// length: Float -// } - -// interface Character { -// id: ID! -// name: String! -// friends: [Character] -// appearsIn: [Episode]! -// } - -// type MovieCharacter { -// name: String! -// appearsIn: [Episode]! -// } - -// type Human implements Character { -// id: ID! -// name: String! -// friends: [MovieCharacter] -// appearsIn: [Episode]! -// starships: [Starship] -// totalCredits: Int -// } - -// type Droid implements Character { -// id: ID! -// name: String! -// friends: [MovieCharacter] -// appearsIn: [Episode]! -// primaryFunction: String -// } - -// union SearchResult = Human | Droid | Starship - -// # tests - -// scalar UUID -// scalar timestamptz -// scalar jsonb - -// # cyclic references/nested types -// input AInput { -// b: BInput! -// } - -// input BInput { -// c: CInput! -// } - -// input CInput { -// a: AInput! -// } - -// type ComplexType { -// a: [String] -// b: [ID!] -// c: [Boolean!]! -// d: [[Int]] -// e: [[Float]!] -// f: [[String]!]! -// g: jsonb -// h: timestamptz! -// i: UUID! -// } -// `); - -// const unionSchema = buildSchema(/* GraphQL */ ` -// enum Episode { -// NEWHOPE -// EMPIRE -// JEDI -// } - -// type Starship { -// id: ID! -// name: String! -// length: Float -// } - -// interface Character { -// id: ID! -// name: String! -// friends: [Character] -// appearsIn: [Episode]! -// } - -// type MovieCharacter { -// name: String! -// appearsIn: [Episode]! -// } - -// type Human implements Character { -// id: ID! -// name: String! -// friends: [MovieCharacter] -// appearsIn: [Episode]! -// starships: [Starship] -// totalCredits: Int -// } - -// type Droid implements Character { -// id: ID! -// name: String! -// friends: [MovieCharacter] -// appearsIn: [Episode]! -// primaryFunction: String -// } - -// union SearchResult = Human | Droid | Starship -// `); - -// it('Should greet', async () => { -// const result = await plugin(unionSchema, [], {}); - -// expect(result).toBe('Hi'); -// }); -// }); - -// describe('get freezed config value', () => { -// const config = new DefaultFreezedPluginConfig({ -// typeSpecificFreezedConfig: { -// Starship: { -// config: { -// immutable: false, -// }, -// }, -// }, -// }); - -// it('should return the typeSpecific config value', () => { -// expect(getFreezedConfigValue('immutable', config, 'Starship')).toBe(false); -// }); - -// /* it('should return the default value', () => { -// expect(getConfigValue(config, 'directiveMap')).toBe([]); -// }); - -// it('should return the default value', () => { -// expect(getConfigValue(config, 'fileName')).toBe('app_models'); -// }); - -// it('should return the default value', () => { -// expect(getConfigValue(config, 'globalFreezedConfig')).toBeInstanceOf(DefaultFreezedConfig); -// }); - -// it('should return the default value', () => { -// expect(getConfigValue(config, 'typeSpecificFreezedConfig')).toBe({}); -// }); - -// it('should return the default value', () => { -// expect(getConfigValue(config, 'ignoreTypes')).toBe([]); -// }); - -// it('should return the default value', () => { -// expect(getConfigValue(config, 'interfaceNamePrefix')).toBe(''); -// }); - -// it('should return the default value', () => { -// expect(getConfigValue(config, 'interfaceNameSuffix')).toBe('Interface'); -// }); - -// it('should return the default value', () => { -// expect(getConfigValue(config, 'lowercaseEnums')).toBeTruthy(); -// }); - -// it('should return the default value', () => { -// expect(getConfigValue(config, 'modular')).toBeTruthy(); -// }); */ - -// it('should return the default value', () => { -// expect(getFreezedConfigValue('immutable', config, 'Spaceship')).toBe(true); -// }); -// }); diff --git a/packages/plugins/dart/flutter-freezed/tests/schema.ts b/packages/plugins/dart/flutter-freezed/tests/schema.ts index 4970ac207..714f9e638 100644 --- a/packages/plugins/dart/flutter-freezed/tests/schema.ts +++ b/packages/plugins/dart/flutter-freezed/tests/schema.ts @@ -5,36 +5,28 @@ export const enumSchema = buildSchema(/* GraphQL */ ` NEWHOPE EMPIRE JEDI + VOID + void + IN + in + String + ELSE + else + SWITCH + switch + FACTORY + factory } `); -export const baseSchema = buildSchema(/* GraphQL */ ` - type BaseType { - id: String - } - - type PersonType { +export const simpleSchema = buildSchema(/* GraphQL */ ` + type Person { id: String name: String! } `); -export const extendedBaseSchema = buildSchema(/* GraphQL */ ` - type BaseType { - id: String - primaryKey: String! - CompositeForeignKey: String! - } - - type PersonType { - id: String - name: String! - primaryKey: String! - CompositeForeignKey: String! - } -`); - -export const movieSchema = buildSchema(/* GraphQL */ ` +export const mergeSchema = buildSchema(/* GraphQL */ ` type Movie { id: ID! title: String! @@ -59,16 +51,21 @@ export const movieSchema = buildSchema(/* GraphQL */ ` } `); -export const starWarsSchema = buildSchema(/* GraphQL */ ` +export const unionSchema = buildSchema(/* GraphQL */ ` enum Episode { NEWHOPE EMPIRE JEDI } + type Actor { + name: String! + appearsIn: [Episode]! + } + type Starship { id: ID! - name: String! + name: String! #@constraint(minLength: 5, maxLength: 10) length: Float } @@ -79,24 +76,18 @@ export const starWarsSchema = buildSchema(/* GraphQL */ ` appearsIn: [Episode]! } - type MovieCharacter { - name: String! - appearsIn: [Episode]! - } - type Human implements Character { id: ID! name: String! - friends: [MovieCharacter] + friends: [Actor] appearsIn: [Episode]! - starships: [Starship] totalCredits: Int } type Droid implements Character { id: ID! name: String! - friends: [MovieCharacter] + friends: [Actor] appearsIn: [Episode]! primaryFunction: String } @@ -122,24 +113,24 @@ export const cyclicSchema = buildSchema(/* GraphQL */ ` } `); -export const simpleUnionSchema = buildSchema(/* GraphQL */ ` - input RequestOTPInput { - email: String - phoneNumber: String +export const escapedSchema = buildSchema(/* GraphQL */ ` + input Enum { + is: String + in: String } - input VerifyOTPInput { - email: String - phoneNumber: String - otpCode: String! + input List { + map: String + implements: String + extends: String! } - union AuthWithOTPInput = RequestOTPInput | VerifyOTPInput + union Object = Enum | List `); export const nonNullableListWithCustomScalars = buildSchema(/* GraphQL */ ` scalar UUID - scalar timestamptz + scalar timestamp scalar jsonb type ComplexType { @@ -150,125 +141,7 @@ export const nonNullableListWithCustomScalars = buildSchema(/* GraphQL */ ` e: [[Float]!] f: [[String]!]! g: jsonb - h: timestamptz! + h: timestamp! i: UUID! } `); - -export const fullSchema = buildSchema(/* GraphQL */ ` - # ******************************************* - # custom scalars * - # ******************************************* - scalar UUID - scalar timestamptz - scalar jsonb - - # ******************************************* - # enums * - # ******************************************* - enum Episode { - NEWHOPE - EMPIRE - JEDI - } - - # ******************************************* - # object type with input types * - # ******************************************* - type Movie { - id: ID! - title: String! - } - - input CreateMovieInput { - title: String! - } - - input UpsertMovieInput { - id: ID! - title: String! - } - - input UpdateMovieInput { - id: ID! - title: String - } - - input DeleteMovieInput { - id: ID! - } - - # ******************************************* - # union type * - # ******************************************* - type Starship { - id: ID! - name: String! - length: Float - } - - interface Character { - id: ID! - name: String! - friends: [Character] - appearsIn: [Episode]! - } - - type MovieCharacter { - name: String! - appearsIn: [Episode]! - } - - type Human implements Character { - id: ID! - name: String! - friends: [MovieCharacter] - appearsIn: [Episode]! - starships: [Starship] - totalCredits: Int - } - - type Droid implements Character { - id: ID! - name: String! - friends: [MovieCharacter] - appearsIn: [Episode]! - primaryFunction: String - } - - union SearchResult = Human | Droid | Starship - - # ******************************************* - # (non)-nullables * - # ******************************************* - type ComplexType { - a: [String] - b: [ID!] - c: [Boolean!]! - d: [[Int]] - e: [[Float]!] - f: [[String]!]! - g: jsonb - h: timestamptz! - i: UUID! - } - - # ******************************************* - # cyclic inputs * - # ******************************************* - input BaseAInput { - b: BaseBInput! - } - - input BaseBInput { - c: BaseCInput! - } - - input BaseCInput { - a: BaseAInput! - } - - type Base { - id: String - } -`); diff --git a/packages/plugins/dart/flutter-freezed/tests/utils.spec.ts b/packages/plugins/dart/flutter-freezed/tests/utils.spec.ts index 7ef057e1d..4d8c1200c 100644 --- a/packages/plugins/dart/flutter-freezed/tests/utils.spec.ts +++ b/packages/plugins/dart/flutter-freezed/tests/utils.spec.ts @@ -1,84 +1,40 @@ -import { CustomDecorator } from '../src/config'; -import { getCustomDecorators, getFreezedConfigValue, transformCustomDecorators } from '../src/utils'; -import { customDecoratorsConfig, defaultConfig, typeConfig } from './config'; - -/** utils test */ -describe('flutter-freezed: utils & helpers', () => { - test('getFreezedConfigValue(): returns the expected value for globalFreezedConfig and typeSpecificFreezedConfig', () => { - const Starship = 'Starship'; - - expect(getFreezedConfigValue('alwaysUseJsonKeyName', defaultConfig)).toBe(false); - expect(getFreezedConfigValue('alwaysUseJsonKeyName', typeConfig, Starship)).toBe(true); - - expect(getFreezedConfigValue('copyWith', defaultConfig)).toBe(undefined); - expect(getFreezedConfigValue('copyWith', typeConfig, Starship)).toBe(false); - - expect(getFreezedConfigValue('customDecorators', defaultConfig)).toMatchObject({}); - expect(getFreezedConfigValue('defaultUnionConstructor', defaultConfig)).toBe(true); - expect(getFreezedConfigValue('equal', defaultConfig)).toBe(undefined); - expect(getFreezedConfigValue('fromJsonToJson', defaultConfig)).toBe(true); - expect(getFreezedConfigValue('immutable', defaultConfig)).toBe(true); - expect(getFreezedConfigValue('makeCollectionsUnmodifiable', defaultConfig)).toBe(undefined); - expect(getFreezedConfigValue('mergeInputs', defaultConfig)).toMatchObject([]); - expect(getFreezedConfigValue('mutableInputs', defaultConfig)).toBe(true); - expect(getFreezedConfigValue('privateEmptyConstructor', defaultConfig)).toBe(true); - expect(getFreezedConfigValue('unionKey', defaultConfig)).toBe(undefined); - - expect(getFreezedConfigValue('unionValueCase', defaultConfig)).toBe(undefined); - expect(getFreezedConfigValue('unionValueCase', typeConfig, Starship)).toBe('FreezedUnionCase.pascal'); +import { transformSchemaAST } from '@graphql-codegen/schema-ast'; +import { unionSchema } from './schema.js'; +import { appliesOnBlock, arrayWrap, dartCasing, nodeIsObjectType } from '../src/utils.js'; +import { defaultFreezedPluginConfig, APPLIES_ON_PARAMETERS } from '../src/config/plugin-config.js'; + +const { + ast: { definitions: nodes }, +} = transformSchemaAST(unionSchema, defaultFreezedPluginConfig); + +describe('arrayWrap:', () => { + it('wraps the value in array if the value is not an array', () => { + expect(arrayWrap('Hello')).toMatchObject(['Hello']); }); - describe('customDecorators', () => { - const globalCustomDecorators = getCustomDecorators(customDecoratorsConfig, ['class']); - - const droidCustomDecorators = getCustomDecorators(customDecoratorsConfig, ['class', 'union_factory'], 'Droid'); - - const idCustomDecorators = getCustomDecorators(customDecoratorsConfig, ['union_factory_parameter'], 'Droid', 'id'); - - test('getCustomDecorators()', () => { - expect(globalCustomDecorators).toMatchObject({ - '@JsonSerializable(explicitToJson: true)': { - applyOn: ['class'], - mapsToFreezedAs: 'custom', - }, - }); - - expect(droidCustomDecorators).toMatchObject({ - '@JsonSerializable(explicitToJson: true)': { - applyOn: ['class'], - mapsToFreezedAs: 'custom', - }, - '@FreezedUnionValue': { - applyOn: ['union_factory'], - arguments: ["'BestDroid'"], - mapsToFreezedAs: 'custom', - }, - }); + it('returns the value if the value is already an array', () => { + expect(arrayWrap(['Hello'])).toMatchObject(['Hello']); + }); - expect(idCustomDecorators).toMatchObject({ - '@NanoId': { - applyOn: ['union_factory_parameter'], - arguments: ['size: 16', 'alphabets: NanoId.ALPHA_NUMERIC'], - mapsToFreezedAs: 'custom', - }, - }); - }); + it('returns an empty array `[]` if the value is undefined', () => { + expect(arrayWrap(undefined)).toMatchObject([]); + }); +}); - test('transformCustomDecorators()', () => { - expect(transformCustomDecorators(globalCustomDecorators)).toMatchObject([ - '@JsonSerializable(explicitToJson: true)\n', - ]); +test('method: nodeIsObjectType() => returns true if node is an ObjectType', () => { + const expected = [false, true, true, false, true, true, false]; + expect(nodes.map(nodeIsObjectType)).toEqual(expected); +}); - /* - expect(transformCustomDecorators(droidCustomDecorators, 'Droid')).toMatchObject([ - '@JsonSerializable(explicitToJson: true)\n', - "@FreezedUnionValue('BestDroid')\n", - ]); +test('method: appliesOnBlock() => returns true if the configAppliesOnBlock contains some of the blockAppliesOn values', () => { + expect(appliesOnBlock(['parameter'], APPLIES_ON_PARAMETERS)).toBe(true); + expect(appliesOnBlock(['factory', 'parameter'], ['parameter'])).toBe(true); + expect(appliesOnBlock(['default_factory_parameter', 'parameter'], ['union_factory_parameter'])).toBe(false); +}); - expect(transformCustomDecorators(idCustomDecorators, 'Droid', 'id')).toMatchObject([ - '@NanoId(size: 16, alphabets: NanoId.ALPHA_NUMERIC)\n', - ]); - */ - }); - }); +test('method: dartCasing() => ', () => { + expect(dartCasing('snake---- Case___ ME', 'snake_case')).toBe('snake_case_me'); + expect(dartCasing('Camel_ case- -- - ME', 'camelCase')).toBe('camelCaseMe'); + expect(dartCasing('pascal-- --case _ ME', 'PascalCase')).toBe('PascalCaseMe'); + expect(dartCasing('lE-AvE mE A-l_o_n-e')).toBe('lE-AvE mE A-l_o_n-e'); });