diff --git a/forward_engineering/mappers/arguments.js b/forward_engineering/mappers/arguments.js index b928291..8119ce7 100644 --- a/forward_engineering/mappers/arguments.js +++ b/forward_engineering/mappers/arguments.js @@ -1,5 +1,5 @@ /** - * @import {Argument, ArgumentsResultStatement, IdToNameMap, FEStatement} from "../../shared/types/types" + * @import {ArgumentsResultStatement, IdToNameMap, FEStatement, FEArgument} from "../../shared/types/types" */ const { getDirectivesUsageStatement } = require('./directiveUsageStatements'); @@ -16,7 +16,7 @@ const EMPTY_LIST = '[]'; * Gets the type of the argument with the required keyword. * * @param {object} args - Arguments object. - * @param {Argument} args.graphqlArgument - The argument to map. + * @param {FEArgument} args.graphqlArgument - The argument to map. * @param {IdToNameMap} [args.idToNameMap] - The ID to name map of all available types in model. * @returns {string} Returns the type of the argument with the required keyword */ @@ -42,7 +42,7 @@ const getArgumentType = ({ graphqlArgument, idToNameMap = {} }) => { * Maps an argument to a string with all configured properties. * * @param {object} args - Arguments object. - * @param {Argument} args.graphqlArgument - The argument to map. + * @param {FEArgument} args.graphqlArgument - The argument to map. * @param {IdToNameMap} [args.idToNameMap] - The ID to name map of all available types in model. * @returns {FEStatement} Returns the argument as a FEStatement */ @@ -72,7 +72,7 @@ const mapArgument = ({ graphqlArgument, idToNameMap = {} }) => { * Maps an array of arguments to a formatted string with all configured properties. * * @param {object} args - Arguments object. - * @param {Argument[]} [args.graphqlArguments] - The arguments to map. + * @param {FEArgument[]} [args.graphqlArguments] - The arguments to map. * @param {IdToNameMap} [args.idToNameMap] - The ID to name map of all available types in model. * @returns {ArgumentsResultStatement} Returns an object containing the arguments as a formatted string and a warning * comment if any. diff --git a/reverse_engineering/mappers/arguments.js b/reverse_engineering/mappers/arguments.js new file mode 100644 index 0000000..eb1537c --- /dev/null +++ b/reverse_engineering/mappers/arguments.js @@ -0,0 +1,105 @@ +/** + * @import {InputValueDefinitionNode, TypeNode} from "graphql" + * @import {ArgumentTypeInfo, REArgument} from "./../../shared/types/types" + */ + +const { mapDirectivesUsage } = require('./directiveUsage'); +const { astNodeKind } = require('../constants/graphqlAST'); +const { parseDefaultValue } = require('./defaultValue'); + +/** + * Maps field arguments to the REArgument format + * + * @param {object} params + * @param {InputValueDefinitionNode[]} params.fieldArguments - The field arguments + * @returns {REArgument[]} The mapped arguments + */ +function getArguments({ fieldArguments = [] }) { + if (!fieldArguments.length) { + return []; + } + + return fieldArguments.map(argument => mapArgument({ argument })); +} + +/** + * Maps a single argument to the REArgument format + * + * @param {object} params + * @param {InputValueDefinitionNode} params.argument - The argument to map + * @returns {REArgument} The mapped argument + */ +function mapArgument({ argument }) { + const typeInfo = getArgumentTypeInfo({ type: argument.type }); + + const mappedArgument = { + name: argument.name.value, + type: typeInfo.typeName, + description: argument.description?.value || '', + directives: mapDirectivesUsage({ directives: [...(argument.directives || [])] }), + required: typeInfo.required, + }; + + // Add list items for List types + if (typeInfo.isList) { + mappedArgument.listItems = [ + { + type: typeInfo.innerTypeName, + required: typeInfo.innerRequired, + }, + ]; + } + + // Add default value if present + if (argument.defaultValue) { + mappedArgument.default = parseDefaultValue(argument.defaultValue); + } + + return mappedArgument; +} + +/** + * Gets type information for an argument by unwrapping non-null and list types + * + * @param {object} params + * @param {TypeNode} params.type - The GraphQL type node + * @returns {ArgumentTypeInfo} Information about the argument type + */ +function getArgumentTypeInfo({ type }) { + if (type.kind === astNodeKind.NON_NULL_TYPE) { + const innerTypeInfo = getArgumentTypeInfo({ type: type.type }); + return { + ...innerTypeInfo, + required: true, + }; + } + + if (type.kind === astNodeKind.LIST_TYPE) { + const innerTypeInfo = getArgumentTypeInfo({ type: type.type }); + return { + typeName: 'List', + isList: true, + innerTypeName: innerTypeInfo.typeName, + innerRequired: innerTypeInfo.required, + required: false, + }; + } + + if (type.kind === astNodeKind.NAMED_TYPE) { + const typeName = type.name.value; + + return { + typeName: typeName, + required: false, + }; + } + + return { + typeName: 'String', + required: false, + }; +} + +module.exports = { + getArguments, +}; diff --git a/reverse_engineering/mappers/defaultValue.js b/reverse_engineering/mappers/defaultValue.js new file mode 100644 index 0000000..04a8f23 --- /dev/null +++ b/reverse_engineering/mappers/defaultValue.js @@ -0,0 +1,47 @@ +/** + * @import {ValueNode} from "graphql" + * @import {InputFieldDefaultValue} from "./../../shared/types/types" + */ + +const { astNodeKind } = require('../constants/graphqlAST'); + +/** + * 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 {InputFieldDefaultValue} 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 ''; + } +} + +module.exports = { + parseDefaultValue, +}; diff --git a/reverse_engineering/mappers/field.js b/reverse_engineering/mappers/field.js index aa8b8b1..9331065 100644 --- a/reverse_engineering/mappers/field.js +++ b/reverse_engineering/mappers/field.js @@ -6,6 +6,8 @@ const { mapDirectivesUsage } = require('./directiveUsage'); const { astNodeKind } = require('../constants/graphqlAST'); const { BUILT_IN_SCALAR_LIST } = require('../constants/types'); +const { getArguments } = require('./arguments'); +const { parseDefaultValue } = require('./defaultValue'); const { sortByName } = require('../helpers/sortByName'); /** @@ -56,57 +58,25 @@ function mapField({ field, definitionCategoryByNameMap }) { sharedProperties.default = parseDefaultValue(field.defaultValue); } + let mappedArguments; + if ('arguments' in field) { + mappedArguments = getArguments({ fieldArguments: [...(field.arguments || [])] }); + } + if ('$ref' in fieldTypeProperties) { return { ...sharedProperties, refDescription: description, - // TODO: add arguments + ...(mappedArguments && { arguments: mappedArguments }), }; } return { ...sharedProperties, description, - // TODO: add arguments + ...(mappedArguments && { arguments: mappedArguments }), // Added handling for mappedArguments }; } -/** - * 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/directive.js b/reverse_engineering/mappers/typeDefinitions/directive.js index 0105395..e123aa4 100644 --- a/reverse_engineering/mappers/typeDefinitions/directive.js +++ b/reverse_engineering/mappers/typeDefinitions/directive.js @@ -3,6 +3,7 @@ * @import {REDirectiveDefinition, DirectiveLocations} from "../../../shared/types/types" */ +const { getArguments } = require('../arguments'); const { getDirectiveName } = require('../directiveName'); const locationMap = { @@ -57,7 +58,7 @@ function mapDirective({ directive }) { type: 'directive', name: getDirectiveName({ name: directive.name.value }), description: directive.description?.value || '', - arguments: [], // TODO: implement argument mapping + arguments: getArguments({ fieldArguments: [...(directive.arguments || [])] }), directiveLocations: locations, }; } diff --git a/shared/types/fe.d.ts b/shared/types/fe.d.ts index 685d24e..272a81a 100644 --- a/shared/types/fe.d.ts +++ b/shared/types/fe.d.ts @@ -62,7 +62,9 @@ export type FEDirectiveLocations = DirectiveLocations & { GUID: string; }; -export type FEDirectiveDefinition = DirectiveDefinition & { +export type FEArgument = Argument; + +export type FEDirectiveDefinition = DirectiveDefinition & { GUID: string; additionalProperties?: boolean; ignore_z_value: boolean; diff --git a/shared/types/re.d.ts b/shared/types/re.d.ts index 0b16441..e65caac 100644 --- a/shared/types/re.d.ts +++ b/shared/types/re.d.ts @@ -8,6 +8,7 @@ import { EnumValue, StructuredDirective, InputFieldDefaultValue, + Argument, EntityDetails, } from './shared'; @@ -266,5 +267,15 @@ export type DefinitionTypeName = | 'Directives'; export type DefinitionNameToTypeNameMap = Record; +export type REArgument = Argument; + +export type ArgumentTypeInfo = { + typeName: string; + required: boolean; + isList?: boolean; + innerTypeName?: string; + innerRequired?: boolean; +}; + export type TestConnectionCallback = (err?: Error | unknown) => void; export type DisconnectCallback = TestConnectionCallback; diff --git a/shared/types/shared.d.ts b/shared/types/shared.d.ts index 897244b..c5cf246 100644 --- a/shared/types/shared.d.ts +++ b/shared/types/shared.d.ts @@ -96,7 +96,7 @@ type RegularFieldData = { description?: string; // Description of the field fieldDirectives?: DirectiveUsage[]; // Directives for the field items?: ArrayItems; // Items of the List type - arguments?: Argument[]; // Arguments of the field + arguments?: Argument[]; // Arguments of the field default?: InputFieldDefaultValue; // Default value of the field }; @@ -105,7 +105,7 @@ type ReferenceFieldData = { isActivated?: boolean; // If the field is activated refDescription?: string; // Description of the reference fieldDirectives?: DirectiveUsage[]; // Directives for the field - arguments?: Argument[]; // Arguments of the field + arguments?: Argument[]; // Arguments of the field default?: InputFieldDefaultValue; // Default value of the reference }; @@ -120,13 +120,12 @@ type ArgumentListItem = { required?: boolean; }; -export type Argument = { - id: string; +export type Argument = { type: string; name: string; default?: string; description?: string; - directives?: DirectivePropertyData[]; + directives?: DirectiveUsage[]; required?: boolean; listItems?: ArgumentListItem[]; }; diff --git a/test/reverse_engineering/mappers/arguments.spec.js b/test/reverse_engineering/mappers/arguments.spec.js new file mode 100644 index 0000000..5c6bf33 --- /dev/null +++ b/test/reverse_engineering/mappers/arguments.spec.js @@ -0,0 +1,314 @@ +const { describe, it, mock, afterEach } = require('node:test'); +const assert = require('assert'); + +// Mock dependencies +const mapDirectivesUsageMock = mock.fn(() => []); +mock.module('../../../reverse_engineering/mappers/directiveUsage', { + namedExports: { + mapDirectivesUsage: mapDirectivesUsageMock, + }, +}); + +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 parseDefaultValue +const parseDefaultValueMock = mock.fn(value => { + // Simple default implementation that returns string values + if (value.kind === astNodeKindMock.INT) { + return parseInt(value.value); + } else if (value.kind === astNodeKindMock.STRING) { + return value.value; + } else if (value.kind === astNodeKindMock.BOOLEAN) { + return value.value.toString(); + } + return 'default-value'; +}); + +mock.module('../../../reverse_engineering/mappers/defaultValue', { + namedExports: { + parseDefaultValue: parseDefaultValueMock, + }, +}); + +const { getArguments } = require('../../../reverse_engineering/mappers/arguments'); + +describe('arguments', () => { + afterEach(() => { + mapDirectivesUsageMock.mock.resetCalls(); + parseDefaultValueMock.mock.resetCalls(); + }); + + describe('getArguments', () => { + it('should return an empty array when no arguments are provided', () => { + const result = getArguments({ fieldArguments: [] }); + assert.deepStrictEqual(result, []); + assert.strictEqual(mapDirectivesUsageMock.mock.calls.length, 0); + }); + + it('should return an empty array when fieldArguments parameter is undefined', () => { + const result = getArguments({}); + assert.deepStrictEqual(result, []); + assert.strictEqual(mapDirectivesUsageMock.mock.calls.length, 0); + }); + + it('should map a simple argument with a scalar type', () => { + const fieldArguments = [ + { + name: { value: 'name' }, + type: { + kind: astNodeKindMock.NAMED_TYPE, + name: { value: 'String' }, + }, + directives: [], + }, + ]; + + const result = getArguments({ fieldArguments }); + + assert.deepStrictEqual(result, [ + { + name: 'name', + type: 'String', + description: '', + directives: [], + required: false, + }, + ]); + assert.strictEqual(mapDirectivesUsageMock.mock.calls.length, 1); + }); + + it('should map multiple arguments', () => { + const fieldArguments = [ + { + name: { value: 'name' }, + type: { + kind: astNodeKindMock.NAMED_TYPE, + name: { value: 'String' }, + }, + directives: [], + }, + { + name: { value: 'age' }, + type: { + kind: astNodeKindMock.NAMED_TYPE, + name: { value: 'Int' }, + }, + directives: [], + }, + ]; + + const result = getArguments({ fieldArguments }); + + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0].name, 'name'); + assert.strictEqual(result[0].type, 'String'); + assert.strictEqual(result[1].name, 'age'); + assert.strictEqual(result[1].type, 'Int'); + assert.strictEqual(mapDirectivesUsageMock.mock.calls.length, 2); + }); + + it('should map an argument with a description', () => { + const fieldArguments = [ + { + name: { value: 'name' }, + description: { value: 'User name' }, + type: { + kind: astNodeKindMock.NAMED_TYPE, + name: { value: 'String' }, + }, + directives: [], + }, + ]; + + const result = getArguments({ fieldArguments }); + + assert.deepStrictEqual(result, [ + { + name: 'name', + type: 'String', + description: 'User name', + directives: [], + required: false, + }, + ]); + }); + + it('should map an argument with directives', () => { + const mockDirectives = [{ name: { value: 'deprecated' }, arguments: [] }]; + const mockMappedDirectives = [{ directiveName: '@deprecated', rawArgumentValues: '' }]; + mapDirectivesUsageMock.mock.mockImplementationOnce(() => mockMappedDirectives); + + const fieldArguments = [ + { + name: { value: 'oldArg' }, + type: { + kind: astNodeKindMock.NAMED_TYPE, + name: { value: 'String' }, + }, + directives: mockDirectives, + }, + ]; + + const result = getArguments({ fieldArguments }); + + assert.deepStrictEqual(result, [ + { + name: 'oldArg', + type: 'String', + description: '', + directives: mockMappedDirectives, + required: false, + }, + ]); + assert.strictEqual(mapDirectivesUsageMock.mock.calls.length, 1); + assert.deepStrictEqual(mapDirectivesUsageMock.mock.calls[0].arguments[0], { + directives: mockDirectives, + }); + }); + + it('should map an argument with a non-null type', () => { + const fieldArguments = [ + { + name: { value: 'id' }, + type: { + kind: astNodeKindMock.NON_NULL_TYPE, + type: { + kind: astNodeKindMock.NAMED_TYPE, + name: { value: 'ID' }, + }, + }, + directives: [], + }, + ]; + + const result = getArguments({ fieldArguments }); + + assert.deepStrictEqual(result, [ + { + name: 'id', + type: 'ID', + description: '', + directives: [], + required: true, + }, + ]); + }); + + it('should map an argument with a list type', () => { + const fieldArguments = [ + { + name: { value: 'tags' }, + type: { + kind: astNodeKindMock.LIST_TYPE, + type: { + kind: astNodeKindMock.NAMED_TYPE, + name: { value: 'String' }, + }, + }, + directives: [], + }, + ]; + + const result = getArguments({ fieldArguments }); + + assert.deepStrictEqual(result, [ + { + name: 'tags', + type: 'List', + description: '', + directives: [], + required: false, + listItems: [ + { + type: 'String', + required: false, + }, + ], + }, + ]); + }); + + it('should map an argument with a non-null list of non-null types', () => { + const fieldArguments = [ + { + name: { value: 'requiredTags' }, + type: { + kind: astNodeKindMock.NON_NULL_TYPE, + type: { + kind: astNodeKindMock.LIST_TYPE, + type: { + kind: astNodeKindMock.NON_NULL_TYPE, + type: { + kind: astNodeKindMock.NAMED_TYPE, + name: { value: 'String' }, + }, + }, + }, + }, + directives: [], + }, + ]; + + const result = getArguments({ fieldArguments }); + + assert.deepStrictEqual(result, [ + { + name: 'requiredTags', + type: 'List', + description: '', + directives: [], + required: true, + listItems: [ + { + type: 'String', + required: true, + }, + ], + }, + ]); + }); + + it('should fallback to String for unknown type kinds', () => { + const fieldArguments = [ + { + name: { value: 'unknownType' }, + type: { + kind: 'UnknownKind', // Not one of the recognized kinds + }, + directives: [], + }, + ]; + + const result = getArguments({ fieldArguments }); + + assert.deepStrictEqual(result, [ + { + name: 'unknownType', + type: 'String', // Fallback to String + description: '', + directives: [], + required: false, + }, + ]); + }); + }); +});