Skip to content
11 changes: 11 additions & 0 deletions Buildenator/CodeAnalysis/ITypedSymbol.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,15 @@ public InterfaceDictionaryMetadata(ITypeSymbol keyType, ITypeSymbol valueType, I
KeyTypeDisplayName = keyType.ToDisplayString();
ValueTypeDisplayName = valueType.ToDisplayString();
}
}

/// <summary>
/// Metadata for array types (e.g., T[]).
/// </summary>
internal sealed class ArrayCollectionMetadata : CollectionMetadata
{
public ArrayCollectionMetadata(ITypeSymbol elementType)
: base(elementType)
{
}
}
7 changes: 7 additions & 0 deletions Buildenator/Configuration/CollectionMethodDetector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ internal static class CollectionMethodDetector
/// <summary>
/// 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.
Expand All @@ -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))
{
Expand Down
99 changes: 77 additions & 22 deletions Buildenator/Generators/PropertiesStringGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -255,27 +255,45 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol)
}}";
}

// For concrete types, use new() and .Add() method from ICollection<T>
if (collectionMetadata is ConcreteCollectionMetadata)
// For array types and concrete collection types, use similar pattern
if (collectionMetadata is ArrayCollectionMetadata or ConcreteCollectionMetadata)
{
return $@"public {_builder.FullName} {methodName}(params {elementTypeName}[] items)
{{
{typedSymbol.TypeFullName} collection;
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;
{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
{{
{collectionVarName} = items;
}}"
: $@"if ({fieldName} != null && {fieldName}.HasValue && {fieldName}.Value.Object != null)
{{
collection = {fieldName}.Value.Object;
{collectionVarName} = {fieldName}.Value.Object;
}}
else
{{
collection = new {typedSymbol.NonNullableTypeFullName}();
{collectionVarName} = new {typedSymbol.NonNullableTypeFullName}();
}}

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;
}}";
}
Expand Down Expand Up @@ -337,29 +355,66 @@ private string GenerateChildBuilderAddToMethodDefinition(ITypedSymbol typedSymbo
var methodName = CreateAddToMethodName(typedSymbol);
var fieldName = typedSymbol.UnderScoreName;

// For concrete collection types
if (collectionMetadata is ConcreteCollectionMetadata)
// 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)
{{
{typedSymbol.TypeFullName} collection;
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;

// 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();
}}"
: "";

var addItemsCode = isArray
? $@"if ({fieldName} != null && {fieldName}.HasValue && {fieldName}.Value.Object != null)
{{
var existingArray = {fieldName}.Value.Object;
{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
{{
collection = {fieldName}.Value.Object;
{collectionVarName} = newItems;
}}"
: $@"if ({fieldName} != null && {fieldName}.HasValue && {fieldName}.Value.Object != null)
{{
{collectionVarName} = {fieldName}.Value.Object;
}}
else
{{
collection = new {typedSymbol.NonNullableTypeFullName}();
{collectionVarName} = new {typedSymbol.NonNullableTypeFullName}();
}}

foreach (var configure in configures)
{{
var childBuilder = new {childBuilderName}();
childBuilder = configure(childBuilder);
collection.Add(childBuilder.Build());
}}
{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)
{{
{methodBody}

{fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>(collection);
{fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>({collectionVarName});
return this;
}}";
}
Expand Down
9 changes: 7 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +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
## 8.7.1.0 - 2025-12-4

### Fixed
- **Nullable dictionary support**: Fixed compilation error when models have nullable dictionary properties (e.g., `Dictionary<string, string>?`, `IDictionary<int, string>?`, `IReadOnlyDictionary<string, object>?`). The generator now correctly handles nullable reference types in `NullBox<T>` type parameters by stripping the nullable annotation.
- **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<ChildBuilder, ChildBuilder>[]` 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
- **Nullable collections support**: Fixed compilation error when models have nullable collection properties (e.g., `Dictionary<string, string>?`, `IDictionary<int, string>?`, `IReadOnlyDictionary<string, object>?`). The generator now correctly handles nullable reference types in `NullBox<T>` type parameters by stripping the nullable annotation.

## 8.7.0.0 - 2025-11-27

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace Buildenator.IntegrationTests.SharedEntities;

/// <summary>
/// A parent entity that contains arrays of child entities, used to test UseChildBuilders feature with arrays.
/// </summary>
public class ParentWithChildArrayEntity
{
public ParentWithChildArrayEntity(ChildForParentEntity[] children, int parentValue)
{
Children = children;
ParentValue = parentValue;
}

public ChildForParentEntity[] Children { get; }
public int ParentValue { get; }

/// <summary>
/// A settable property with an array of children for testing AddTo methods with child builder configuration.
/// </summary>
public ChildForParentEntity[] OptionalChildren { get; set; } = [];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Buildenator.Abstraction;
using Buildenator.IntegrationTests.SharedEntities;

namespace Buildenator.IntegrationTests.Source.Builders;

/// <summary>
/// Builder for ParentWithChildArrayEntity with UseChildBuilders enabled.
/// This will generate AddTo methods that accept Func&lt;ChildBuilder, ChildBuilder&gt; for arrays containing buildable entities.
/// </summary>
[MakeBuilder(typeof(ParentWithChildArrayEntity), generateDefaultBuildMethod: false, useChildBuilders: true)]
public partial class ParentWithChildArrayEntityBuilder
{
}
102 changes: 102 additions & 0 deletions Tests/Buildenator.IntegrationTests/BuildersGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChildBuilder, ChildBuilder> 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

Expand Down
Loading