diff --git a/forward_engineering/mappers/fieldDefaultValue.js b/forward_engineering/mappers/fieldDefaultValue.js index 97b985d..0990076 100644 --- a/forward_engineering/mappers/fieldDefaultValue.js +++ b/forward_engineering/mappers/fieldDefaultValue.js @@ -29,7 +29,7 @@ function getFieldDefaultValueStatement({ field }) { * Formats the default value for a field. * * @param {object} param0 - * @param {string} [param0.defaultValue] - The default value. + * @param {FieldData['default']} [param0.defaultValue] - The default value. * @param {string} param0.fieldType - The type of the field. * @returns {string} - The formatted default value. */ @@ -84,7 +84,7 @@ function isComplexDefaultValue({ defaultValue }) { /** * Checks if a value is present (not undefined or empty). * - * @param {string} [value] - The value to check. + * @param {FieldData['default']} [value] - The value to check. * @returns {boolean} - True if the value is present, false otherwise. */ function isValuePresent(value) { @@ -95,11 +95,14 @@ function isValuePresent(value) { * Prepares a complex default value by removing newlines. * * @param {object} param0 - * @param {string} [param0.defaultValue] - The default value. + * @param {FieldData['default']} [param0.defaultValue] - The default value. * @returns {string} - The prepared default value. */ function prepareComplexDefaultValue({ defaultValue = '' }) { - return defaultValue.trim().replace(/\n/g, ' '); + if (typeof defaultValue === 'string') { + return defaultValue.trim().replace(/\n/g, ' '); + } + return ''; } module.exports = { diff --git a/reverse_engineering/mappers/field.js b/reverse_engineering/mappers/field.js index 337735f..1947994 100644 --- a/reverse_engineering/mappers/field.js +++ b/reverse_engineering/mappers/field.js @@ -1,6 +1,6 @@ /** - * @import {FieldDefinitionNode, TypeNode} from "graphql" - * @import {DefinitionNameToTypeNameMap, FieldTypeProperties, PreProcessedFieldData} from "./../../shared/types/types" + * @import {FieldDefinitionNode, TypeNode, InputValueDefinitionNode, ValueNode} from "graphql" + * @import {DefinitionNameToTypeNameMap, FieldTypeProperties, InputTypeFieldProperties, PreProcessedFieldData} from "./../../shared/types/types" */ const { mapDirectivesUsage } = require('./directiveUsage'); @@ -11,7 +11,7 @@ const { BUILT_IN_SCALAR_LIST } = require('../constants/types'); * Maps a field * * @param {object} params - * @param {FieldDefinitionNode} params.field - The field to map + * @param {FieldDefinitionNode | InputValueDefinitionNode} params.field - The field to map * @param {DefinitionNameToTypeNameMap} params.definitionCategoryByNameMap - The definition category by name map * @returns {PreProcessedFieldData} The mapped field */ @@ -24,6 +24,11 @@ function mapField({ field, definitionCategoryByNameMap }) { }; const description = field.description?.value; + // Add default value handling for InputValueDefinitionNode + if ('defaultValue' in field && field.defaultValue) { + sharedProperties.default = parseDefaultValue(field.defaultValue); + } + if ('$ref' in fieldTypeProperties) { return { ...sharedProperties, @@ -38,6 +43,43 @@ function mapField({ field, definitionCategoryByNameMap }) { }; } +/** + * Parses a default value from a ValueNode into a string representation + * + * @param {ValueNode} defaultValue - The default value node to parse + * @param {boolean} [isNested] - Whether this value is nested inside an object or list. Default is `false` + * @returns {InputTypeFieldProperties['default']} String representation of the default value + */ +function parseDefaultValue(defaultValue, isNested = false) { + switch (defaultValue.kind) { + case astNodeKind.INT: + return parseInt(defaultValue.value); + case astNodeKind.FLOAT: + return parseFloat(defaultValue.value); + case astNodeKind.ENUM: + return defaultValue.value; + case astNodeKind.STRING: + // Add quotes only if the string is nested in an object or list + return isNested ? `"${defaultValue.value}"` : defaultValue.value; + case astNodeKind.BOOLEAN: + return defaultValue.value.toString(); + case astNodeKind.NULL: + return 'null'; + case astNodeKind.LIST: { + const listValues = defaultValue.values.map(value => parseDefaultValue(value, true)); + return `[${listValues.join(', ')}]`; + } + case astNodeKind.OBJECT: { + const objectFields = defaultValue.fields.map( + field => `${field.name.value}: ${parseDefaultValue(field.value, true)}`, + ); + return `{ ${objectFields.join(', ')} }`; + } + default: + return ''; + } +} + /** * Recursively maps the type properties unwrapping non-null and list types and resolving named types to references * diff --git a/reverse_engineering/mappers/typeDefinitions/inputType.js b/reverse_engineering/mappers/typeDefinitions/inputType.js new file mode 100644 index 0000000..47d85ba --- /dev/null +++ b/reverse_engineering/mappers/typeDefinitions/inputType.js @@ -0,0 +1,59 @@ +/** + * @import {InputObjectTypeDefinitionNode} from "graphql" + * @import {DefinitionNameToTypeNameMap, FieldsOrder, REInputTypeDefinition, REPropertiesSchema} from "../../../shared/types/types" + */ + +const { sortByName } = require('../../helpers/sortByName'); +const { mapDirectivesUsage } = require('../directiveUsage'); +const { mapField } = require('../field'); + +/** + * Maps input object type definitions + * + * @param {object} params + * @param {InputObjectTypeDefinitionNode[]} params.inputObjectTypes - The input object types + * @param {DefinitionNameToTypeNameMap} params.definitionCategoryByNameMap - The definition category by name map + * @param {FieldsOrder} params.fieldsOrder - The fields order + * @returns {REInputTypeDefinition[]} The mapped input object type definitions + */ +function getInputObjectTypeDefinitions({ inputObjectTypes = [], definitionCategoryByNameMap, fieldsOrder }) { + return inputObjectTypes.map(inputObjectType => + mapInputObjectType({ inputObjectType, definitionCategoryByNameMap, fieldsOrder }), + ); +} + +/** + * Maps a single input object type definition + * + * @param {object} params + * @param {InputObjectTypeDefinitionNode} params.inputObjectType - The input object type to map + * @param {DefinitionNameToTypeNameMap} params.definitionCategoryByNameMap - The definition category by name map + * @param {FieldsOrder} params.fieldsOrder - The fields order + * @returns {REInputTypeDefinition} The mapped input object type definition + */ +function mapInputObjectType({ inputObjectType, definitionCategoryByNameMap, fieldsOrder }) { + const properties = inputObjectType.fields + ? inputObjectType.fields.map(field => mapField({ field, definitionCategoryByNameMap })) + : []; + const required = properties.filter(property => property.required).map(property => property.name); + const convertedProperties = sortByName({ items: properties, fieldsOrder }).reduce( + (acc, property) => { + acc[property.name] = property; + return acc; + }, + /** @type {REPropertiesSchema} */ {}, + ); + + return { + type: 'input', + name: inputObjectType.name.value, + properties: convertedProperties, + required, + description: inputObjectType.description?.value || '', + typeDirectives: mapDirectivesUsage({ directives: [...(inputObjectType.directives || [])] }), + }; +} + +module.exports = { + getInputObjectTypeDefinitions, +}; diff --git a/reverse_engineering/mappers/typeDefinitions/typeDefinitions.js b/reverse_engineering/mappers/typeDefinitions/typeDefinitions.js index ba436e3..13b9505 100644 --- a/reverse_engineering/mappers/typeDefinitions/typeDefinitions.js +++ b/reverse_engineering/mappers/typeDefinitions/typeDefinitions.js @@ -1,7 +1,6 @@ /** * @import {DefinitionNode} from "graphql" * @import {REDirectiveDefinition, - * REDefinitionsSchema, * FieldsOrder, * RECustomScalarDefinition, * REDefinition, @@ -14,7 +13,9 @@ * ScalarStructureType, * REObjectTypeDefinition, * InterfaceStructureType, - * REInterfaceDefinition} from "../../../shared/types/types" + * REInterfaceDefinition, + * REInputTypeDefinition, + * InputStructureType} from "../../../shared/types/types" */ const { astNodeKind } = require('../../constants/graphqlAST'); @@ -26,6 +27,7 @@ const { getDirectiveTypeDefinitions } = require('./directive'); const { getObjectTypeDefinitions } = require('./objectType'); const { getEnumTypeDefinitions } = require('./enum'); const { getInterfaceDefinitions } = require('./interface'); +const { getInputObjectTypeDefinitions } = require('./inputType'); /** * Gets the type definitions structure @@ -65,6 +67,12 @@ function getTypeDefinitions({ typeDefinitions, fieldsOrder, rootTypeNames }) { fieldsOrder, }); + const inputTypes = getInputObjectTypeDefinitions({ + inputObjectTypes: findNodesByKind({ nodes: typeDefinitions, kind: astNodeKind.INPUT_OBJECT_TYPE_DEFINITION }), + definitionCategoryByNameMap, + fieldsOrder, + }); + const definitions = getTypeDefinitionsStructure({ fieldsOrder, directives, @@ -72,6 +80,7 @@ function getTypeDefinitions({ typeDefinitions, fieldsOrder, rootTypeNames }) { enums, objectTypes, interfaces, + inputTypes, }); return definitions; @@ -87,9 +96,18 @@ function getTypeDefinitions({ typeDefinitions, fieldsOrder, rootTypeNames }) { * @param {REEnumDefinition[]} params.enums - The enum definitions * @param {REObjectTypeDefinition[]} params.objectTypes - The object type definitions * @param {REInterfaceDefinition[]} params.interfaces - The interface definitions + * @param {REInputTypeDefinition[]} params.inputTypes - The input type definitions * @returns {REModelDefinitionsSchema} The type definitions structure */ -function getTypeDefinitionsStructure({ fieldsOrder, directives, customScalars, enums, objectTypes, interfaces }) { +function getTypeDefinitionsStructure({ + fieldsOrder, + directives, + customScalars, + enums, + objectTypes, + interfaces, + inputTypes, +}) { const definitions = { ['Directives']: /** @type {DirectiveStructureType} */ ( getDefinitionCategoryStructure({ @@ -126,6 +144,13 @@ function getTypeDefinitionsStructure({ fieldsOrder, directives, customScalars, e properties: interfaces, }) ), + ['Input objects']: /** @type {InputStructureType} */ ( + getDefinitionCategoryStructure({ + fieldsOrder, + subtype: 'input', + properties: inputTypes, + }) + ), }; return { diff --git a/shared/types/fe.d.ts b/shared/types/fe.d.ts index c2d354a..3b27499 100644 --- a/shared/types/fe.d.ts +++ b/shared/types/fe.d.ts @@ -47,7 +47,9 @@ export type FEEnumDefinitionsSchema = Record; // Object type definition export type FEObjectLikeDefinitionsSchema = Record; -export type FEObjectLikeDefinition = ObjectLikeDefinition; +export type FEObjectLikeDefinition = ObjectLikeDefinition & { + implementsInterfaces?: ImplementsInterface[]; +}; export type ArgumentsResultStatement = { argumentsStatement: string; // The formatted arguments string. diff --git a/shared/types/re.d.ts b/shared/types/re.d.ts index 480fec4..2b9013a 100644 --- a/shared/types/re.d.ts +++ b/shared/types/re.d.ts @@ -7,6 +7,7 @@ import { EnumDefinition, EnumValue, StructuredDirective, + InputFieldDefaultValue, } from './shared'; type ContainerName = string; @@ -118,20 +119,20 @@ export type REDirectiveDefinition = DirectiveDefinition & { name: string; }; -export type REDefinitionsSchema = REDirectiveDefinitionsSchema | RECustomScalarDefinitionsSchema; - -export type REDirectiveDefinitionsSchema = Record; -export type RECustomScalarDefinitionsSchema = Record; -export type REObjectDefinitionsSchema = Record; -export type REEnumDefinitionsSchema = Record; -export type REInterfaceDefinitionsSchema = Record; +type REDirectiveDefinitionsSchema = Record; +type RECustomScalarDefinitionsSchema = Record; +type REObjectDefinitionsSchema = Record; +type REEnumDefinitionsSchema = Record; +type REInterfaceDefinitionsSchema = Record; +type REInputDefinitionsSchema = Record; export type REDefinition = | RECustomScalarDefinition | REDirectiveDefinition | REEnumDefinition | REObjectTypeDefinition - | REInterfaceDefinition; + | REInterfaceDefinition + | REInputTypeDefinition; export type REModelDefinitionsSchema = { definitions: { @@ -148,7 +149,8 @@ export type DefinitionREStructure = | ScalarStructureType | EnumStructureType | ObjectStructureType - | InterfaceStructureType; + | InterfaceStructureType + | InputStructureType; type StructureType = { type: 'type'; @@ -168,10 +170,14 @@ export type EnumStructureType = StructureType & { subtype: 'enum'; }; -export type InterfaceStructureType = StructureType & { +export type InterfaceStructureType = StructureType & { subtype: 'interface'; }; +export type InputStructureType = StructureType & { + subtype: 'input'; +}; + export type RECustomScalarDefinition = CustomScalarDefinition & { type: 'scalar'; name: string; @@ -193,16 +199,20 @@ export type REImplementsInterface = { }; export type REPropertiesSchema = Record>; -type REObjectLikeDefinition = ObjectLikeDefinition; +type REObjectLikeDefinition = ObjectLikeDefinition & { name: string }; export type REObjectTypeDefinition = REObjectLikeDefinition & { type: 'object'; - name: string; + implementsInterfaces?: REImplementsInterface[]; }; export type REInterfaceDefinition = REObjectLikeDefinition & { type: 'interface'; - name: string; + implementsInterfaces?: REImplementsInterface[]; +}; + +export type REInputTypeDefinition = REObjectLikeDefinition & { + type: 'input'; }; export type PreProcessedFieldData = FieldData & @@ -210,20 +220,24 @@ export type PreProcessedFieldData = FieldData & name: string; }; +export type InputTypeFieldProperties = { + default?: InputFieldDefaultValue; +}; + export type FieldTypeProperties = RegularFieldTypeProperties | ArrayFieldTypeProperties | ReferenceFieldTypeProperties; -export type RegularFieldTypeProperties = { +type RegularFieldTypeProperties = InputTypeFieldProperties & { type: string; required: boolean; }; -export type ArrayFieldTypeProperties = { +type ArrayFieldTypeProperties = InputTypeFieldProperties & { type: 'List'; items?: [FieldTypeProperties]; required: boolean; }; -export type ReferenceFieldTypeProperties = { +type ReferenceFieldTypeProperties = InputTypeFieldProperties & { $ref: string; required: boolean; }; diff --git a/shared/types/shared.d.ts b/shared/types/shared.d.ts index a3a3beb..16b6468 100644 --- a/shared/types/shared.d.ts +++ b/shared/types/shared.d.ts @@ -68,10 +68,9 @@ export type DirectiveDefinition = { directiveLocations: D; }; -export type ObjectLikeDefinition = { +export type ObjectLikeDefinition = { description?: string; // Description of the object type isActivated?: boolean; // If the object type is activated - implementsInterfaces?: Interface[]; // Interfaces that the object type implements typeDirectives?: DirectiveUsage[]; // Directives for the type properties: Record>; // Properties of the object type required?: string[]; @@ -84,6 +83,8 @@ export type FieldSchema = Record = ArrayItem | ArrayItem[]; +export type InputFieldDefaultValue = string | number; + type RegularFieldData = { type: string; // Type of the field isActivated?: boolean; // If the field is activated @@ -91,7 +92,7 @@ type RegularFieldData = { fieldDirectives?: DirectiveUsage[]; // Directives for the field items?: ArrayItems; // Items of the List type arguments?: Argument[]; // Arguments of the field - default?: string; // Default value of the field + default?: InputFieldDefaultValue; // Default value of the field }; type ReferenceFieldData = { @@ -100,7 +101,7 @@ type ReferenceFieldData = { refDescription?: string; // Description of the reference fieldDirectives?: DirectiveUsage[]; // Directives for the field arguments?: Argument[]; // Arguments of the field - default?: string; // Default value of the reference + default?: InputFieldDefaultValue; // Default value of the reference }; export type ArrayItem = FieldData & { diff --git a/test/reverse_engineering/mappers/field.spec.js b/test/reverse_engineering/mappers/field.spec.js index 4d60cef..08524fa 100644 --- a/test/reverse_engineering/mappers/field.spec.js +++ b/test/reverse_engineering/mappers/field.spec.js @@ -14,8 +14,29 @@ const astNodeKindMock = { NAMED_TYPE: 'NamedType', NON_NULL_TYPE: 'NonNullType', LIST_TYPE: 'ListType', + INT: 'IntValue', + FLOAT: 'FloatValue', + STRING: 'StringValue', + BOOLEAN: 'BooleanValue', + NULL: 'NullValue', + ENUM: 'EnumValue', + LIST: 'ListValue', + OBJECT: 'ObjectValue', + OBJECT_FIELD: 'ObjectField', }; +mock.module('../../../reverse_engineering/constants/graphqlAST', { + namedExports: { + astNodeKind: astNodeKindMock, + }, +}); + +mock.module('../../../reverse_engineering/constants/types', { + namedExports: { + BUILT_IN_SCALAR_LIST: ['String', 'Int', 'Float', 'Boolean', 'ID'], + }, +}); + const { mapField } = require('../../../reverse_engineering/mappers/field'); describe('field', () => { @@ -268,4 +289,343 @@ describe('field', () => { }); }); }); + + describe('default value handling', () => { + it('should map field with string default value', () => { + const field = { + name: { value: 'username' }, + type: { + kind: astNodeKindMock.NAMED_TYPE, + name: { value: 'String' }, + }, + directives: [], + defaultValue: { + kind: astNodeKindMock.STRING, + value: 'anonymous', + }, + }; + const definitionCategoryByNameMap = {}; + + const result = mapField({ field, definitionCategoryByNameMap }); + + assert.deepStrictEqual(result, { + name: 'username', + type: 'String', + required: false, + fieldDirectives: [], + description: undefined, + default: 'anonymous', + }); + }); + + it('should map field with numeric default value', () => { + const field = { + name: { value: 'age' }, + type: { + kind: astNodeKindMock.NAMED_TYPE, + name: { value: 'Int' }, + }, + directives: [], + defaultValue: { + kind: astNodeKindMock.INT, + value: '30', + }, + }; + const definitionCategoryByNameMap = {}; + + const result = mapField({ field, definitionCategoryByNameMap }); + + assert.deepStrictEqual(result, { + name: 'age', + type: 'Int', + required: false, + fieldDirectives: [], + description: undefined, + default: 30, + }); + }); + + it('should map field with boolean default value', () => { + const field = { + name: { value: 'isActive' }, + type: { + kind: astNodeKindMock.NAMED_TYPE, + name: { value: 'Boolean' }, + }, + directives: [], + defaultValue: { + kind: astNodeKindMock.BOOLEAN, + value: true, + }, + }; + const definitionCategoryByNameMap = {}; + + const result = mapField({ field, definitionCategoryByNameMap }); + + assert.deepStrictEqual(result, { + name: 'isActive', + type: 'Boolean', + required: false, + fieldDirectives: [], + description: undefined, + default: 'true', + }); + }); + + it('should map field with enum default value', () => { + const field = { + name: { value: 'role' }, + type: { + kind: astNodeKindMock.NAMED_TYPE, + name: { value: 'Role' }, + }, + directives: [], + defaultValue: { + kind: astNodeKindMock.ENUM, + value: 'USER', + }, + }; + const definitionCategoryByNameMap = { + 'Role': 'Enums', + }; + + const result = mapField({ field, definitionCategoryByNameMap }); + + assert.deepStrictEqual(result, { + name: 'role', + $ref: '#model/definitions/Enums/Role', + required: false, + fieldDirectives: [], + refDescription: undefined, + default: 'USER', + }); + }); + + it('should map field with null default value', () => { + const field = { + name: { value: 'optionalField' }, + type: { + kind: astNodeKindMock.NAMED_TYPE, + name: { value: 'String' }, + }, + directives: [], + defaultValue: { + kind: astNodeKindMock.NULL, + }, + }; + const definitionCategoryByNameMap = {}; + + const result = mapField({ field, definitionCategoryByNameMap }); + + assert.deepStrictEqual(result, { + name: 'optionalField', + type: 'String', + required: false, + fieldDirectives: [], + description: undefined, + default: 'null', + }); + }); + + it('should map field with list default value', () => { + const field = { + name: { value: 'tags' }, + type: { + kind: astNodeKindMock.LIST_TYPE, + type: { + kind: astNodeKindMock.NAMED_TYPE, + name: { value: 'String' }, + }, + }, + directives: [], + defaultValue: { + kind: astNodeKindMock.LIST, + values: [ + { + kind: astNodeKindMock.STRING, + value: 'tag1', + }, + { + kind: astNodeKindMock.STRING, + value: 'tag2', + }, + ], + }, + }; + const definitionCategoryByNameMap = {}; + + const result = mapField({ field, definitionCategoryByNameMap }); + + assert.deepStrictEqual(result, { + name: 'tags', + type: 'List', + items: [ + { + type: 'String', + required: false, + }, + ], + required: false, + fieldDirectives: [], + description: undefined, + default: '["tag1", "tag2"]', + }); + }); + + it('should map field with object default value', () => { + const field = { + name: { value: 'settings' }, + type: { + kind: astNodeKindMock.NAMED_TYPE, + name: { value: 'UserSettings' }, + }, + directives: [], + defaultValue: { + kind: astNodeKindMock.OBJECT, + fields: [ + { + name: { value: 'theme' }, + value: { + kind: astNodeKindMock.STRING, + value: 'dark', + }, + }, + { + name: { value: 'notifications' }, + value: { + kind: astNodeKindMock.BOOLEAN, + value: true, + }, + }, + ], + }, + }; + const definitionCategoryByNameMap = { + 'UserSettings': 'Input objects', + }; + + const result = mapField({ field, definitionCategoryByNameMap }); + + assert.deepStrictEqual(result, { + name: 'settings', + $ref: '#model/definitions/Input objects/UserSettings', + required: false, + fieldDirectives: [], + refDescription: undefined, + default: '{ theme: "dark", notifications: true }', + }); + }); + + it('should handle nested default values correctly', () => { + const field = { + name: { value: 'complexField' }, + type: { + kind: astNodeKindMock.NAMED_TYPE, + name: { value: 'ComplexInput' }, + }, + directives: [], + defaultValue: { + kind: astNodeKindMock.OBJECT, + fields: [ + { + name: { value: 'name' }, + value: { + kind: astNodeKindMock.STRING, + value: 'John', + }, + }, + { + name: { value: 'preferences' }, + value: { + kind: astNodeKindMock.OBJECT, + fields: [ + { + name: { value: 'favoriteColors' }, + value: { + kind: astNodeKindMock.LIST, + values: [ + { + kind: astNodeKindMock.STRING, + value: 'blue', + }, + { + kind: astNodeKindMock.STRING, + value: 'green', + }, + ], + }, + }, + ], + }, + }, + ], + }, + }; + const definitionCategoryByNameMap = { + 'ComplexInput': 'Input objects', + }; + + const result = mapField({ field, definitionCategoryByNameMap }); + + assert.deepStrictEqual(result, { + name: 'complexField', + $ref: '#model/definitions/Input objects/ComplexInput', + required: false, + fieldDirectives: [], + refDescription: undefined, + default: '{ name: "John", preferences: { favoriteColors: ["blue", "green"] } }', + }); + }); + + it('should properly handle quotes in string default values', () => { + // Test for top-level string (no quotes) + const topLevelField = { + name: { value: 'greeting' }, + type: { + kind: astNodeKindMock.NAMED_TYPE, + name: { value: 'String' }, + }, + directives: [], + defaultValue: { + kind: astNodeKindMock.STRING, + value: 'Hello World', + }, + }; + + // Test for nested string (should have quotes) + const nestedField = { + name: { value: 'user' }, + type: { + kind: astNodeKindMock.NAMED_TYPE, + name: { value: 'UserInput' }, + }, + directives: [], + defaultValue: { + kind: astNodeKindMock.OBJECT, + fields: [ + { + name: { value: 'name' }, + value: { + kind: astNodeKindMock.STRING, + value: 'Taras', + }, + }, + ], + }, + }; + + const definitionCategoryByNameMap = { + 'UserInput': 'Input objects', + }; + + const topLevelResult = mapField({ field: topLevelField, definitionCategoryByNameMap }); + const nestedResult = mapField({ field: nestedField, definitionCategoryByNameMap }); + + // Top-level string should not have quotes + assert.strictEqual(topLevelResult.default, 'Hello World'); + + // Nested string should have quotes + assert.strictEqual(nestedResult.default, '{ name: "Taras" }'); + }); + }); }); diff --git a/test/reverse_engineering/mappers/typeDefinitions/inputType.spec.js b/test/reverse_engineering/mappers/typeDefinitions/inputType.spec.js new file mode 100644 index 0000000..90a5a66 --- /dev/null +++ b/test/reverse_engineering/mappers/typeDefinitions/inputType.spec.js @@ -0,0 +1,460 @@ +const { describe, it, mock, afterEach } = require('node:test'); +const assert = require('assert'); + +// Mock dependencies +const sortByNameMock = mock.fn(({ items }) => items); +mock.module('../../../../reverse_engineering/helpers/sortByName', { + namedExports: { + sortByName: sortByNameMock, + }, +}); + +const mapDirectivesUsageMock = mock.fn(() => []); +mock.module('../../../../reverse_engineering/mappers/directiveUsage', { + namedExports: { + mapDirectivesUsage: mapDirectivesUsageMock, + }, +}); + +const mapFieldMock = mock.fn(({ field }) => ({ + name: field.name.value, + required: field.type.kind === 'NON_NULL_TYPE', +})); +mock.module('../../../../reverse_engineering/mappers/field', { + namedExports: { + mapField: mapFieldMock, + }, +}); + +const { getInputObjectTypeDefinitions } = require('../../../../reverse_engineering/mappers/typeDefinitions/inputType'); + +describe('getInputObjectTypeDefinitions', () => { + afterEach(() => { + sortByNameMock.mock.resetCalls(); + mapDirectivesUsageMock.mock.resetCalls(); + mapFieldMock.mock.resetCalls(); + }); + + it('should return an empty array when no input object types are provided', () => { + const result = getInputObjectTypeDefinitions({ + inputObjectTypes: [], + definitionCategoryByNameMap: {}, + fieldsOrder: {}, + }); + assert.deepStrictEqual(result, []); + }); + + it('should correctly map a simple input object type with no fields', () => { + const mockInputObjectType = { + name: { value: 'EmptyInput' }, + fields: [], + directives: [], + }; + + const expected = [ + { + type: 'input', + name: 'EmptyInput', + properties: {}, + required: [], + description: '', + typeDirectives: [], + }, + ]; + + const result = getInputObjectTypeDefinitions({ + inputObjectTypes: [mockInputObjectType], + definitionCategoryByNameMap: {}, + fieldsOrder: {}, + }); + + assert.deepStrictEqual(result, expected); + assert.strictEqual(mapDirectivesUsageMock.mock.calls.length, 1); + assert.strictEqual(sortByNameMock.mock.calls.length, 1); + assert.strictEqual(mapFieldMock.mock.calls.length, 0); + }); + + it('should correctly map an input object type with fields', () => { + const mockInputObjectType = { + name: { value: 'UserInput' }, + description: { value: 'Input for creating a user' }, + fields: [ + { + name: { value: 'username' }, + type: { kind: 'NON_NULL_TYPE' }, + directives: [], + }, + { + name: { value: 'email' }, + type: { kind: 'NAMED_TYPE' }, + directives: [], + }, + ], + directives: [], + }; + + // Expected result with the properties based on the mocked mapField responses + const expected = [ + { + type: 'input', + name: 'UserInput', + properties: { + username: { name: 'username', required: true }, + email: { name: 'email', required: false }, + }, + required: ['username'], + description: 'Input for creating a user', + typeDirectives: [], + }, + ]; + + const result = getInputObjectTypeDefinitions({ + inputObjectTypes: [mockInputObjectType], + definitionCategoryByNameMap: {}, + fieldsOrder: {}, + }); + + assert.deepStrictEqual(result, expected); + assert.strictEqual(mapDirectivesUsageMock.mock.calls.length, 1); + assert.strictEqual(sortByNameMock.mock.calls.length, 1); + assert.strictEqual(mapFieldMock.mock.calls.length, 2); + }); + + it('should correctly map an input object type with fields with default values', () => { + const mockInputObjectType = { + name: { value: 'FilterInput' }, + fields: [ + { + name: { value: 'limit' }, + type: { kind: 'NAMED_TYPE' }, + directives: [], + defaultValue: { kind: 'IntValue', value: '10' }, + }, + { + name: { value: 'sortBy' }, + type: { kind: 'NAMED_TYPE' }, + directives: [], + defaultValue: { kind: 'StringValue', value: 'createdAt' }, + }, + ], + directives: [], + }; + + // Mock mapField to return field with default value + mapFieldMock.mock.mockImplementation(({ field }) => ({ + name: field.name.value, + required: field.type.kind === 'NON_NULL_TYPE', + default: field.defaultValue ? field.defaultValue.value : undefined, + })); + + const expected = [ + { + type: 'input', + name: 'FilterInput', + properties: { + limit: { name: 'limit', required: false, default: '10' }, + sortBy: { name: 'sortBy', required: false, default: 'createdAt' }, + }, + required: [], + description: '', + typeDirectives: [], + }, + ]; + + const result = getInputObjectTypeDefinitions({ + inputObjectTypes: [mockInputObjectType], + definitionCategoryByNameMap: {}, + fieldsOrder: {}, + }); + + assert.deepStrictEqual(result, expected); + assert.strictEqual(mapDirectivesUsageMock.mock.calls.length, 1); + assert.strictEqual(sortByNameMock.mock.calls.length, 1); + assert.strictEqual(mapFieldMock.mock.calls.length, 2); + }); + + it('should correctly map an input object type with directives', () => { + const mockDirectiveResult = [ + { directiveName: '@deprecated', rawArgumentValues: 'reason: "Use NewInput instead"' }, + ]; + mapDirectivesUsageMock.mock.mockImplementationOnce(() => mockDirectiveResult); + + const mockInputObjectType = { + name: { value: 'OldInput' }, + fields: [], + directives: [ + { + name: { value: 'deprecated' }, + arguments: [{ name: { value: 'reason' }, value: { value: 'Use NewInput instead' } }], + }, + ], + }; + + const expected = [ + { + type: 'input', + name: 'OldInput', + properties: {}, + required: [], + description: '', + typeDirectives: mockDirectiveResult, + }, + ]; + + const result = getInputObjectTypeDefinitions({ + inputObjectTypes: [mockInputObjectType], + definitionCategoryByNameMap: {}, + fieldsOrder: {}, + }); + + assert.deepStrictEqual(result, expected); + assert.strictEqual(mapDirectivesUsageMock.mock.calls.length, 1); + assert.deepStrictEqual(mapDirectivesUsageMock.mock.calls[0].arguments[0], { + directives: mockInputObjectType.directives, + }); + }); + + it('should correctly handle fields order', () => { + const mockInputObjectType = { + name: { value: 'OrderedInput' }, + fields: [ + { name: { value: 'fieldB' }, type: { kind: 'NAMED_TYPE' }, directives: [] }, + { name: { value: 'fieldA' }, type: { kind: 'NAMED_TYPE' }, directives: [] }, + { name: { value: 'fieldC' }, type: { kind: 'NAMED_TYPE' }, directives: [] }, + ], + directives: [], + }; + + mapFieldMock.mock.mockImplementation(({ field }) => ({ + name: field.name.value, + required: false, + })); + + // Test both fieldsOrder options + const testCases = [ + { + fieldsOrder: 'alphabetical', + expectedOrder: ['fieldA', 'fieldB', 'fieldC'], // Alphabetical order + }, + { + fieldsOrder: 'field', + expectedOrder: ['fieldB', 'fieldA', 'fieldC'], // Original order + }, + ]; + + for (const testCase of testCases) { + sortByNameMock.mock.resetCalls(); + mapFieldMock.mock.resetCalls(); + + // Mock sortByName to simulate behavior based on fieldsOrder value + if (testCase.fieldsOrder === 'alphabetical') { + // For 'alphabetical', sort alphabetically + sortByNameMock.mock.mockImplementationOnce(({ items }) => { + return [...items].sort((a, b) => a.name.localeCompare(b.name)); + }); + } else { + // For 'field', maintain original order + sortByNameMock.mock.mockImplementationOnce(({ items }) => items); + } + + const result = getInputObjectTypeDefinitions({ + inputObjectTypes: [mockInputObjectType], + definitionCategoryByNameMap: {}, + fieldsOrder: testCase.fieldsOrder, + }); + + // Check that sortByName was called correctly + assert.strictEqual(sortByNameMock.mock.calls.length, 1); + assert.strictEqual(sortByNameMock.mock.calls[0].arguments[0].fieldsOrder, testCase.fieldsOrder); + + // Verify the result structure + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].name, 'OrderedInput'); + + const propertyNames = Object.keys(result[0].properties); + + // Verify the properties are in the expected order for this test case + assert.deepStrictEqual( + propertyNames, + testCase.expectedOrder, + `Field order should be ${testCase.expectedOrder.join(', ')} when fieldsOrder is "${testCase.fieldsOrder}"`, + ); + } + }); + + it('should correctly map multiple input object types', () => { + const mockInputObjectTypes = [ + { + name: { value: 'Input1' }, + fields: [], + directives: [], + }, + { + name: { value: 'Input2' }, + fields: [], + directives: [], + }, + ]; + + const result = getInputObjectTypeDefinitions({ + inputObjectTypes: mockInputObjectTypes, + definitionCategoryByNameMap: {}, + fieldsOrder: {}, + }); + + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0].name, 'Input1'); + assert.strictEqual(result[1].name, 'Input2'); + assert.strictEqual(mapDirectivesUsageMock.mock.calls.length, 2); + }); + + it('should handle undefined fields', () => { + const mockInputObjectType = { + name: { value: 'InputWithoutFields' }, + // fields is undefined + directives: [], + }; + + const expected = [ + { + type: 'input', + name: 'InputWithoutFields', + properties: {}, + required: [], + description: '', + typeDirectives: [], + }, + ]; + + const result = getInputObjectTypeDefinitions({ + inputObjectTypes: [mockInputObjectType], + definitionCategoryByNameMap: {}, + fieldsOrder: {}, + }); + + assert.deepStrictEqual(result, expected); + + // Verify no fields were processed + assert.strictEqual(mapFieldMock.mock.calls.length, 0); + + // Verify sortByName was still called (but with empty array) + assert.strictEqual(sortByNameMock.mock.calls.length, 1); + assert.deepStrictEqual(sortByNameMock.mock.calls[0].arguments[0].items, []); + }); + + it('should handle undefined directives', () => { + const mockInputObjectType = { + name: { value: 'InputWithoutDirectives' }, + fields: [], + // directives is undefined + }; + + const expected = [ + { + type: 'input', + name: 'InputWithoutDirectives', + properties: {}, + required: [], + description: '', + typeDirectives: [], // We expect an empty array since our mock returns [] + }, + ]; + + const result = getInputObjectTypeDefinitions({ + inputObjectTypes: [mockInputObjectType], + definitionCategoryByNameMap: {}, + fieldsOrder: {}, + }); + + assert.deepStrictEqual(result, expected); + + // Verify the mapDirectivesUsage was called with empty directives array + assert.strictEqual(mapDirectivesUsageMock.mock.calls.length, 1); + assert.deepStrictEqual(mapDirectivesUsageMock.mock.calls[0].arguments[0].directives, []); + }); + + it('should correctly handle input types with complex nested references', () => { + const mockInputObjectType = { + name: { value: 'ComplexInput' }, + fields: [ + { + name: { value: 'nestedInput' }, + type: { kind: 'NAMED_TYPE', name: { value: 'NestedInput' } }, + directives: [], + }, + { + name: { value: 'inputList' }, + type: { + kind: 'LIST_TYPE', + type: { kind: 'NAMED_TYPE', name: { value: 'NestedInput' } }, + }, + directives: [], + }, + ], + directives: [], + }; + + // Mock mapField to return different types of fields + mapFieldMock.mock.mockImplementationOnce( + ({ field }) => ({ + name: field.name.value, + required: false, + $ref: '#model/definitions/Input objects/NestedInput', + }), + 0, + ); + mapFieldMock.mock.mockImplementationOnce( + ({ field }) => ({ + name: field.name.value, + required: false, + type: 'List', + items: [ + { + $ref: '#model/definitions/Input objects/NestedInput', + required: false, + }, + ], + }), + 1, + ); + + const expected = [ + { + type: 'input', + name: 'ComplexInput', + properties: { + nestedInput: { + name: 'nestedInput', + required: false, + $ref: '#model/definitions/Input objects/NestedInput', + }, + inputList: { + name: 'inputList', + required: false, + type: 'List', + items: [ + { + $ref: '#model/definitions/Input objects/NestedInput', + required: false, + }, + ], + }, + }, + required: [], + description: '', + typeDirectives: [], + }, + ]; + + const result = getInputObjectTypeDefinitions({ + inputObjectTypes: [mockInputObjectType], + definitionCategoryByNameMap: { + 'NestedInput': 'Input objects', + }, + fieldsOrder: {}, + }); + + assert.deepStrictEqual(result, expected); + assert.strictEqual(mapFieldMock.mock.calls.length, 2); + }); +});