Skip to content
17 changes: 17 additions & 0 deletions Buildenator/CodeAnalysis/ITypedSymbol.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ internal interface ITypedSymbol
string SymbolName { get; }
string SymbolPascalName { get; }
string TypeFullName { get; }
/// <summary>
/// Gets the type name suitable for use as a NullBox generic parameter.
/// For nullable reference types, returns the non-nullable version.
/// For nullable value types, returns the type as-is since Nullable&lt;T&gt; is the actual type.
/// </summary>
string NonNullableTypeFullName { get; }
string TypeName { get; }
string UnderScoreName { get; }

Expand Down Expand Up @@ -110,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)
{
}
}
29 changes: 29 additions & 0 deletions Buildenator/CodeAnalysis/TypedSymbol.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,35 @@ private TypedSymbol(
private string? _typeFullName;
public string TypeFullName => _typeFullName ??= Type.ToDisplayString();

private string? _nonNullableTypeFullName;
/// <summary>
/// Gets the type name suitable for use as a NullBox generic parameter.
/// For nullable reference types (e.g., "string?", "Dictionary&lt;K,V&gt;?"), returns the non-nullable version.
/// For nullable value types (e.g., "int?"), returns the type as-is since Nullable&lt;T&gt; is the actual type.
/// </summary>
public string NonNullableTypeFullName => _nonNullableTypeFullName ??= GetNonNullableTypeFullName();

private string GetNonNullableTypeFullName()
{
// For nullable value types (e.g., int?, DateTime?), keep the nullable form
// because Nullable<T> is the actual type, not just an annotation
if (Type.TypeKind == TypeKind.Struct && Type is INamedTypeSymbol namedType &&
namedType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T)
{
return TypeFullName;
}

// For nullable reference types, strip the nullable annotation
// This handles cases like string?, Dictionary<K,V>?, etc.
if (Type.NullableAnnotation == NullableAnnotation.Annotated)
{
return Type.WithNullableAnnotation(NullableAnnotation.NotAnnotated).ToDisplayString();
}

// For non-nullable types, return as-is
return TypeFullName;
}

public string TypeName => Type.Name;

public string SymbolPascalName => Symbol.PascalCaseName();
Expand Down
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
5 changes: 3 additions & 2 deletions Buildenator/Generators/ConstructorsGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,12 @@ private static string GenerateEmptyCollectionInitialization(ITypedSymbol typedSy
{
var fieldName = typedSymbol.UnderScoreName;
var typeFullName = typedSymbol.TypeFullName;
var nonNullableTypeFullName = typedSymbol.NonNullableTypeFullName;

// For concrete dictionary types, create new instance
if (collectionMetadata is ConcreteDictionaryMetadata)
{
return $"{fieldName} = new {DefaultConstants.NullBox}<{typeFullName}>(new {typeFullName}());";
return $"{fieldName} = new {DefaultConstants.NullBox}<{typeFullName}>(new {nonNullableTypeFullName}());";
}

// For interface dictionary types, create a Dictionary<K,V>
Expand All @@ -103,7 +104,7 @@ private static string GenerateEmptyCollectionInitialization(ITypedSymbol typedSy
// Standard .NET collections (List<T>, HashSet<T>, Collection<T>, etc.) all support this.
if (collectionMetadata is ConcreteCollectionMetadata)
{
return $"{fieldName} = new {DefaultConstants.NullBox}<{typeFullName}>(new {typeFullName}());";
return $"{fieldName} = new {DefaultConstants.NullBox}<{typeFullName}>(new {nonNullableTypeFullName}());";
}

// For interface collection types, create a List<T>
Expand Down
113 changes: 85 additions & 28 deletions Buildenator/Generators/PropertiesStringGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -189,9 +189,12 @@ 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})";

return $"{typedSymbol.UnderScoreName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>({DefaultConstants.ValueLiteral})";
}

private string CreateMethodName(ITypedSymbol property) => $"{_builder.BuildingMethodsPrefix}{property.SymbolPascalName}";

Expand Down Expand Up @@ -219,7 +222,7 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol)
}}
else
{{
dictionary = new {typedSymbol.TypeFullName}();
dictionary = new {typedSymbol.NonNullableTypeFullName}();
}}

foreach (var item in items)
Expand Down Expand Up @@ -252,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)
{{
collection = {fieldName}.Value.Object;
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
{{
collection = new {typedSymbol.TypeFullName}();
{collectionVarName} = items;
}}"
: $@"if ({fieldName} != null && {fieldName}.HasValue && {fieldName}.Value.Object != null)
{{
{collectionVarName} = {fieldName}.Value.Object;
}}
else
{{
{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 @@ -315,13 +336,12 @@ private string GenerateChildBuilderMethodDefinition(ITypedSymbol typedSymbol)

var methodName = CreateMethodName(typedSymbol);
var fieldName = typedSymbol.UnderScoreName;
var entityTypeName = typedSymbol.TypeFullName;

return $@"public {_builder.FullName} {methodName}(System.Func<{childBuilderName}, {childBuilderName}> configure{typedSymbol.SymbolPascalName})
{{
var childBuilder = new {childBuilderName}();
childBuilder = configure{typedSymbol.SymbolPascalName}(childBuilder);
{fieldName} = new {DefaultConstants.NullBox}<{entityTypeName}>(childBuilder.Build());
{fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>(childBuilder.Build());
return this;
}}";
}
Expand All @@ -335,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
{{
{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.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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ 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).

## 8.7.1.0 - 2025-12-4

### 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<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

### Added
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,41 @@
using System.Collections.Generic;

namespace Buildenator.IntegrationTests.SharedEntitiesNullable
{
/// <summary>
/// Entity used to test nullable dictionary handling in generated builders.
/// Tests various nullable dictionary types.
/// </summary>
public class EntityWithNullableDictionary
{
/// <summary>
/// Nullable Dictionary property
/// </summary>
public Dictionary<string, string>? Metadata { get; set; }

/// <summary>
/// Nullable IDictionary property
/// </summary>
public IDictionary<int, string>? Items { get; set; }

/// <summary>
/// Nullable IReadOnlyDictionary property
/// </summary>
public IReadOnlyDictionary<string, object>? Settings { get; set; }

/// <summary>
/// Another nullable Dictionary property
/// </summary>
public Dictionary<string, int>? Scores { get; set; }

/// <summary>
/// Simple non-dictionary property for comparison
/// </summary>
public string? Name { get; set; }

/// <summary>
/// Nullable value type property to test int? handling
/// </summary>
public int? Count { 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
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using Buildenator.Abstraction;
using Buildenator.IntegrationTests.SharedEntitiesNullable;

namespace Buildenator.IntegrationTests.SourceNullable.Builders
{
[MakeBuilder(typeof(EntityWithNullableDictionary))]
public partial class EntityWithNullableDictionaryBuilder
{
}
}
Loading
Loading