From bfdab86ea5ef12b03de3c5f4c62b288947db4c35 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Dec 2025 10:26:43 +0000 Subject: [PATCH 1/7] Initial plan From ac3a84b4841c13b1fea95c68c273214d2cd710bf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Dec 2025 10:36:58 +0000 Subject: [PATCH 2/7] Add AddTo method generation for arrays with child builders - Added ArrayCollectionMetadata class to represent array collections - Updated CollectionMethodDetector to detect and handle array types - Implemented array-specific AddTo method generation for both regular and child builder scenarios - Added ParentWithChildArrayEntity test entity - Added comprehensive integration tests for array AddTo methods - Updated CHANGELOG.md with fix details Co-authored-by: pmrogala <9459432+pmrogala@users.noreply.github.com> --- Buildenator/CodeAnalysis/ITypedSymbol.cs | 11 ++ .../Configuration/CollectionMethodDetector.cs | 7 ++ .../Generators/PropertiesStringGenerator.cs | 54 ++++++++++ CHANGELOG.md | 9 ++ .../ParentWithChildArrayEntity.cs | 24 +++++ .../ParentWithChildArrayEntityBuilder.cs | 13 +++ .../BuildersGeneratorTests.cs | 102 ++++++++++++++++++ 7 files changed, 220 insertions(+) create mode 100644 Tests/Buildenator.IntegrationTests.SharedEntities/ParentWithChildArrayEntity.cs create mode 100644 Tests/Buildenator.IntegrationTests.Source/Builders/ParentWithChildArrayEntityBuilder.cs diff --git a/Buildenator/CodeAnalysis/ITypedSymbol.cs b/Buildenator/CodeAnalysis/ITypedSymbol.cs index a693a13..63467a9 100644 --- a/Buildenator/CodeAnalysis/ITypedSymbol.cs +++ b/Buildenator/CodeAnalysis/ITypedSymbol.cs @@ -110,4 +110,15 @@ public InterfaceDictionaryMetadata(ITypeSymbol keyType, ITypeSymbol valueType, I KeyTypeDisplayName = keyType.ToDisplayString(); ValueTypeDisplayName = valueType.ToDisplayString(); } +} + +/// +/// Metadata for array types (e.g., T[]). +/// +internal sealed class ArrayCollectionMetadata : CollectionMetadata +{ + public ArrayCollectionMetadata(ITypeSymbol elementType) + : base(elementType) + { + } } \ No newline at end of file diff --git a/Buildenator/Configuration/CollectionMethodDetector.cs b/Buildenator/Configuration/CollectionMethodDetector.cs index f23fbe3..1a97423 100644 --- a/Buildenator/Configuration/CollectionMethodDetector.cs +++ b/Buildenator/Configuration/CollectionMethodDetector.cs @@ -9,6 +9,7 @@ internal static class CollectionMethodDetector /// /// Factory method that creates CollectionMetadata for a given type symbol. /// Returns ConcreteDictionaryMetadata or InterfaceDictionaryMetadata for dictionary types, + /// ArrayCollectionMetadata for array types, /// ConcreteCollectionMetadata for concrete collection types, /// InterfaceCollectionMetadata for interface collection types, /// or null if the type is not a collection. @@ -23,6 +24,12 @@ internal static class CollectionMethodDetector return dictionaryMetadata; } + // Check for array types + if (propertyType is IArrayTypeSymbol arrayType) + { + return new ArrayCollectionMetadata(arrayType.ElementType); + } + // Check for concrete collection types (before interface check) if (IsConcreteCollectionProperty(propertyType)) { diff --git a/Buildenator/Generators/PropertiesStringGenerator.cs b/Buildenator/Generators/PropertiesStringGenerator.cs index b330061..814bd22 100644 --- a/Buildenator/Generators/PropertiesStringGenerator.cs +++ b/Buildenator/Generators/PropertiesStringGenerator.cs @@ -252,6 +252,29 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol) }}"; } + // For array types, concatenate arrays + if (collectionMetadata is ArrayCollectionMetadata) + { + return $@"public {_builder.FullName} {methodName}(params {elementTypeName}[] items) + {{ + {elementTypeName}[] array; + if ({fieldName} != null && {fieldName}.HasValue && {fieldName}.Value.Object != null) + {{ + var existingArray = {fieldName}.Value.Object; + array = new {elementTypeName}[existingArray.Length + items.Length]; + System.Array.Copy(existingArray, 0, array, 0, existingArray.Length); + System.Array.Copy(items, 0, array, existingArray.Length, items.Length); + }} + else + {{ + array = items; + }} + + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>(array); + return this; + }}"; + } + // For concrete types, use new() and .Add() method from ICollection if (collectionMetadata is ConcreteCollectionMetadata) { @@ -335,6 +358,37 @@ private string GenerateChildBuilderAddToMethodDefinition(ITypedSymbol typedSymbo var methodName = CreateAddToMethodName(typedSymbol); var fieldName = typedSymbol.UnderScoreName; + // For array types + if (collectionMetadata is ArrayCollectionMetadata) + { + return $@"public {_builder.FullName} {methodName}(params System.Func<{childBuilderName}, {childBuilderName}>[] configures) + {{ + var newItems = new {elementTypeName}[configures.Length]; + for (int i = 0; i < configures.Length; i++) + {{ + var childBuilder = new {childBuilderName}(); + childBuilder = configures[i](childBuilder); + newItems[i] = childBuilder.Build(); + }} + + {elementTypeName}[] array; + if ({fieldName} != null && {fieldName}.HasValue && {fieldName}.Value.Object != null) + {{ + var existingArray = {fieldName}.Value.Object; + array = new {elementTypeName}[existingArray.Length + newItems.Length]; + System.Array.Copy(existingArray, 0, array, 0, existingArray.Length); + System.Array.Copy(newItems, 0, array, existingArray.Length, newItems.Length); + }} + else + {{ + array = newItems; + }} + + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>(array); + return this; + }}"; + } + // For concrete collection types if (collectionMetadata is ConcreteCollectionMetadata) { diff --git a/CHANGELOG.md b/CHANGELOG.md index 4876234..4bdc77a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Fixed +- **Array collection support**: Fixed an issue where `AddTo...` methods were not generated for array properties when `useChildBuilders` is enabled. Arrays now receive the same treatment as other collection types: + - `AddTo` methods with `params T[]` parameters for regular value additions + - `AddTo` methods with `params Func[]` parameters when `useChildBuilders: true` is set + - Arrays are properly concatenated when using `AddTo` multiple times + - Both constructor parameter arrays and settable property arrays are supported + ## 8.7.0.0 - 2025-11-27 ### Added diff --git a/Tests/Buildenator.IntegrationTests.SharedEntities/ParentWithChildArrayEntity.cs b/Tests/Buildenator.IntegrationTests.SharedEntities/ParentWithChildArrayEntity.cs new file mode 100644 index 0000000..d8687fa --- /dev/null +++ b/Tests/Buildenator.IntegrationTests.SharedEntities/ParentWithChildArrayEntity.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Buildenator.IntegrationTests.SharedEntities; + +/// +/// A parent entity that contains arrays of child entities, used to test UseChildBuilders feature with arrays. +/// +public class ParentWithChildArrayEntity +{ + public ParentWithChildArrayEntity(ChildForParentEntity[] children, int parentValue) + { + Children = children; + ParentValue = parentValue; + } + + public ChildForParentEntity[] Children { get; } + public int ParentValue { get; } + + /// + /// A settable property with an array of children for testing AddTo methods with child builder configuration. + /// + public ChildForParentEntity[] OptionalChildren { get; set; } = []; +} diff --git a/Tests/Buildenator.IntegrationTests.Source/Builders/ParentWithChildArrayEntityBuilder.cs b/Tests/Buildenator.IntegrationTests.Source/Builders/ParentWithChildArrayEntityBuilder.cs new file mode 100644 index 0000000..1d43232 --- /dev/null +++ b/Tests/Buildenator.IntegrationTests.Source/Builders/ParentWithChildArrayEntityBuilder.cs @@ -0,0 +1,13 @@ +using Buildenator.Abstraction; +using Buildenator.IntegrationTests.SharedEntities; + +namespace Buildenator.IntegrationTests.Source.Builders; + +/// +/// Builder for ParentWithChildArrayEntity with UseChildBuilders enabled. +/// This will generate AddTo methods that accept Func<ChildBuilder, ChildBuilder> for arrays containing buildable entities. +/// +[MakeBuilder(typeof(ParentWithChildArrayEntity), generateDefaultBuildMethod: false, useChildBuilders: true)] +public partial class ParentWithChildArrayEntityBuilder +{ +} diff --git a/Tests/Buildenator.IntegrationTests/BuildersGeneratorTests.cs b/Tests/Buildenator.IntegrationTests/BuildersGeneratorTests.cs index 5155e60..ffab40d 100644 --- a/Tests/Buildenator.IntegrationTests/BuildersGeneratorTests.cs +++ b/Tests/Buildenator.IntegrationTests/BuildersGeneratorTests.cs @@ -1459,6 +1459,108 @@ public void BuildersGenerator_UseChildBuildersWithCollection_TraditionalMethodsS _ = result.OptionalChildren[0].Name.Should().Be(childName); } + // ===== Tests for UseChildBuilders with Arrays ===== + // These tests verify that child builder methods are generated for arrays when useChildBuilders is enabled + + [Theory] + [AutoData] + public void BuildersGenerator_UseChildBuildersWithArray_AddToMethodShouldAcceptFuncParameter( + string childName1, int childValue1, string childName2, int childValue2, int parentValue) + { + // Arrange - ParentWithChildArrayEntityBuilder has useChildBuilders: true + var builder = ParentWithChildArrayEntityBuilder.ParentWithChildArrayEntity; + + // Act - Use the generated AddTo method with Func parameters + var result = builder + .AddToChildren( + child => child.WithName(childName1).WithValue(childValue1), + child => child.WithName(childName2).WithValue(childValue2)) + .WithParentValue(parentValue) + .Build(); + + // Assert + _ = result.Should().NotBeNull(); + _ = result.Children.Should().HaveCount(2); + _ = result.Children[0].Name.Should().Be(childName1); + _ = result.Children[0].Value.Should().Be(childValue1); + _ = result.Children[1].Name.Should().Be(childName2); + _ = result.Children[1].Value.Should().Be(childValue2); + _ = result.ParentValue.Should().Be(parentValue); + } + + [Theory] + [AutoData] + public void BuildersGenerator_UseChildBuildersWithArray_SettablePropertyAddToShouldAcceptFuncParameter( + string childName1, int childValue1, string childName2, int childValue2, int parentValue) + { + // Arrange + var builder = ParentWithChildArrayEntityBuilder.ParentWithChildArrayEntity; + + // Act - Use the AddTo method for the settable OptionalChildren array property + var result = builder + .AddToChildren(child => child.WithName("constructor-child").WithValue(0)) + .WithParentValue(parentValue) + .AddToOptionalChildren( + child => child.WithName(childName1).WithValue(childValue1), + child => child.WithName(childName2).WithValue(childValue2)) + .Build(); + + // Assert + _ = result.Should().NotBeNull(); + _ = result.OptionalChildren.Should().HaveCount(2); + _ = result.OptionalChildren[0].Name.Should().Be(childName1); + _ = result.OptionalChildren[0].Value.Should().Be(childValue1); + _ = result.OptionalChildren[1].Name.Should().Be(childName2); + _ = result.OptionalChildren[1].Value.Should().Be(childValue2); + } + + [Theory] + [AutoData] + public void BuildersGenerator_UseChildBuildersWithArray_MultipleAddToCalls_ShouldAccumulate( + string childName1, int childValue1, string childName2, int childValue2, int parentValue) + { + // Arrange + var builder = ParentWithChildArrayEntityBuilder.ParentWithChildArrayEntity; + + // Act - Use AddTo multiple times - items should accumulate in the array + var result = builder + .AddToChildren(child => child.WithName(childName1).WithValue(childValue1)) + .AddToChildren(child => child.WithName(childName2).WithValue(childValue2)) + .WithParentValue(parentValue) + .Build(); + + // Assert + _ = result.Should().NotBeNull(); + _ = result.Children.Should().HaveCount(2); + _ = result.Children[0].Name.Should().Be(childName1); + _ = result.Children[1].Name.Should().Be(childName2); + } + + [Theory] + [AutoData] + public void BuildersGenerator_UseChildBuildersWithArray_TraditionalMethodsStillWork( + string childName, int childValue, int parentValue) + { + // Arrange - This test verifies that both traditional With/AddTo methods and Func-based methods are generated + // Compilation success is the test - if the methods don't exist, this won't compile + var child = new ChildForParentEntity(childName, childValue); + var builder = ParentWithChildArrayEntityBuilder.ParentWithChildArrayEntity; + + // Act - Use the traditional WithChildren and AddToChildren methods + var result = builder + .WithChildren(new[] { child }) // Traditional With method + .AddToOptionalChildren(child) // Traditional AddTo method for settable property + .WithParentValue(parentValue) + .Build(); + + // Assert + _ = result.Should().NotBeNull(); + _ = result.Children.Should().HaveCount(1); + _ = result.Children[0].Name.Should().Be(childName); + _ = result.OptionalChildren.Should().HaveCount(1); + _ = result.OptionalChildren[0].Name.Should().Be(childName); + } + // ===== Tests for UseChildBuilders: false ===== // These tests verify that Func<> methods are NOT generated when useChildBuilders is disabled From 5c1a8facfc90eea0a59533291f781e24ab613b9f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Dec 2025 10:38:47 +0000 Subject: [PATCH 3/7] Remove unused imports from ParentWithChildArrayEntity Co-authored-by: pmrogala <9459432+pmrogala@users.noreply.github.com> --- .../ParentWithChildArrayEntity.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/Tests/Buildenator.IntegrationTests.SharedEntities/ParentWithChildArrayEntity.cs b/Tests/Buildenator.IntegrationTests.SharedEntities/ParentWithChildArrayEntity.cs index d8687fa..80b05a8 100644 --- a/Tests/Buildenator.IntegrationTests.SharedEntities/ParentWithChildArrayEntity.cs +++ b/Tests/Buildenator.IntegrationTests.SharedEntities/ParentWithChildArrayEntity.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Linq; - namespace Buildenator.IntegrationTests.SharedEntities; /// From 5eab549fddb6e1e8f6d793707530c81da56dbf26 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Dec 2025 12:51:27 +0000 Subject: [PATCH 4/7] Refactor array handling to use List internally with ToArray() conversion Updated approach per code review feedback: - Arrays now use List for internal storage - Conversion to array happens in Build() method via ToArray() - AddTo methods use List.Add/AddRange instead of Array.Copy - Simpler and more performant for multiple AddTo calls Note: This changes semantics - With methods now create new array instances rather than preserving reference equality. 8 tests fail due to this change. Co-authored-by: pmrogala <9459432+pmrogala@users.noreply.github.com> --- Buildenator/CodeAnalysis/TypedSymbol.cs | 86 ++++++++++++++++--- .../Generators/ConstructorsGenerator.cs | 9 ++ .../Generators/PropertiesStringGenerator.cs | 58 +++++++------ 3 files changed, 116 insertions(+), 37 deletions(-) diff --git a/Buildenator/CodeAnalysis/TypedSymbol.cs b/Buildenator/CodeAnalysis/TypedSymbol.cs index 68d6e67..f05faa3 100644 --- a/Buildenator/CodeAnalysis/TypedSymbol.cs +++ b/Buildenator/CodeAnalysis/TypedSymbol.cs @@ -122,6 +122,10 @@ public string GenerateFieldType() if (collectionMetadata is InterfaceDictionaryMetadata dictMetadata) return $"System.Collections.Generic.Dictionary<{dictMetadata.KeyTypeDisplayName}, {dictMetadata.ValueTypeDisplayName}>"; + // For array types, use List internally and convert to array in build method + if (collectionMetadata is ArrayCollectionMetadata) + return $"System.Collections.Generic.List<{collectionMetadata.ElementTypeDisplayName}>"; + // For concrete collection types, use the actual type name if (collectionMetadata is ConcreteCollectionMetadata) return TypeFullName; @@ -135,20 +139,82 @@ public string GenerateFieldType() } public string GenerateLazyFieldType() - => IsMockable() ? GenerateMockableFieldType() : $"{DefaultConstants.NullBox}<{TypeFullName}>?"; + { + if (IsMockable()) + return GenerateMockableFieldType(); + + // For array types, the lazy field type should use List + var collectionMetadata = GetCollectionMetadata(); + if (collectionMetadata is ArrayCollectionMetadata) + return $"{DefaultConstants.NullBox}<{GenerateFieldType()}>?"; + + return $"{DefaultConstants.NullBox}<{TypeFullName}>?"; + } public string GenerateLazyFieldValueReturn() - => IsMockable() - ? string.Format(_mockingProperties!.ReturnObjectFormat, UnderScoreName) - : @$"({UnderScoreName}.HasValue ? {UnderScoreName}.Value : new {DefaultConstants.NullBox}<{TypeFullName}>({(IsFakeable() - ? $"{string.Format(_fixtureProperties!.CreateSingleFormat, TypeFullName, SymbolName, DefaultConstants.FixtureLiteral)}" - + (_nullableStrategy == NullableStrategy.Enabled ? "!" : "") - : $"default({TypeFullName})")})).Object"; + { + if (IsMockable()) + return string.Format(_mockingProperties!.ReturnObjectFormat, UnderScoreName); + + var collectionMetadata = GetCollectionMetadata(); + + // Handle the default value or AutoFixture creation + string defaultOrFixtureValue; + string nullBoxType; + + if (collectionMetadata is ArrayCollectionMetadata) + { + // For arrays, we store as List internally + nullBoxType = GenerateFieldType(); // List + + if (IsFakeable()) + { + var fixtureCreation = $"{string.Format(_fixtureProperties!.CreateSingleFormat, TypeFullName, SymbolName, DefaultConstants.FixtureLiteral)}" + + (_nullableStrategy == NullableStrategy.Enabled ? "!" : ""); + defaultOrFixtureValue = $"new System.Collections.Generic.List<{collectionMetadata.ElementTypeDisplayName}>({fixtureCreation})"; + } + else + { + defaultOrFixtureValue = $"default({nullBoxType})!"; + } + } + else + { + // For all other types, use the original type + nullBoxType = TypeFullName; + + if (IsFakeable()) + { + defaultOrFixtureValue = $"{string.Format(_fixtureProperties!.CreateSingleFormat, TypeFullName, SymbolName, DefaultConstants.FixtureLiteral)}" + + (_nullableStrategy == NullableStrategy.Enabled ? "!" : ""); + } + else + { + defaultOrFixtureValue = $"default({nullBoxType})"; + } + } + + var baseReturn = @$"({UnderScoreName}.HasValue ? {UnderScoreName}.Value : new {DefaultConstants.NullBox}<{nullBoxType}>({defaultOrFixtureValue})).Object"; + + // For array types, convert the internal List to T[] when returning + if (collectionMetadata is ArrayCollectionMetadata) + return $"{baseReturn}.ToArray()"; + + return baseReturn; + } public string GenerateFieldValueReturn() - => IsMockable() - ? string.Format(_mockingProperties!.ReturnObjectFormat, UnderScoreName) - : UnderScoreName; + { + if (IsMockable()) + return string.Format(_mockingProperties!.ReturnObjectFormat, UnderScoreName); + + // For array types, convert the internal List to T[] when returning + var collectionMetadata = GetCollectionMetadata(); + if (collectionMetadata is ArrayCollectionMetadata) + return $"{UnderScoreName}.ToArray()"; + + return UnderScoreName; + } public string GenerateMethodParameterDefinition() => IsMockable() ? $"Action<{GenerateMockableFieldType()}> {DefaultConstants.SetupActionLiteral}" : $"{TypeFullName} {DefaultConstants.ValueLiteral}"; diff --git a/Buildenator/Generators/ConstructorsGenerator.cs b/Buildenator/Generators/ConstructorsGenerator.cs index 13bc7cf..67f1776 100644 --- a/Buildenator/Generators/ConstructorsGenerator.cs +++ b/Buildenator/Generators/ConstructorsGenerator.cs @@ -84,6 +84,7 @@ private static string GenerateEmptyCollectionInitialization(ITypedSymbol typedSy { var fieldName = typedSymbol.UnderScoreName; var typeFullName = typedSymbol.TypeFullName; + var fieldType = typedSymbol.GenerateFieldType(); // For concrete dictionary types, create new instance if (collectionMetadata is ConcreteDictionaryMetadata) @@ -98,6 +99,14 @@ private static string GenerateEmptyCollectionInitialization(ITypedSymbol typedSy return $"{fieldName} = new {DefaultConstants.NullBox}<{typeFullName}>(new {dictionaryType}());"; } + // For array types, create an empty List + if (collectionMetadata is ArrayCollectionMetadata) + { + var elementTypeName = collectionMetadata.ElementTypeDisplayName; + var listType = $"System.Collections.Generic.List<{elementTypeName}>"; + return $"{fieldName} = new {DefaultConstants.NullBox}<{listType}>(new {listType}());"; + } + // For concrete collection types, create new instance. // Note: This assumes the concrete type has a parameterless constructor. // Standard .NET collections (List, HashSet, Collection, etc.) all support this. diff --git a/Buildenator/Generators/PropertiesStringGenerator.cs b/Buildenator/Generators/PropertiesStringGenerator.cs index 814bd22..0d36d49 100644 --- a/Buildenator/Generators/PropertiesStringGenerator.cs +++ b/Buildenator/Generators/PropertiesStringGenerator.cs @@ -189,9 +189,18 @@ private string GenerateMethodDefinitionHeader(ITypedSymbol typedSymbol) => $"public {_builder.FullName} {CreateMethodName(typedSymbol)}({typedSymbol.GenerateMethodParameterDefinition()})"; private static string GenerateValueAssignment(ITypedSymbol typedSymbol) - => typedSymbol.IsMockable() - ? $"{DefaultConstants.SetupActionLiteral}({typedSymbol.UnderScoreName})" - : $"{typedSymbol.UnderScoreName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>({DefaultConstants.ValueLiteral})"; + { + if (typedSymbol.IsMockable()) + return $"{DefaultConstants.SetupActionLiteral}({typedSymbol.UnderScoreName})"; + + var collectionMetadata = typedSymbol.GetCollectionMetadata(); + + // For array types, store the array as a List (arrays will be converted via ToArray() in Build method) + if (collectionMetadata is ArrayCollectionMetadata) + return $"{typedSymbol.UnderScoreName} = new {DefaultConstants.NullBox}<{typedSymbol.GenerateFieldType()}>({DefaultConstants.ValueLiteral} != null ? new System.Collections.Generic.List<{collectionMetadata.ElementTypeDisplayName}>({DefaultConstants.ValueLiteral}) : default({typedSymbol.GenerateFieldType()})!)"; + + return $"{typedSymbol.UnderScoreName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>({DefaultConstants.ValueLiteral})"; + } private string CreateMethodName(ITypedSymbol property) => $"{_builder.BuildingMethodsPrefix}{property.SymbolPascalName}"; @@ -252,25 +261,24 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol) }}"; } - // For array types, concatenate arrays + // For array types, use List internally if (collectionMetadata is ArrayCollectionMetadata) { return $@"public {_builder.FullName} {methodName}(params {elementTypeName}[] items) {{ - {elementTypeName}[] array; + System.Collections.Generic.List<{elementTypeName}> list; if ({fieldName} != null && {fieldName}.HasValue && {fieldName}.Value.Object != null) {{ - var existingArray = {fieldName}.Value.Object; - array = new {elementTypeName}[existingArray.Length + items.Length]; - System.Array.Copy(existingArray, 0, array, 0, existingArray.Length); - System.Array.Copy(items, 0, array, existingArray.Length, items.Length); + list = {fieldName}.Value.Object; }} else {{ - array = items; + list = new System.Collections.Generic.List<{elementTypeName}>(); }} - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>(array); + list.AddRange(items); + + {fieldName} = new {DefaultConstants.NullBox}>(list); return this; }}"; } @@ -358,33 +366,29 @@ private string GenerateChildBuilderAddToMethodDefinition(ITypedSymbol typedSymbo var methodName = CreateAddToMethodName(typedSymbol); var fieldName = typedSymbol.UnderScoreName; - // For array types + // For array types, use List internally if (collectionMetadata is ArrayCollectionMetadata) { return $@"public {_builder.FullName} {methodName}(params System.Func<{childBuilderName}, {childBuilderName}>[] configures) {{ - var newItems = new {elementTypeName}[configures.Length]; - for (int i = 0; i < configures.Length; i++) - {{ - var childBuilder = new {childBuilderName}(); - childBuilder = configures[i](childBuilder); - newItems[i] = childBuilder.Build(); - }} - - {elementTypeName}[] array; + System.Collections.Generic.List<{elementTypeName}> list; if ({fieldName} != null && {fieldName}.HasValue && {fieldName}.Value.Object != null) {{ - var existingArray = {fieldName}.Value.Object; - array = new {elementTypeName}[existingArray.Length + newItems.Length]; - System.Array.Copy(existingArray, 0, array, 0, existingArray.Length); - System.Array.Copy(newItems, 0, array, existingArray.Length, newItems.Length); + list = {fieldName}.Value.Object; }} else {{ - array = newItems; + list = new System.Collections.Generic.List<{elementTypeName}>(); + }} + + foreach (var configure in configures) + {{ + var childBuilder = new {childBuilderName}(); + childBuilder = configure(childBuilder); + list.Add(childBuilder.Build()); }} - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>(array); + {fieldName} = new {DefaultConstants.NullBox}>(list); return this; }}"; } From dd74d64dc255357bbe2431ef2c384905144b46ff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Dec 2025 15:59:45 +0000 Subject: [PATCH 5/7] Revert to array backing field with Array.Copy approach Reverted changes from commit 5eab549 to restore the original Array.Copy implementation from commit ac3a84b. This preserves reference equality semantics for With methods while still supporting AddTo methods for arrays. All 111 tests now pass (previously 8 were failing with List approach). Co-authored-by: pmrogala <9459432+pmrogala@users.noreply.github.com> --- Buildenator/CodeAnalysis/TypedSymbol.cs | 86 +++---------------- .../Generators/ConstructorsGenerator.cs | 9 -- .../Generators/PropertiesStringGenerator.cs | 58 ++++++------- 3 files changed, 37 insertions(+), 116 deletions(-) diff --git a/Buildenator/CodeAnalysis/TypedSymbol.cs b/Buildenator/CodeAnalysis/TypedSymbol.cs index f05faa3..68d6e67 100644 --- a/Buildenator/CodeAnalysis/TypedSymbol.cs +++ b/Buildenator/CodeAnalysis/TypedSymbol.cs @@ -122,10 +122,6 @@ public string GenerateFieldType() if (collectionMetadata is InterfaceDictionaryMetadata dictMetadata) return $"System.Collections.Generic.Dictionary<{dictMetadata.KeyTypeDisplayName}, {dictMetadata.ValueTypeDisplayName}>"; - // For array types, use List internally and convert to array in build method - if (collectionMetadata is ArrayCollectionMetadata) - return $"System.Collections.Generic.List<{collectionMetadata.ElementTypeDisplayName}>"; - // For concrete collection types, use the actual type name if (collectionMetadata is ConcreteCollectionMetadata) return TypeFullName; @@ -139,82 +135,20 @@ public string GenerateFieldType() } public string GenerateLazyFieldType() - { - if (IsMockable()) - return GenerateMockableFieldType(); - - // For array types, the lazy field type should use List - var collectionMetadata = GetCollectionMetadata(); - if (collectionMetadata is ArrayCollectionMetadata) - return $"{DefaultConstants.NullBox}<{GenerateFieldType()}>?"; - - return $"{DefaultConstants.NullBox}<{TypeFullName}>?"; - } + => IsMockable() ? GenerateMockableFieldType() : $"{DefaultConstants.NullBox}<{TypeFullName}>?"; public string GenerateLazyFieldValueReturn() - { - if (IsMockable()) - return string.Format(_mockingProperties!.ReturnObjectFormat, UnderScoreName); - - var collectionMetadata = GetCollectionMetadata(); - - // Handle the default value or AutoFixture creation - string defaultOrFixtureValue; - string nullBoxType; - - if (collectionMetadata is ArrayCollectionMetadata) - { - // For arrays, we store as List internally - nullBoxType = GenerateFieldType(); // List - - if (IsFakeable()) - { - var fixtureCreation = $"{string.Format(_fixtureProperties!.CreateSingleFormat, TypeFullName, SymbolName, DefaultConstants.FixtureLiteral)}" - + (_nullableStrategy == NullableStrategy.Enabled ? "!" : ""); - defaultOrFixtureValue = $"new System.Collections.Generic.List<{collectionMetadata.ElementTypeDisplayName}>({fixtureCreation})"; - } - else - { - defaultOrFixtureValue = $"default({nullBoxType})!"; - } - } - else - { - // For all other types, use the original type - nullBoxType = TypeFullName; - - if (IsFakeable()) - { - defaultOrFixtureValue = $"{string.Format(_fixtureProperties!.CreateSingleFormat, TypeFullName, SymbolName, DefaultConstants.FixtureLiteral)}" - + (_nullableStrategy == NullableStrategy.Enabled ? "!" : ""); - } - else - { - defaultOrFixtureValue = $"default({nullBoxType})"; - } - } - - var baseReturn = @$"({UnderScoreName}.HasValue ? {UnderScoreName}.Value : new {DefaultConstants.NullBox}<{nullBoxType}>({defaultOrFixtureValue})).Object"; - - // For array types, convert the internal List to T[] when returning - if (collectionMetadata is ArrayCollectionMetadata) - return $"{baseReturn}.ToArray()"; - - return baseReturn; - } + => IsMockable() + ? string.Format(_mockingProperties!.ReturnObjectFormat, UnderScoreName) + : @$"({UnderScoreName}.HasValue ? {UnderScoreName}.Value : new {DefaultConstants.NullBox}<{TypeFullName}>({(IsFakeable() + ? $"{string.Format(_fixtureProperties!.CreateSingleFormat, TypeFullName, SymbolName, DefaultConstants.FixtureLiteral)}" + + (_nullableStrategy == NullableStrategy.Enabled ? "!" : "") + : $"default({TypeFullName})")})).Object"; public string GenerateFieldValueReturn() - { - if (IsMockable()) - return string.Format(_mockingProperties!.ReturnObjectFormat, UnderScoreName); - - // For array types, convert the internal List to T[] when returning - var collectionMetadata = GetCollectionMetadata(); - if (collectionMetadata is ArrayCollectionMetadata) - return $"{UnderScoreName}.ToArray()"; - - return UnderScoreName; - } + => IsMockable() + ? string.Format(_mockingProperties!.ReturnObjectFormat, UnderScoreName) + : UnderScoreName; public string GenerateMethodParameterDefinition() => IsMockable() ? $"Action<{GenerateMockableFieldType()}> {DefaultConstants.SetupActionLiteral}" : $"{TypeFullName} {DefaultConstants.ValueLiteral}"; diff --git a/Buildenator/Generators/ConstructorsGenerator.cs b/Buildenator/Generators/ConstructorsGenerator.cs index 67f1776..13bc7cf 100644 --- a/Buildenator/Generators/ConstructorsGenerator.cs +++ b/Buildenator/Generators/ConstructorsGenerator.cs @@ -84,7 +84,6 @@ private static string GenerateEmptyCollectionInitialization(ITypedSymbol typedSy { var fieldName = typedSymbol.UnderScoreName; var typeFullName = typedSymbol.TypeFullName; - var fieldType = typedSymbol.GenerateFieldType(); // For concrete dictionary types, create new instance if (collectionMetadata is ConcreteDictionaryMetadata) @@ -99,14 +98,6 @@ private static string GenerateEmptyCollectionInitialization(ITypedSymbol typedSy return $"{fieldName} = new {DefaultConstants.NullBox}<{typeFullName}>(new {dictionaryType}());"; } - // For array types, create an empty List - if (collectionMetadata is ArrayCollectionMetadata) - { - var elementTypeName = collectionMetadata.ElementTypeDisplayName; - var listType = $"System.Collections.Generic.List<{elementTypeName}>"; - return $"{fieldName} = new {DefaultConstants.NullBox}<{listType}>(new {listType}());"; - } - // For concrete collection types, create new instance. // Note: This assumes the concrete type has a parameterless constructor. // Standard .NET collections (List, HashSet, Collection, etc.) all support this. diff --git a/Buildenator/Generators/PropertiesStringGenerator.cs b/Buildenator/Generators/PropertiesStringGenerator.cs index 0d36d49..814bd22 100644 --- a/Buildenator/Generators/PropertiesStringGenerator.cs +++ b/Buildenator/Generators/PropertiesStringGenerator.cs @@ -189,18 +189,9 @@ private string GenerateMethodDefinitionHeader(ITypedSymbol typedSymbol) => $"public {_builder.FullName} {CreateMethodName(typedSymbol)}({typedSymbol.GenerateMethodParameterDefinition()})"; private static string GenerateValueAssignment(ITypedSymbol typedSymbol) - { - if (typedSymbol.IsMockable()) - return $"{DefaultConstants.SetupActionLiteral}({typedSymbol.UnderScoreName})"; - - var collectionMetadata = typedSymbol.GetCollectionMetadata(); - - // For array types, store the array as a List (arrays will be converted via ToArray() in Build method) - if (collectionMetadata is ArrayCollectionMetadata) - return $"{typedSymbol.UnderScoreName} = new {DefaultConstants.NullBox}<{typedSymbol.GenerateFieldType()}>({DefaultConstants.ValueLiteral} != null ? new System.Collections.Generic.List<{collectionMetadata.ElementTypeDisplayName}>({DefaultConstants.ValueLiteral}) : default({typedSymbol.GenerateFieldType()})!)"; - - return $"{typedSymbol.UnderScoreName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>({DefaultConstants.ValueLiteral})"; - } + => typedSymbol.IsMockable() + ? $"{DefaultConstants.SetupActionLiteral}({typedSymbol.UnderScoreName})" + : $"{typedSymbol.UnderScoreName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>({DefaultConstants.ValueLiteral})"; private string CreateMethodName(ITypedSymbol property) => $"{_builder.BuildingMethodsPrefix}{property.SymbolPascalName}"; @@ -261,24 +252,25 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol) }}"; } - // For array types, use List internally + // For array types, concatenate arrays if (collectionMetadata is ArrayCollectionMetadata) { return $@"public {_builder.FullName} {methodName}(params {elementTypeName}[] items) {{ - System.Collections.Generic.List<{elementTypeName}> list; + {elementTypeName}[] array; if ({fieldName} != null && {fieldName}.HasValue && {fieldName}.Value.Object != null) {{ - list = {fieldName}.Value.Object; + var existingArray = {fieldName}.Value.Object; + array = new {elementTypeName}[existingArray.Length + items.Length]; + System.Array.Copy(existingArray, 0, array, 0, existingArray.Length); + System.Array.Copy(items, 0, array, existingArray.Length, items.Length); }} else {{ - list = new System.Collections.Generic.List<{elementTypeName}>(); + array = items; }} - list.AddRange(items); - - {fieldName} = new {DefaultConstants.NullBox}>(list); + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>(array); return this; }}"; } @@ -366,29 +358,33 @@ private string GenerateChildBuilderAddToMethodDefinition(ITypedSymbol typedSymbo var methodName = CreateAddToMethodName(typedSymbol); var fieldName = typedSymbol.UnderScoreName; - // For array types, use List internally + // For array types if (collectionMetadata is ArrayCollectionMetadata) { return $@"public {_builder.FullName} {methodName}(params System.Func<{childBuilderName}, {childBuilderName}>[] configures) {{ - System.Collections.Generic.List<{elementTypeName}> list; - if ({fieldName} != null && {fieldName}.HasValue && {fieldName}.Value.Object != null) + var newItems = new {elementTypeName}[configures.Length]; + for (int i = 0; i < configures.Length; i++) {{ - list = {fieldName}.Value.Object; + var childBuilder = new {childBuilderName}(); + childBuilder = configures[i](childBuilder); + newItems[i] = childBuilder.Build(); }} - else + + {elementTypeName}[] array; + if ({fieldName} != null && {fieldName}.HasValue && {fieldName}.Value.Object != null) {{ - list = new System.Collections.Generic.List<{elementTypeName}>(); + var existingArray = {fieldName}.Value.Object; + array = new {elementTypeName}[existingArray.Length + newItems.Length]; + System.Array.Copy(existingArray, 0, array, 0, existingArray.Length); + System.Array.Copy(newItems, 0, array, existingArray.Length, newItems.Length); }} - - foreach (var configure in configures) + else {{ - var childBuilder = new {childBuilderName}(); - childBuilder = configure(childBuilder); - list.Add(childBuilder.Build()); + array = newItems; }} - {fieldName} = new {DefaultConstants.NullBox}>(list); + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>(array); return this; }}"; } From 4e00e9a80d073daa7dd9d245837b520d29df779b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:02:24 +0000 Subject: [PATCH 6/7] Refactor: Consolidate array and concrete collection AddTo generation Unified the code generation for arrays and concrete collections to reduce duplication. Both types now share the same pattern with conditional logic to handle the differences (Array.Copy vs collection.Add). This addresses code review feedback about similar-looking generation code between ArrayCollectionMetadata and ConcreteCollectionMetadata. Co-authored-by: pmrogala <9459432+pmrogala@users.noreply.github.com> --- .../Generators/PropertiesStringGenerator.cs | 115 ++++++++---------- 1 file changed, 54 insertions(+), 61 deletions(-) diff --git a/Buildenator/Generators/PropertiesStringGenerator.cs b/Buildenator/Generators/PropertiesStringGenerator.cs index 814bd22..dcda7f0 100644 --- a/Buildenator/Generators/PropertiesStringGenerator.cs +++ b/Buildenator/Generators/PropertiesStringGenerator.cs @@ -252,50 +252,45 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol) }}"; } - // For array types, concatenate arrays - if (collectionMetadata is ArrayCollectionMetadata) + // For array types and concrete collection types, use similar pattern + if (collectionMetadata is ArrayCollectionMetadata or ConcreteCollectionMetadata) { - return $@"public {_builder.FullName} {methodName}(params {elementTypeName}[] items) - {{ - {elementTypeName}[] array; - if ({fieldName} != null && {fieldName}.HasValue && {fieldName}.Value.Object != null) + var isArray = collectionMetadata is ArrayCollectionMetadata; + var collectionVarName = isArray ? "array" : "collection"; + var collectionTypeName = isArray ? $"{elementTypeName}[]" : typedSymbol.TypeFullName; + + var addItemsCode = isArray + ? $@"if ({fieldName} != null && {fieldName}.HasValue && {fieldName}.Value.Object != null) {{ var existingArray = {fieldName}.Value.Object; - array = new {elementTypeName}[existingArray.Length + items.Length]; - System.Array.Copy(existingArray, 0, array, 0, existingArray.Length); - System.Array.Copy(items, 0, array, existingArray.Length, items.Length); + {collectionVarName} = new {elementTypeName}[existingArray.Length + items.Length]; + System.Array.Copy(existingArray, 0, {collectionVarName}, 0, existingArray.Length); + System.Array.Copy(items, 0, {collectionVarName}, existingArray.Length, items.Length); }} else {{ - array = items; - }} - - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>(array); - return this; - }}"; - } - - // For concrete types, use new() and .Add() method from ICollection - if (collectionMetadata is ConcreteCollectionMetadata) - { - return $@"public {_builder.FullName} {methodName}(params {elementTypeName}[] items) - {{ - {typedSymbol.TypeFullName} collection; - if ({fieldName} != null && {fieldName}.HasValue && {fieldName}.Value.Object != null) + {collectionVarName} = items; + }}" + : $@"if ({fieldName} != null && {fieldName}.HasValue && {fieldName}.Value.Object != null) {{ - collection = {fieldName}.Value.Object; + {collectionVarName} = {fieldName}.Value.Object; }} else {{ - collection = new {typedSymbol.TypeFullName}(); + {collectionVarName} = new {typedSymbol.TypeFullName}(); }} foreach (var item in items) {{ - collection.Add(item); - }} + {collectionVarName}.Add(item); + }}"; + + return $@"public {_builder.FullName} {methodName}(params {elementTypeName}[] items) + {{ + {collectionTypeName} {collectionVarName}; + {addItemsCode} - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>(collection); + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>({collectionVarName}); return this; }}"; } @@ -358,60 +353,58 @@ private string GenerateChildBuilderAddToMethodDefinition(ITypedSymbol typedSymbo var methodName = CreateAddToMethodName(typedSymbol); var fieldName = typedSymbol.UnderScoreName; - // For array types - if (collectionMetadata is ArrayCollectionMetadata) + // For array types and concrete collection types, use similar pattern + if (collectionMetadata is ArrayCollectionMetadata or ConcreteCollectionMetadata) { - return $@"public {_builder.FullName} {methodName}(params System.Func<{childBuilderName}, {childBuilderName}>[] configures) - {{ - var newItems = new {elementTypeName}[configures.Length]; + var isArray = collectionMetadata is ArrayCollectionMetadata; + var collectionVarName = isArray ? "array" : "collection"; + var collectionTypeName = isArray ? $"{elementTypeName}[]" : typedSymbol.TypeFullName; + + // Build child items first - for arrays we need to know the count upfront + var buildItemsCode = isArray + ? $@"var newItems = new {elementTypeName}[configures.Length]; for (int i = 0; i < configures.Length; i++) {{ var childBuilder = new {childBuilderName}(); childBuilder = configures[i](childBuilder); newItems[i] = childBuilder.Build(); - }} - - {elementTypeName}[] array; - if ({fieldName} != null && {fieldName}.HasValue && {fieldName}.Value.Object != null) + }}" + : ""; + + var addItemsCode = isArray + ? $@"if ({fieldName} != null && {fieldName}.HasValue && {fieldName}.Value.Object != null) {{ var existingArray = {fieldName}.Value.Object; - array = new {elementTypeName}[existingArray.Length + newItems.Length]; - System.Array.Copy(existingArray, 0, array, 0, existingArray.Length); - System.Array.Copy(newItems, 0, array, existingArray.Length, newItems.Length); + {collectionVarName} = new {elementTypeName}[existingArray.Length + newItems.Length]; + System.Array.Copy(existingArray, 0, {collectionVarName}, 0, existingArray.Length); + System.Array.Copy(newItems, 0, {collectionVarName}, existingArray.Length, newItems.Length); }} else {{ - array = newItems; - }} - - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>(array); - return this; - }}"; - } - - // For concrete collection types - if (collectionMetadata is ConcreteCollectionMetadata) - { - return $@"public {_builder.FullName} {methodName}(params System.Func<{childBuilderName}, {childBuilderName}>[] configures) - {{ - {typedSymbol.TypeFullName} collection; - if ({fieldName} != null && {fieldName}.HasValue && {fieldName}.Value.Object != null) + {collectionVarName} = newItems; + }}" + : $@"if ({fieldName} != null && {fieldName}.HasValue && {fieldName}.Value.Object != null) {{ - collection = {fieldName}.Value.Object; + {collectionVarName} = {fieldName}.Value.Object; }} else {{ - collection = new {typedSymbol.TypeFullName}(); + {collectionVarName} = new {typedSymbol.TypeFullName}(); }} foreach (var configure in configures) {{ var childBuilder = new {childBuilderName}(); childBuilder = configure(childBuilder); - collection.Add(childBuilder.Build()); - }} + {collectionVarName}.Add(childBuilder.Build()); + }}"; + + return $@"public {_builder.FullName} {methodName}(params System.Func<{childBuilderName}, {childBuilderName}>[] configures) + {{ + {buildItemsCode}{(isArray ? "\n \n " : "")}{collectionTypeName} {collectionVarName}; + {addItemsCode} - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>(collection); + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>({collectionVarName}); return this; }}"; } From 31862d816b59e547d8d86036ac089c269b85673f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:05:06 +0000 Subject: [PATCH 7/7] Improve code readability in child builder AddTo generation Extracted the inline conditional for whitespace formatting into a clearer methodBody variable. This makes the code more maintainable and easier to understand. Co-authored-by: pmrogala <9459432+pmrogala@users.noreply.github.com> --- Buildenator/Generators/PropertiesStringGenerator.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Buildenator/Generators/PropertiesStringGenerator.cs b/Buildenator/Generators/PropertiesStringGenerator.cs index dcda7f0..af60818 100644 --- a/Buildenator/Generators/PropertiesStringGenerator.cs +++ b/Buildenator/Generators/PropertiesStringGenerator.cs @@ -399,10 +399,18 @@ private string GenerateChildBuilderAddToMethodDefinition(ITypedSymbol typedSymbo {collectionVarName}.Add(childBuilder.Build()); }}"; + // For arrays, we need extra newlines between buildItemsCode and the variable declaration + var methodBody = isArray + ? $@"{buildItemsCode} + + {collectionTypeName} {collectionVarName}; + {addItemsCode}" + : $@"{collectionTypeName} {collectionVarName}; + {addItemsCode}"; + return $@"public {_builder.FullName} {methodName}(params System.Func<{childBuilderName}, {childBuilderName}>[] configures) {{ - {buildItemsCode}{(isArray ? "\n \n " : "")}{collectionTypeName} {collectionVarName}; - {addItemsCode} + {methodBody} {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>({collectionVarName}); return this;