diff --git a/reverse_engineering/helpers/getDefinitionReferencePath.js b/reverse_engineering/helpers/getDefinitionReferencePath.js new file mode 100644 index 0000000..1192166 --- /dev/null +++ b/reverse_engineering/helpers/getDefinitionReferencePath.js @@ -0,0 +1,19 @@ +/** + * @import {DefinitionTypeName} from "../../shared/types/types" + */ + +/** + * Returns the path to the definition reference + * + * @param {object} params + * @param {DefinitionTypeName} params.definitionCategoryName - The definition category name + * @param {string} params.definitionName - The definition name + * @returns {string} The path to the definition reference + */ +function getDefinitionReferencePath({ definitionCategoryName, definitionName }) { + return `#model/definitions/${definitionCategoryName}/${definitionName}`; +} + +module.exports = { + getDefinitionReferencePath, +}; diff --git a/reverse_engineering/mappers/field.js b/reverse_engineering/mappers/field.js index 9331065..d8f6645 100644 --- a/reverse_engineering/mappers/field.js +++ b/reverse_engineering/mappers/field.js @@ -1,11 +1,12 @@ /** - * @import {FieldDefinitionNode, TypeNode, InputValueDefinitionNode, ValueNode} from "graphql" - * @import {DefinitionNameToTypeNameMap, FieldsOrder, FieldTypeProperties, InputTypeFieldProperties, PreProcessedFieldData, REFieldsSchemaProperties, REPropertiesSchema} from "./../../shared/types/types" + * @import {FieldDefinitionNode, TypeNode, InputValueDefinitionNode} from "graphql" + * @import {DefinitionNameToTypeNameMap, FieldsOrder, FieldTypeProperties, PreProcessedFieldData, REFieldsSchemaProperties, REPropertiesSchema} from "./../../shared/types/types" */ const { mapDirectivesUsage } = require('./directiveUsage'); const { astNodeKind } = require('../constants/graphqlAST'); const { BUILT_IN_SCALAR_LIST } = require('../constants/types'); +const { getDefinitionReferencePath } = require('../helpers/getDefinitionReferencePath'); const { getArguments } = require('./arguments'); const { parseDefaultValue } = require('./defaultValue'); const { sortByName } = require('../helpers/sortByName'); @@ -118,7 +119,7 @@ function getTypeProperties({ type, definitionCategoryByNameMap }) { const definitionCategoryName = definitionCategoryByNameMap[typeName]; if (definitionCategoryName) { return { - '$ref': `#model/definitions/${definitionCategoryName}/${typeName}`, + '$ref': getDefinitionReferencePath({ definitionCategoryName, definitionName: typeName }), required: false, }; } diff --git a/reverse_engineering/mappers/typeDefinitions/typeDefinitions.js b/reverse_engineering/mappers/typeDefinitions/typeDefinitions.js index b5f7500..97239fc 100644 --- a/reverse_engineering/mappers/typeDefinitions/typeDefinitions.js +++ b/reverse_engineering/mappers/typeDefinitions/typeDefinitions.js @@ -1,21 +1,23 @@ /** * @import {DefinitionNode} from "graphql" * @import {REDirectiveDefinition, - * FieldsOrder, - * RECustomScalarDefinition, - * REDefinition, - * REModelDefinitionsSchema, - * DefinitionREStructure, - * DirectiveStructureType, - * REEnumDefinition, - * EnumStructureType, - * ObjectStructureType, - * ScalarStructureType, + * FieldsOrder, + * RECustomScalarDefinition, + * REDefinition, + * REModelDefinitionsSchema, + * DefinitionREStructure, + * DirectiveStructureType, + * REEnumDefinition, + * EnumStructureType, + * ObjectStructureType, + * ScalarStructureType, * REObjectTypeDefinition, * InterfaceStructureType, * REInterfaceDefinition, * REInputTypeDefinition, * InputStructureType, + * REUnionDefinition, + * UnionStructureType, * DefinitionNameToTypeNameMap} from "../../../shared/types/types" */ @@ -28,6 +30,7 @@ const { getObjectTypeDefinitions } = require('./objectType'); const { getEnumTypeDefinitions } = require('./enum'); const { getInterfaceDefinitions } = require('./interface'); const { getInputObjectTypeDefinitions } = require('./inputType'); +const { getUnionTypeDefinitions } = require('./union'); /** * Gets the type definitions structure @@ -72,6 +75,11 @@ function getTypeDefinitions({ typeDefinitions, fieldsOrder, rootTypeNames, defin fieldsOrder, }); + const unions = getUnionTypeDefinitions({ + unions: findNodesByKind({ nodes: typeDefinitions, kind: astNodeKind.UNION_TYPE_DEFINITION }), + definitionCategoryByNameMap, + }); + const definitions = getTypeDefinitionsStructure({ fieldsOrder, directives, @@ -80,6 +88,7 @@ function getTypeDefinitions({ typeDefinitions, fieldsOrder, rootTypeNames, defin objectTypes, interfaces, inputTypes, + unions, }); return definitions; @@ -96,6 +105,7 @@ function getTypeDefinitions({ typeDefinitions, fieldsOrder, rootTypeNames, defin * @param {REObjectTypeDefinition[]} params.objectTypes - The object type definitions * @param {REInterfaceDefinition[]} params.interfaces - The interface definitions * @param {REInputTypeDefinition[]} params.inputTypes - The input type definitions + * @param {REUnionDefinition[]} params.unions - The union definitions * @returns {REModelDefinitionsSchema} The type definitions structure */ function getTypeDefinitionsStructure({ @@ -106,6 +116,7 @@ function getTypeDefinitionsStructure({ objectTypes, interfaces, inputTypes, + unions, }) { const definitions = { ['Directives']: /** @type {DirectiveStructureType} */ ( @@ -150,6 +161,13 @@ function getTypeDefinitionsStructure({ properties: inputTypes, }) ), + ['Unions']: /** @type {UnionStructureType} */ ( + getDefinitionCategoryStructure({ + fieldsOrder, + subtype: 'union', + properties: unions, + }) + ), }; return { diff --git a/reverse_engineering/mappers/typeDefinitions/union.js b/reverse_engineering/mappers/typeDefinitions/union.js new file mode 100644 index 0000000..42cfea8 --- /dev/null +++ b/reverse_engineering/mappers/typeDefinitions/union.js @@ -0,0 +1,67 @@ +/** + * @import {NamedTypeNode, UnionTypeDefinitionNode} from "graphql" + * @import {DefinitionNameToTypeNameMap, REUnionDefinition, REUnionMemberType} from "../../../shared/types/types" + */ + +const { getDefinitionReferencePath } = require('../../helpers/getDefinitionReferencePath'); +const { mapDirectivesUsage } = require('../directiveUsage'); + +/** + * Maps union type definitions + * + * @param {object} params + * @param {UnionTypeDefinitionNode[]} params.unions - The union types + * @param {DefinitionNameToTypeNameMap} params.definitionCategoryByNameMap - The definition category by name map + * @returns {REUnionDefinition[]} The mapped union type definitions + */ +function getUnionTypeDefinitions({ unions = [], definitionCategoryByNameMap }) { + return unions.map(union => mapUnion({ union, definitionCategoryByNameMap })); +} + +/** + * Maps a single union type definition + * + * @param {object} params + * @param {UnionTypeDefinitionNode} params.union - The union to map + * @param {DefinitionNameToTypeNameMap} params.definitionCategoryByNameMap - The definition category by name map + * @returns {REUnionDefinition} The mapped union type definition + */ +function mapUnion({ union, definitionCategoryByNameMap }) { + return { + type: 'union', + name: union.name.value, + description: union.description?.value || '', + typeDirectives: mapDirectivesUsage({ directives: [...(union.directives || [])] }), + oneOf: mapUnionTypes({ types: [...(union.types || [])], definitionCategoryByNameMap }), + }; +} + +/** + * Maps the union types to references + * + * @param {object} params + * @param {NamedTypeNode[]} params.types - The types that the union can be + * @param {DefinitionNameToTypeNameMap} params.definitionCategoryByNameMap - The definition category by name map + * @returns {REUnionMemberType[]} The mapped union types as references + */ +function mapUnionTypes({ types, definitionCategoryByNameMap }) { + return types.map(type => { + const typeName = type.name.value; + const definitionCategoryName = definitionCategoryByNameMap[typeName]; + + if (definitionCategoryName) { + return { + $ref: getDefinitionReferencePath({ definitionCategoryName, definitionName: typeName }), + }; + } + + // Fallback to Objects + return { + $ref: getDefinitionReferencePath({ definitionCategoryName: 'Objects', definitionName: typeName }), + }; + }); +} + +module.exports = { + getUnionTypeDefinitions, +}; diff --git a/shared/types/re.d.ts b/shared/types/re.d.ts index e65caac..815574a 100644 --- a/shared/types/re.d.ts +++ b/shared/types/re.d.ts @@ -135,6 +135,7 @@ type REObjectDefinitionsSchema = Record; type REEnumDefinitionsSchema = Record; type REInterfaceDefinitionsSchema = Record; type REInputDefinitionsSchema = Record; +type REUnionDefinitionsSchema = Record; export type REDefinition = | RECustomScalarDefinition @@ -142,7 +143,8 @@ export type REDefinition = | REEnumDefinition | REObjectTypeDefinition | REInterfaceDefinition - | REInputTypeDefinition; + | REInputTypeDefinition + | REUnionDefinition; export type REModelDefinitionsSchema = { definitions: { @@ -151,6 +153,8 @@ export type REModelDefinitionsSchema = { Objects: ObjectStructureType; Enums: EnumStructureType; Interfaces: InterfaceStructureType; + 'Input objects': InputStructureType; + Unions: UnionStructureType; }; }; @@ -160,7 +164,8 @@ export type DefinitionREStructure = | EnumStructureType | ObjectStructureType | InterfaceStructureType - | InputStructureType; + | InputStructureType + | UnionStructureType; type StructureType = { type: 'type'; @@ -188,6 +193,10 @@ export type InputStructureType = StructureType & { subtype: 'input'; }; +export type UnionStructureType = StructureType & { + subtype: 'union'; +}; + export type RECustomScalarDefinition = CustomScalarDefinition & { type: 'scalar'; name: string; @@ -230,6 +239,18 @@ export type REInputTypeDefinition = REObjectLikeDefinition & { type: 'input'; }; +export type REUnionDefinition = { + type: 'union'; + name: string; + description?: string; + typeDirectives?: StructuredDirective[]; + oneOf: REUnionMemberType[]; +}; + +export type REUnionMemberType = { + $ref: string; +}; + export type PreProcessedFieldData = FieldData & FieldTypeProperties & { name: string; diff --git a/test/reverse_engineering/mappers/typeDefinitions/union.spec.js b/test/reverse_engineering/mappers/typeDefinitions/union.spec.js new file mode 100644 index 0000000..1863d0c --- /dev/null +++ b/test/reverse_engineering/mappers/typeDefinitions/union.spec.js @@ -0,0 +1,305 @@ +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 { getUnionTypeDefinitions } = require('../../../../reverse_engineering/mappers/typeDefinitions/union'); + +describe('getUnionTypeDefinitions', () => { + afterEach(() => { + mapDirectivesUsageMock.mock.resetCalls(); + }); + + it('should return an empty array when no union types are provided', () => { + const result = getUnionTypeDefinitions({ + unions: [], + definitionCategoryByNameMap: {}, + }); + assert.deepStrictEqual(result, []); + }); + + it('should correctly map a simple union with member types', () => { + const mockUnion = { + name: { value: 'SearchResult' }, + types: [{ name: { value: 'User' } }, { name: { value: 'Post' } }], + directives: [], + }; + + const definitionCategoryByNameMap = { + 'User': 'Objects', + 'Post': 'Objects', + }; + + const expected = [ + { + type: 'union', + name: 'SearchResult', + description: '', + typeDirectives: [], + oneOf: [{ $ref: '#model/definitions/Objects/User' }, { $ref: '#model/definitions/Objects/Post' }], + }, + ]; + + const result = getUnionTypeDefinitions({ + unions: [mockUnion], + definitionCategoryByNameMap, + }); + + assert.deepStrictEqual(result, expected); + assert.strictEqual(mapDirectivesUsageMock.mock.calls.length, 1); + assert.deepStrictEqual(mapDirectivesUsageMock.mock.calls[0].arguments[0], { + directives: [], + }); + }); + + it('should correctly map a union with description', () => { + const mockUnion = { + name: { value: 'Result' }, + description: { value: 'A result can be either success or error' }, + types: [{ name: { value: 'Success' } }, { name: { value: 'Error' } }], + directives: [], + }; + + const definitionCategoryByNameMap = { + 'Success': 'Objects', + 'Error': 'Objects', + }; + + const expected = [ + { + type: 'union', + name: 'Result', + description: 'A result can be either success or error', + typeDirectives: [], + oneOf: [{ $ref: '#model/definitions/Objects/Success' }, { $ref: '#model/definitions/Objects/Error' }], + }, + ]; + + const result = getUnionTypeDefinitions({ + unions: [mockUnion], + definitionCategoryByNameMap, + }); + + assert.deepStrictEqual(result, expected); + assert.strictEqual(mapDirectivesUsageMock.mock.calls.length, 1); + }); + + it('should correctly map a union with directives', () => { + const mockDirectiveResult = [ + { directiveName: '@deprecated', rawArgumentValues: 'reason: "Use NewResult instead"' }, + ]; + mapDirectivesUsageMock.mock.mockImplementationOnce(() => mockDirectiveResult); + + const mockUnion = { + name: { value: 'OldResult' }, + types: [{ name: { value: 'TypeA' } }, { name: { value: 'TypeB' } }], + directives: [ + { + name: { value: 'deprecated' }, + arguments: [{ name: { value: 'reason' }, value: { value: 'Use NewResult instead' } }], + }, + ], + }; + + const definitionCategoryByNameMap = { + 'TypeA': 'Objects', + 'TypeB': 'Objects', + }; + + const expected = [ + { + type: 'union', + name: 'OldResult', + description: '', + typeDirectives: mockDirectiveResult, + oneOf: [{ $ref: '#model/definitions/Objects/TypeA' }, { $ref: '#model/definitions/Objects/TypeB' }], + }, + ]; + + const result = getUnionTypeDefinitions({ + unions: [mockUnion], + definitionCategoryByNameMap, + }); + + assert.deepStrictEqual(result, expected); + assert.strictEqual(mapDirectivesUsageMock.mock.calls.length, 1); + assert.deepStrictEqual(mapDirectivesUsageMock.mock.calls[0].arguments[0], { + directives: mockUnion.directives, + }); + }); + + it('should correctly map union with types from different definition categories', () => { + const mockUnion = { + name: { value: 'MixedResult' }, + types: [{ name: { value: 'CustomObject' } }, { name: { value: 'StandardInterface' } }], + directives: [], + }; + + const definitionCategoryByNameMap = { + 'CustomObject': 'Objects', + 'StandardInterface': 'Interfaces', + }; + + const expected = [ + { + type: 'union', + name: 'MixedResult', + description: '', + typeDirectives: [], + oneOf: [ + { $ref: '#model/definitions/Objects/CustomObject' }, + { $ref: '#model/definitions/Interfaces/StandardInterface' }, + ], + }, + ]; + + const result = getUnionTypeDefinitions({ + unions: [mockUnion], + definitionCategoryByNameMap, + }); + + assert.deepStrictEqual(result, expected); + }); + + it('should handle undefined member types array', () => { + const mockUnion = { + name: { value: 'EmptyUnion' }, + // types is undefined + directives: [], + }; + + const expected = [ + { + type: 'union', + name: 'EmptyUnion', + description: '', + typeDirectives: [], + oneOf: [], + }, + ]; + + const result = getUnionTypeDefinitions({ + unions: [mockUnion], + definitionCategoryByNameMap: {}, + }); + + assert.deepStrictEqual(result, expected); + }); + + it('should handle undefined directives', () => { + const mockUnion = { + name: { value: 'UnionWithoutDirectives' }, + types: [{ name: { value: 'TypeA' } }], + // directives is undefined + }; + + const definitionCategoryByNameMap = { + 'TypeA': 'Objects', + }; + + const expected = [ + { + type: 'union', + name: 'UnionWithoutDirectives', + description: '', + typeDirectives: [], + oneOf: [{ $ref: '#model/definitions/Objects/TypeA' }], + }, + ]; + + const result = getUnionTypeDefinitions({ + unions: [mockUnion], + definitionCategoryByNameMap, + }); + + assert.deepStrictEqual(result, expected); + assert.strictEqual(mapDirectivesUsageMock.mock.calls.length, 1); + assert.deepStrictEqual(mapDirectivesUsageMock.mock.calls[0].arguments[0].directives, []); + }); + + it('should correctly map multiple union types', () => { + const mockUnions = [ + { + name: { value: 'Union1' }, + types: [{ name: { value: 'TypeA' } }], + directives: [], + }, + { + name: { value: 'Union2' }, + types: [{ name: { value: 'TypeB' } }, { name: { value: 'TypeC' } }], + directives: [], + }, + ]; + + const definitionCategoryByNameMap = { + 'TypeA': 'Objects', + 'TypeB': 'Objects', + 'TypeC': 'Objects', + }; + + const expected = [ + { + type: 'union', + name: 'Union1', + description: '', + typeDirectives: [], + oneOf: [{ $ref: '#model/definitions/Objects/TypeA' }], + }, + { + type: 'union', + name: 'Union2', + description: '', + typeDirectives: [], + oneOf: [{ $ref: '#model/definitions/Objects/TypeB' }, { $ref: '#model/definitions/Objects/TypeC' }], + }, + ]; + + const result = getUnionTypeDefinitions({ + unions: mockUnions, + definitionCategoryByNameMap, + }); + + assert.deepStrictEqual(result, expected); + assert.strictEqual(mapDirectivesUsageMock.mock.calls.length, 2); + }); + + it('should default to Objects category if no mapping exists for a type', () => { + const mockUnion = { + name: { value: 'FallbackUnion' }, + types: [{ name: { value: 'KnownType' } }, { name: { value: 'UnknownType' } }], + directives: [], + }; + + const definitionCategoryByNameMap = { + 'KnownType': 'Objects', + // UnknownType is not in the map + }; + + const expected = [ + { + type: 'union', + name: 'FallbackUnion', + description: '', + typeDirectives: [], + oneOf: [ + { $ref: '#model/definitions/Objects/KnownType' }, + { $ref: '#model/definitions/Objects/UnknownType' }, // Fallback to Objects + ], + }, + ]; + + const result = getUnionTypeDefinitions({ + unions: [mockUnion], + definitionCategoryByNameMap, + }); + + assert.deepStrictEqual(result, expected); + }); +});