From 83e04ebd92eeefda1fabcbff8f7c96d883cf758f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Dec 2025 10:30:36 +0000 Subject: [PATCH 1/8] Add test case for nullable dictionary support Co-authored-by: pmrogala <9459432+pmrogala@users.noreply.github.com> --- .../EntityWithNullableDictionary.cs | 36 +++++++++++++++++++ .../EntityWithNullableDictionaryBuilder.cs | 10 ++++++ 2 files changed, 46 insertions(+) create mode 100644 Tests/Buildenator.IntegrationTests.SharedEntitiesNullable/EntityWithNullableDictionary.cs create mode 100644 Tests/Buildenator.IntegrationTests.SourceNullable/Builders/EntityWithNullableDictionaryBuilder.cs diff --git a/Tests/Buildenator.IntegrationTests.SharedEntitiesNullable/EntityWithNullableDictionary.cs b/Tests/Buildenator.IntegrationTests.SharedEntitiesNullable/EntityWithNullableDictionary.cs new file mode 100644 index 0000000..a36a9be --- /dev/null +++ b/Tests/Buildenator.IntegrationTests.SharedEntitiesNullable/EntityWithNullableDictionary.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; + +namespace Buildenator.IntegrationTests.SharedEntitiesNullable +{ + /// + /// Entity used to test nullable dictionary handling in generated builders. + /// Tests various nullable dictionary types. + /// + public class EntityWithNullableDictionary + { + /// + /// Nullable Dictionary property + /// + public Dictionary? Metadata { get; set; } + + /// + /// Nullable IDictionary property + /// + public IDictionary? Items { get; set; } + + /// + /// Nullable IReadOnlyDictionary property + /// + public IReadOnlyDictionary? Settings { get; set; } + + /// + /// Another nullable Dictionary property + /// + public Dictionary? Scores { get; set; } + + /// + /// Simple non-dictionary property for comparison + /// + public string? Name { get; set; } + } +} diff --git a/Tests/Buildenator.IntegrationTests.SourceNullable/Builders/EntityWithNullableDictionaryBuilder.cs b/Tests/Buildenator.IntegrationTests.SourceNullable/Builders/EntityWithNullableDictionaryBuilder.cs new file mode 100644 index 0000000..e4f4b6f --- /dev/null +++ b/Tests/Buildenator.IntegrationTests.SourceNullable/Builders/EntityWithNullableDictionaryBuilder.cs @@ -0,0 +1,10 @@ +using Buildenator.Abstraction; +using Buildenator.IntegrationTests.SharedEntitiesNullable; + +namespace Buildenator.IntegrationTests.SourceNullable.Builders +{ + [MakeBuilder(typeof(EntityWithNullableDictionary))] + public partial class EntityWithNullableDictionaryBuilder + { + } +} From a5e7b199780a51ad2615e45bec4a42f1394fb3ea 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:27 +0000 Subject: [PATCH 2/8] Fix nullable dictionary support in builder generation Co-authored-by: pmrogala <9459432+pmrogala@users.noreply.github.com> --- Buildenator/CodeAnalysis/ITypedSymbol.cs | 1 + Buildenator/CodeAnalysis/TypedSymbol.cs | 7 +- .../Generators/ConstructorsGenerator.cs | 2 +- .../Generators/PropertiesStringGenerator.cs | 34 +++++---- .../BuildersGeneratorNullableTests.cs | 74 +++++++++++++++++++ 5 files changed, 101 insertions(+), 17 deletions(-) diff --git a/Buildenator/CodeAnalysis/ITypedSymbol.cs b/Buildenator/CodeAnalysis/ITypedSymbol.cs index a693a13..be3c777 100644 --- a/Buildenator/CodeAnalysis/ITypedSymbol.cs +++ b/Buildenator/CodeAnalysis/ITypedSymbol.cs @@ -7,6 +7,7 @@ internal interface ITypedSymbol string SymbolName { get; } string SymbolPascalName { get; } string TypeFullName { get; } + string NonNullableTypeFullName { get; } string TypeName { get; } string UnderScoreName { get; } diff --git a/Buildenator/CodeAnalysis/TypedSymbol.cs b/Buildenator/CodeAnalysis/TypedSymbol.cs index 68d6e67..943d616 100644 --- a/Buildenator/CodeAnalysis/TypedSymbol.cs +++ b/Buildenator/CodeAnalysis/TypedSymbol.cs @@ -56,6 +56,9 @@ private TypedSymbol( private string? _typeFullName; public string TypeFullName => _typeFullName ??= Type.ToDisplayString(); + private string? _nonNullableTypeFullName; + public string NonNullableTypeFullName => _nonNullableTypeFullName ??= Type.WithNullableAnnotation(NullableAnnotation.NotAnnotated).ToDisplayString(); + public string TypeName => Type.Name; public string SymbolPascalName => Symbol.PascalCaseName(); @@ -135,12 +138,12 @@ public string GenerateFieldType() } public string GenerateLazyFieldType() - => IsMockable() ? GenerateMockableFieldType() : $"{DefaultConstants.NullBox}<{TypeFullName}>?"; + => IsMockable() ? GenerateMockableFieldType() : $"{DefaultConstants.NullBox}<{NonNullableTypeFullName}>?"; public string GenerateLazyFieldValueReturn() => IsMockable() ? string.Format(_mockingProperties!.ReturnObjectFormat, UnderScoreName) - : @$"({UnderScoreName}.HasValue ? {UnderScoreName}.Value : new {DefaultConstants.NullBox}<{TypeFullName}>({(IsFakeable() + : @$"({UnderScoreName}.HasValue ? {UnderScoreName}.Value : new {DefaultConstants.NullBox}<{NonNullableTypeFullName}>({(IsFakeable() ? $"{string.Format(_fixtureProperties!.CreateSingleFormat, TypeFullName, SymbolName, DefaultConstants.FixtureLiteral)}" + (_nullableStrategy == NullableStrategy.Enabled ? "!" : "") : $"default({TypeFullName})")})).Object"; diff --git a/Buildenator/Generators/ConstructorsGenerator.cs b/Buildenator/Generators/ConstructorsGenerator.cs index 13bc7cf..13d7345 100644 --- a/Buildenator/Generators/ConstructorsGenerator.cs +++ b/Buildenator/Generators/ConstructorsGenerator.cs @@ -83,7 +83,7 @@ private static bool ShouldInitializeCollectionField(ITypedSymbol typedSymbol) private static string GenerateEmptyCollectionInitialization(ITypedSymbol typedSymbol, CollectionMetadata collectionMetadata) { var fieldName = typedSymbol.UnderScoreName; - var typeFullName = typedSymbol.TypeFullName; + var typeFullName = typedSymbol.NonNullableTypeFullName; // For concrete dictionary types, create new instance if (collectionMetadata is ConcreteDictionaryMetadata) diff --git a/Buildenator/Generators/PropertiesStringGenerator.cs b/Buildenator/Generators/PropertiesStringGenerator.cs index b330061..e18d839 100644 --- a/Buildenator/Generators/PropertiesStringGenerator.cs +++ b/Buildenator/Generators/PropertiesStringGenerator.cs @@ -175,7 +175,7 @@ private string GenerateFieldInitializer(ITypedSymbol typedSymbol) if (defaultValueName is null) return string.Empty; - return $" = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>({defaultValueName})"; + return $" = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>({defaultValueName})"; } private string GenerateMethodDefinition(ITypedSymbol typedSymbol) @@ -189,9 +189,15 @@ 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})"; + + // Add null-forgiving operator (!) for nullable reference types to suppress CS8604 warnings + // when assigning potentially null values to NullBox where T is non-nullable + var nullForgiving = typedSymbol.TypeFullName != typedSymbol.NonNullableTypeFullName ? "!" : ""; + return $"{typedSymbol.UnderScoreName} = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>({DefaultConstants.ValueLiteral}{nullForgiving})"; + } private string CreateMethodName(ITypedSymbol property) => $"{_builder.BuildingMethodsPrefix}{property.SymbolPascalName}"; @@ -219,7 +225,7 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol) }} else {{ - dictionary = new {typedSymbol.TypeFullName}(); + dictionary = new {typedSymbol.NonNullableTypeFullName}(); }} foreach (var item in items) @@ -227,7 +233,7 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol) dictionary[item.Key] = item.Value; }} - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>(dictionary); + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>(dictionary); return this; }}"; } @@ -247,7 +253,7 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol) {{ dictionary[item.Key] = item.Value; }} - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>(dictionary); + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>(dictionary); return this; }}"; } @@ -264,7 +270,7 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol) }} else {{ - collection = new {typedSymbol.TypeFullName}(); + collection = new {typedSymbol.NonNullableTypeFullName}(); }} foreach (var item in items) @@ -272,7 +278,7 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol) collection.Add(item); }} - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>(collection); + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>(collection); return this; }}"; } @@ -284,7 +290,7 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol) ? new System.Collections.Generic.List<{elementTypeName}>({fieldName}.Value.Object) : new System.Collections.Generic.List<{elementTypeName}>(); list.AddRange(items); - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>(({typedSymbol.TypeFullName})list); + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>(({typedSymbol.NonNullableTypeFullName})list); return this; }}"; } @@ -315,7 +321,7 @@ private string GenerateChildBuilderMethodDefinition(ITypedSymbol typedSymbol) var methodName = CreateMethodName(typedSymbol); var fieldName = typedSymbol.UnderScoreName; - var entityTypeName = typedSymbol.TypeFullName; + var entityTypeName = typedSymbol.NonNullableTypeFullName; return $@"public {_builder.FullName} {methodName}(System.Func<{childBuilderName}, {childBuilderName}> configure{typedSymbol.SymbolPascalName}) {{ @@ -347,7 +353,7 @@ private string GenerateChildBuilderAddToMethodDefinition(ITypedSymbol typedSymbo }} else {{ - collection = new {typedSymbol.TypeFullName}(); + collection = new {typedSymbol.NonNullableTypeFullName}(); }} foreach (var configure in configures) @@ -357,7 +363,7 @@ private string GenerateChildBuilderAddToMethodDefinition(ITypedSymbol typedSymbo collection.Add(childBuilder.Build()); }} - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>(collection); + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>(collection); return this; }}"; } @@ -376,7 +382,7 @@ private string GenerateChildBuilderAddToMethodDefinition(ITypedSymbol typedSymbo list.Add(childBuilder.Build()); }} - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>(({typedSymbol.TypeFullName})list); + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>(({typedSymbol.NonNullableTypeFullName})list); return this; }}"; } diff --git a/Tests/Buildenator.IntegrationTests/BuildersGeneratorNullableTests.cs b/Tests/Buildenator.IntegrationTests/BuildersGeneratorNullableTests.cs index f8e44cc..b160562 100644 --- a/Tests/Buildenator.IntegrationTests/BuildersGeneratorNullableTests.cs +++ b/Tests/Buildenator.IntegrationTests/BuildersGeneratorNullableTests.cs @@ -258,5 +258,79 @@ public void BuildersGenerator_ReadOnlyProperty_ShouldCreateMethodForSettingItsVa builder.WithPrivateField(privateField); builder.Build().PrivateField.Should().BeEquivalentTo(privateField); } + + [Theory] + [AutoData] + public void BuildersGenerator_NullableDictionary_WithMethodShouldSetValue(string key, string value) + { + // Arrange + var builder = EntityWithNullableDictionaryBuilder.EntityWithNullableDictionary; + var metadata = new Dictionary { { key, value } }; + + // Act + var result = builder + .WithMetadata(metadata) + .Build(); + + // Assert + _ = result.Metadata.Should().NotBeNull(); + _ = result.Metadata.Should().HaveCount(1); + _ = result.Metadata![key].Should().Be(value); + } + + [Theory] + [AutoData] + public void BuildersGenerator_NullableIDictionary_WithMethodShouldSetValue(int key, string value) + { + // Arrange + var builder = EntityWithNullableDictionaryBuilder.EntityWithNullableDictionary; + var items = new Dictionary { { key, value } }; + + // Act + var result = builder + .WithItems(items) + .Build(); + + // Assert + _ = result.Items.Should().NotBeNull(); + _ = result.Items.Should().HaveCount(1); + _ = result.Items![key].Should().Be(value); + } + + [Fact] + public void BuildersGenerator_NullableDictionary_WithNullValueShouldWork() + { + // Arrange + var builder = EntityWithNullableDictionaryBuilder.EntityWithNullableDictionary; + + // Act + var result = builder + .WithMetadata(null) + .Build(); + + // Assert + _ = result.Metadata.Should().BeNull(); + } + + [Theory] + [AutoData] + public void BuildersGenerator_NullableDictionary_AddToMethodShouldAddItems(string key1, string value1, string key2, string value2) + { + // Arrange + var builder = EntityWithNullableDictionaryBuilder.EntityWithNullableDictionary; + + // Act + var result = builder + .AddToMetadata( + new KeyValuePair(key1, value1), + new KeyValuePair(key2, value2)) + .Build(); + + // Assert + _ = result.Metadata.Should().NotBeNull(); + _ = result.Metadata.Should().HaveCount(2); + _ = result.Metadata![key1].Should().Be(value1); + _ = result.Metadata[key2].Should().Be(value2); + } } } From 0a3b63881c15913c6949a61cbcc50643bfe71d1a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Dec 2025 10:41:06 +0000 Subject: [PATCH 3/8] Update CHANGELOG for nullable dictionary fix Co-authored-by: pmrogala <9459432+pmrogala@users.noreply.github.com> --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4876234..f216d04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ 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 +- **Nullable dictionary support**: Fixed compilation error when models have nullable dictionary properties (e.g., `Dictionary?`, `IDictionary?`, `IReadOnlyDictionary?`). The generator now correctly handles nullable reference types in `NullBox` type parameters by stripping the nullable annotation. + ## 8.7.0.0 - 2025-11-27 ### Added From 157a1303414b9d592330dc87f381c6d912af308b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Dec 2025 10:46:27 +0000 Subject: [PATCH 4/8] Add XML documentation for NonNullableTypeFullName property Co-authored-by: pmrogala <9459432+pmrogala@users.noreply.github.com> --- Buildenator/CodeAnalysis/ITypedSymbol.cs | 4 ++++ Buildenator/CodeAnalysis/TypedSymbol.cs | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/Buildenator/CodeAnalysis/ITypedSymbol.cs b/Buildenator/CodeAnalysis/ITypedSymbol.cs index be3c777..5970470 100644 --- a/Buildenator/CodeAnalysis/ITypedSymbol.cs +++ b/Buildenator/CodeAnalysis/ITypedSymbol.cs @@ -7,6 +7,10 @@ internal interface ITypedSymbol string SymbolName { get; } string SymbolPascalName { get; } string TypeFullName { get; } + /// + /// Gets the full type name without nullable annotation. + /// Used for generic type parameters where nullable reference types are not allowed. + /// string NonNullableTypeFullName { get; } string TypeName { get; } string UnderScoreName { get; } diff --git a/Buildenator/CodeAnalysis/TypedSymbol.cs b/Buildenator/CodeAnalysis/TypedSymbol.cs index 943d616..46ef629 100644 --- a/Buildenator/CodeAnalysis/TypedSymbol.cs +++ b/Buildenator/CodeAnalysis/TypedSymbol.cs @@ -57,6 +57,10 @@ private TypedSymbol( public string TypeFullName => _typeFullName ??= Type.ToDisplayString(); private string? _nonNullableTypeFullName; + /// + /// Gets the full type name without nullable annotation (e.g., "Dictionary<string, string>" instead of "Dictionary<string, string>?"). + /// This is used for generic type parameters like NullBox<T> where nullable reference types are not allowed. + /// public string NonNullableTypeFullName => _nonNullableTypeFullName ??= Type.WithNullableAnnotation(NullableAnnotation.NotAnnotated).ToDisplayString(); public string TypeName => Type.Name; From 6e525e985338ad8629b5f07e3a511153eea90761 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Dec 2025 11:55:35 +0000 Subject: [PATCH 5/8] Refactor to use GetTypeFullNameForGenericParameter instead of NonNullableTypeFullName Co-authored-by: pmrogala <9459432+pmrogala@users.noreply.github.com> --- Buildenator/CodeAnalysis/ITypedSymbol.cs | 11 +++---- Buildenator/CodeAnalysis/TypedSymbol.cs | 21 ++++++++------ .../Generators/ConstructorsGenerator.cs | 10 +++---- .../Generators/PropertiesStringGenerator.cs | 29 +++++++++---------- 4 files changed, 37 insertions(+), 34 deletions(-) diff --git a/Buildenator/CodeAnalysis/ITypedSymbol.cs b/Buildenator/CodeAnalysis/ITypedSymbol.cs index 5970470..f7f4940 100644 --- a/Buildenator/CodeAnalysis/ITypedSymbol.cs +++ b/Buildenator/CodeAnalysis/ITypedSymbol.cs @@ -7,11 +7,6 @@ internal interface ITypedSymbol string SymbolName { get; } string SymbolPascalName { get; } string TypeFullName { get; } - /// - /// Gets the full type name without nullable annotation. - /// Used for generic type parameters where nullable reference types are not allowed. - /// - string NonNullableTypeFullName { get; } string TypeName { get; } string UnderScoreName { get; } @@ -37,6 +32,12 @@ internal interface ITypedSymbol /// /// The default value name (e.g., "DefaultName") if found, otherwise null. string? GetDefaultValueName(); + + /// + /// Returns the type full name suitable for use as a generic type parameter. + /// For nullable reference types, strips the nullable annotation to avoid CS8628 errors. + /// + string GetTypeFullNameForGenericParameter(); } /// diff --git a/Buildenator/CodeAnalysis/TypedSymbol.cs b/Buildenator/CodeAnalysis/TypedSymbol.cs index 46ef629..ab41818 100644 --- a/Buildenator/CodeAnalysis/TypedSymbol.cs +++ b/Buildenator/CodeAnalysis/TypedSymbol.cs @@ -56,13 +56,6 @@ private TypedSymbol( private string? _typeFullName; public string TypeFullName => _typeFullName ??= Type.ToDisplayString(); - private string? _nonNullableTypeFullName; - /// - /// Gets the full type name without nullable annotation (e.g., "Dictionary<string, string>" instead of "Dictionary<string, string>?"). - /// This is used for generic type parameters like NullBox<T> where nullable reference types are not allowed. - /// - public string NonNullableTypeFullName => _nonNullableTypeFullName ??= Type.WithNullableAnnotation(NullableAnnotation.NotAnnotated).ToDisplayString(); - public string TypeName => Type.Name; public string SymbolPascalName => Symbol.PascalCaseName(); @@ -142,12 +135,12 @@ public string GenerateFieldType() } public string GenerateLazyFieldType() - => IsMockable() ? GenerateMockableFieldType() : $"{DefaultConstants.NullBox}<{NonNullableTypeFullName}>?"; + => IsMockable() ? GenerateMockableFieldType() : $"{DefaultConstants.NullBox}<{GetTypeFullNameForGenericParameter()}>?"; public string GenerateLazyFieldValueReturn() => IsMockable() ? string.Format(_mockingProperties!.ReturnObjectFormat, UnderScoreName) - : @$"({UnderScoreName}.HasValue ? {UnderScoreName}.Value : new {DefaultConstants.NullBox}<{NonNullableTypeFullName}>({(IsFakeable() + : @$"({UnderScoreName}.HasValue ? {UnderScoreName}.Value : new {DefaultConstants.NullBox}<{GetTypeFullNameForGenericParameter()}>({(IsFakeable() ? $"{string.Format(_fixtureProperties!.CreateSingleFormat, TypeFullName, SymbolName, DefaultConstants.FixtureLiteral)}" + (_nullableStrategy == NullableStrategy.Enabled ? "!" : "") : $"default({TypeFullName})")})).Object"; @@ -160,6 +153,16 @@ public string GenerateFieldValueReturn() public string GenerateMethodParameterDefinition() => IsMockable() ? $"Action<{GenerateMockableFieldType()}> {DefaultConstants.SetupActionLiteral}" : $"{TypeFullName} {DefaultConstants.ValueLiteral}"; + /// + /// Returns the type full name suitable for use as a generic type parameter. + /// For nullable reference types, strips the nullable annotation to avoid CS8628 errors. + /// For example, "Dictionary<string, string>?" becomes "Dictionary<string, string>". + /// + public string GetTypeFullNameForGenericParameter() + => Type.NullableAnnotation == NullableAnnotation.Annotated + ? Type.WithNullableAnnotation(NullableAnnotation.NotAnnotated).ToDisplayString() + : TypeFullName; + public string? GetDefaultValueName() { if (_defaultValueNames == null) diff --git a/Buildenator/Generators/ConstructorsGenerator.cs b/Buildenator/Generators/ConstructorsGenerator.cs index 13d7345..0d72ca9 100644 --- a/Buildenator/Generators/ConstructorsGenerator.cs +++ b/Buildenator/Generators/ConstructorsGenerator.cs @@ -83,19 +83,19 @@ private static bool ShouldInitializeCollectionField(ITypedSymbol typedSymbol) private static string GenerateEmptyCollectionInitialization(ITypedSymbol typedSymbol, CollectionMetadata collectionMetadata) { var fieldName = typedSymbol.UnderScoreName; - var typeFullName = typedSymbol.NonNullableTypeFullName; + var typeForGenericParam = typedSymbol.GetTypeFullNameForGenericParameter(); // For concrete dictionary types, create new instance if (collectionMetadata is ConcreteDictionaryMetadata) { - return $"{fieldName} = new {DefaultConstants.NullBox}<{typeFullName}>(new {typeFullName}());"; + return $"{fieldName} = new {DefaultConstants.NullBox}<{typeForGenericParam}>(new {typeForGenericParam}());"; } // For interface dictionary types, create a Dictionary if (collectionMetadata is InterfaceDictionaryMetadata dictMetadata) { var dictionaryType = $"System.Collections.Generic.Dictionary<{dictMetadata.KeyTypeDisplayName}, {dictMetadata.ValueTypeDisplayName}>"; - return $"{fieldName} = new {DefaultConstants.NullBox}<{typeFullName}>(new {dictionaryType}());"; + return $"{fieldName} = new {DefaultConstants.NullBox}<{typeForGenericParam}>(new {dictionaryType}());"; } // For concrete collection types, create new instance. @@ -103,7 +103,7 @@ private static string GenerateEmptyCollectionInitialization(ITypedSymbol typedSy // Standard .NET collections (List, HashSet, Collection, etc.) all support this. if (collectionMetadata is ConcreteCollectionMetadata) { - return $"{fieldName} = new {DefaultConstants.NullBox}<{typeFullName}>(new {typeFullName}());"; + return $"{fieldName} = new {DefaultConstants.NullBox}<{typeForGenericParam}>(new {typeForGenericParam}());"; } // For interface collection types, create a List @@ -111,7 +111,7 @@ private static string GenerateEmptyCollectionInitialization(ITypedSymbol typedSy { var elementTypeName = collectionMetadata.ElementTypeDisplayName; var listType = $"System.Collections.Generic.List<{elementTypeName}>"; - return $"{fieldName} = new {DefaultConstants.NullBox}<{typeFullName}>(new {listType}());"; + return $"{fieldName} = new {DefaultConstants.NullBox}<{typeForGenericParam}>(new {listType}());"; } return string.Empty; diff --git a/Buildenator/Generators/PropertiesStringGenerator.cs b/Buildenator/Generators/PropertiesStringGenerator.cs index e18d839..54b11e4 100644 --- a/Buildenator/Generators/PropertiesStringGenerator.cs +++ b/Buildenator/Generators/PropertiesStringGenerator.cs @@ -175,7 +175,7 @@ private string GenerateFieldInitializer(ITypedSymbol typedSymbol) if (defaultValueName is null) return string.Empty; - return $" = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>({defaultValueName})"; + return $" = new {DefaultConstants.NullBox}<{typedSymbol.GetTypeFullNameForGenericParameter()}>({defaultValueName})"; } private string GenerateMethodDefinition(ITypedSymbol typedSymbol) @@ -193,10 +193,9 @@ private static string GenerateValueAssignment(ITypedSymbol typedSymbol) if (typedSymbol.IsMockable()) return $"{DefaultConstants.SetupActionLiteral}({typedSymbol.UnderScoreName})"; - // Add null-forgiving operator (!) for nullable reference types to suppress CS8604 warnings - // when assigning potentially null values to NullBox where T is non-nullable - var nullForgiving = typedSymbol.TypeFullName != typedSymbol.NonNullableTypeFullName ? "!" : ""; - return $"{typedSymbol.UnderScoreName} = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>({DefaultConstants.ValueLiteral}{nullForgiving})"; + // Add null-forgiving operator when the parameter is nullable but NullBox expects non-nullable + var nullForgiving = typedSymbol.TypeFullName != typedSymbol.GetTypeFullNameForGenericParameter() ? "!" : ""; + return $"{typedSymbol.UnderScoreName} = new {DefaultConstants.NullBox}<{typedSymbol.GetTypeFullNameForGenericParameter()}>({DefaultConstants.ValueLiteral}{nullForgiving})"; } private string CreateMethodName(ITypedSymbol property) => $"{_builder.BuildingMethodsPrefix}{property.SymbolPascalName}"; @@ -225,7 +224,7 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol) }} else {{ - dictionary = new {typedSymbol.NonNullableTypeFullName}(); + dictionary = new {typedSymbol.GetTypeFullNameForGenericParameter()}(); }} foreach (var item in items) @@ -233,7 +232,7 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol) dictionary[item.Key] = item.Value; }} - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>(dictionary); + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.GetTypeFullNameForGenericParameter()}>(dictionary); return this; }}"; } @@ -253,7 +252,7 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol) {{ dictionary[item.Key] = item.Value; }} - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>(dictionary); + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.GetTypeFullNameForGenericParameter()}>(dictionary); return this; }}"; } @@ -270,7 +269,7 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol) }} else {{ - collection = new {typedSymbol.NonNullableTypeFullName}(); + collection = new {typedSymbol.GetTypeFullNameForGenericParameter()}(); }} foreach (var item in items) @@ -278,7 +277,7 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol) collection.Add(item); }} - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>(collection); + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.GetTypeFullNameForGenericParameter()}>(collection); return this; }}"; } @@ -290,7 +289,7 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol) ? new System.Collections.Generic.List<{elementTypeName}>({fieldName}.Value.Object) : new System.Collections.Generic.List<{elementTypeName}>(); list.AddRange(items); - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>(({typedSymbol.NonNullableTypeFullName})list); + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.GetTypeFullNameForGenericParameter()}>(({typedSymbol.GetTypeFullNameForGenericParameter()})list); return this; }}"; } @@ -321,7 +320,7 @@ private string GenerateChildBuilderMethodDefinition(ITypedSymbol typedSymbol) var methodName = CreateMethodName(typedSymbol); var fieldName = typedSymbol.UnderScoreName; - var entityTypeName = typedSymbol.NonNullableTypeFullName; + var entityTypeName = typedSymbol.GetTypeFullNameForGenericParameter(); return $@"public {_builder.FullName} {methodName}(System.Func<{childBuilderName}, {childBuilderName}> configure{typedSymbol.SymbolPascalName}) {{ @@ -353,7 +352,7 @@ private string GenerateChildBuilderAddToMethodDefinition(ITypedSymbol typedSymbo }} else {{ - collection = new {typedSymbol.NonNullableTypeFullName}(); + collection = new {typedSymbol.GetTypeFullNameForGenericParameter()}(); }} foreach (var configure in configures) @@ -363,7 +362,7 @@ private string GenerateChildBuilderAddToMethodDefinition(ITypedSymbol typedSymbo collection.Add(childBuilder.Build()); }} - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>(collection); + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.GetTypeFullNameForGenericParameter()}>(collection); return this; }}"; } @@ -382,7 +381,7 @@ private string GenerateChildBuilderAddToMethodDefinition(ITypedSymbol typedSymbo list.Add(childBuilder.Build()); }} - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>(({typedSymbol.NonNullableTypeFullName})list); + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.GetTypeFullNameForGenericParameter()}>(({typedSymbol.GetTypeFullNameForGenericParameter()})list); return this; }}"; } From 06c8beb41aa663e235900654db191f0662c0c39b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Dec 2025 12:29:34 +0000 Subject: [PATCH 6/8] Fix NonNullableTypeFullName to preserve nullable value types (int?, etc.) Co-authored-by: pmrogala <9459432+pmrogala@users.noreply.github.com> --- Buildenator/CodeAnalysis/ITypedSymbol.cs | 12 +++--- Buildenator/CodeAnalysis/TypedSymbol.cs | 43 +++++++++++++------ .../Generators/ConstructorsGenerator.cs | 10 ++--- .../Generators/PropertiesStringGenerator.cs | 29 +++++++------ .../EntityWithNullableDictionary.cs | 5 +++ .../BuildersGeneratorNullableTests.cs | 31 +++++++++++++ 6 files changed, 93 insertions(+), 37 deletions(-) diff --git a/Buildenator/CodeAnalysis/ITypedSymbol.cs b/Buildenator/CodeAnalysis/ITypedSymbol.cs index f7f4940..acce241 100644 --- a/Buildenator/CodeAnalysis/ITypedSymbol.cs +++ b/Buildenator/CodeAnalysis/ITypedSymbol.cs @@ -7,6 +7,12 @@ internal interface ITypedSymbol string SymbolName { get; } string SymbolPascalName { get; } string TypeFullName { get; } + /// + /// 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<T> is the actual type. + /// + string NonNullableTypeFullName { get; } string TypeName { get; } string UnderScoreName { get; } @@ -32,12 +38,6 @@ internal interface ITypedSymbol /// /// The default value name (e.g., "DefaultName") if found, otherwise null. string? GetDefaultValueName(); - - /// - /// Returns the type full name suitable for use as a generic type parameter. - /// For nullable reference types, strips the nullable annotation to avoid CS8628 errors. - /// - string GetTypeFullNameForGenericParameter(); } /// diff --git a/Buildenator/CodeAnalysis/TypedSymbol.cs b/Buildenator/CodeAnalysis/TypedSymbol.cs index ab41818..863053a 100644 --- a/Buildenator/CodeAnalysis/TypedSymbol.cs +++ b/Buildenator/CodeAnalysis/TypedSymbol.cs @@ -56,6 +56,35 @@ private TypedSymbol( private string? _typeFullName; public string TypeFullName => _typeFullName ??= Type.ToDisplayString(); + private string? _nonNullableTypeFullName; + /// + /// Gets the type name suitable for use as a NullBox generic parameter. + /// For nullable reference types (e.g., "string?", "Dictionary<K,V>?"), returns the non-nullable version. + /// For nullable value types (e.g., "int?"), returns the type as-is since Nullable<T> is the actual type. + /// + public string NonNullableTypeFullName => _nonNullableTypeFullName ??= GetNonNullableTypeFullName(); + + private string GetNonNullableTypeFullName() + { + // For nullable value types (e.g., int?, DateTime?), keep the nullable form + // because Nullable 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?, 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(); @@ -135,12 +164,12 @@ public string GenerateFieldType() } public string GenerateLazyFieldType() - => IsMockable() ? GenerateMockableFieldType() : $"{DefaultConstants.NullBox}<{GetTypeFullNameForGenericParameter()}>?"; + => IsMockable() ? GenerateMockableFieldType() : $"{DefaultConstants.NullBox}<{NonNullableTypeFullName}>?"; public string GenerateLazyFieldValueReturn() => IsMockable() ? string.Format(_mockingProperties!.ReturnObjectFormat, UnderScoreName) - : @$"({UnderScoreName}.HasValue ? {UnderScoreName}.Value : new {DefaultConstants.NullBox}<{GetTypeFullNameForGenericParameter()}>({(IsFakeable() + : @$"({UnderScoreName}.HasValue ? {UnderScoreName}.Value : new {DefaultConstants.NullBox}<{NonNullableTypeFullName}>({(IsFakeable() ? $"{string.Format(_fixtureProperties!.CreateSingleFormat, TypeFullName, SymbolName, DefaultConstants.FixtureLiteral)}" + (_nullableStrategy == NullableStrategy.Enabled ? "!" : "") : $"default({TypeFullName})")})).Object"; @@ -153,16 +182,6 @@ public string GenerateFieldValueReturn() public string GenerateMethodParameterDefinition() => IsMockable() ? $"Action<{GenerateMockableFieldType()}> {DefaultConstants.SetupActionLiteral}" : $"{TypeFullName} {DefaultConstants.ValueLiteral}"; - /// - /// Returns the type full name suitable for use as a generic type parameter. - /// For nullable reference types, strips the nullable annotation to avoid CS8628 errors. - /// For example, "Dictionary<string, string>?" becomes "Dictionary<string, string>". - /// - public string GetTypeFullNameForGenericParameter() - => Type.NullableAnnotation == NullableAnnotation.Annotated - ? Type.WithNullableAnnotation(NullableAnnotation.NotAnnotated).ToDisplayString() - : TypeFullName; - public string? GetDefaultValueName() { if (_defaultValueNames == null) diff --git a/Buildenator/Generators/ConstructorsGenerator.cs b/Buildenator/Generators/ConstructorsGenerator.cs index 0d72ca9..13d7345 100644 --- a/Buildenator/Generators/ConstructorsGenerator.cs +++ b/Buildenator/Generators/ConstructorsGenerator.cs @@ -83,19 +83,19 @@ private static bool ShouldInitializeCollectionField(ITypedSymbol typedSymbol) private static string GenerateEmptyCollectionInitialization(ITypedSymbol typedSymbol, CollectionMetadata collectionMetadata) { var fieldName = typedSymbol.UnderScoreName; - var typeForGenericParam = typedSymbol.GetTypeFullNameForGenericParameter(); + var typeFullName = typedSymbol.NonNullableTypeFullName; // For concrete dictionary types, create new instance if (collectionMetadata is ConcreteDictionaryMetadata) { - return $"{fieldName} = new {DefaultConstants.NullBox}<{typeForGenericParam}>(new {typeForGenericParam}());"; + return $"{fieldName} = new {DefaultConstants.NullBox}<{typeFullName}>(new {typeFullName}());"; } // For interface dictionary types, create a Dictionary if (collectionMetadata is InterfaceDictionaryMetadata dictMetadata) { var dictionaryType = $"System.Collections.Generic.Dictionary<{dictMetadata.KeyTypeDisplayName}, {dictMetadata.ValueTypeDisplayName}>"; - return $"{fieldName} = new {DefaultConstants.NullBox}<{typeForGenericParam}>(new {dictionaryType}());"; + return $"{fieldName} = new {DefaultConstants.NullBox}<{typeFullName}>(new {dictionaryType}());"; } // For concrete collection types, create new instance. @@ -103,7 +103,7 @@ private static string GenerateEmptyCollectionInitialization(ITypedSymbol typedSy // Standard .NET collections (List, HashSet, Collection, etc.) all support this. if (collectionMetadata is ConcreteCollectionMetadata) { - return $"{fieldName} = new {DefaultConstants.NullBox}<{typeForGenericParam}>(new {typeForGenericParam}());"; + return $"{fieldName} = new {DefaultConstants.NullBox}<{typeFullName}>(new {typeFullName}());"; } // For interface collection types, create a List @@ -111,7 +111,7 @@ private static string GenerateEmptyCollectionInitialization(ITypedSymbol typedSy { var elementTypeName = collectionMetadata.ElementTypeDisplayName; var listType = $"System.Collections.Generic.List<{elementTypeName}>"; - return $"{fieldName} = new {DefaultConstants.NullBox}<{typeForGenericParam}>(new {listType}());"; + return $"{fieldName} = new {DefaultConstants.NullBox}<{typeFullName}>(new {listType}());"; } return string.Empty; diff --git a/Buildenator/Generators/PropertiesStringGenerator.cs b/Buildenator/Generators/PropertiesStringGenerator.cs index 54b11e4..e18d839 100644 --- a/Buildenator/Generators/PropertiesStringGenerator.cs +++ b/Buildenator/Generators/PropertiesStringGenerator.cs @@ -175,7 +175,7 @@ private string GenerateFieldInitializer(ITypedSymbol typedSymbol) if (defaultValueName is null) return string.Empty; - return $" = new {DefaultConstants.NullBox}<{typedSymbol.GetTypeFullNameForGenericParameter()}>({defaultValueName})"; + return $" = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>({defaultValueName})"; } private string GenerateMethodDefinition(ITypedSymbol typedSymbol) @@ -193,9 +193,10 @@ private static string GenerateValueAssignment(ITypedSymbol typedSymbol) if (typedSymbol.IsMockable()) return $"{DefaultConstants.SetupActionLiteral}({typedSymbol.UnderScoreName})"; - // Add null-forgiving operator when the parameter is nullable but NullBox expects non-nullable - var nullForgiving = typedSymbol.TypeFullName != typedSymbol.GetTypeFullNameForGenericParameter() ? "!" : ""; - return $"{typedSymbol.UnderScoreName} = new {DefaultConstants.NullBox}<{typedSymbol.GetTypeFullNameForGenericParameter()}>({DefaultConstants.ValueLiteral}{nullForgiving})"; + // Add null-forgiving operator (!) for nullable reference types to suppress CS8604 warnings + // when assigning potentially null values to NullBox where T is non-nullable + var nullForgiving = typedSymbol.TypeFullName != typedSymbol.NonNullableTypeFullName ? "!" : ""; + return $"{typedSymbol.UnderScoreName} = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>({DefaultConstants.ValueLiteral}{nullForgiving})"; } private string CreateMethodName(ITypedSymbol property) => $"{_builder.BuildingMethodsPrefix}{property.SymbolPascalName}"; @@ -224,7 +225,7 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol) }} else {{ - dictionary = new {typedSymbol.GetTypeFullNameForGenericParameter()}(); + dictionary = new {typedSymbol.NonNullableTypeFullName}(); }} foreach (var item in items) @@ -232,7 +233,7 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol) dictionary[item.Key] = item.Value; }} - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.GetTypeFullNameForGenericParameter()}>(dictionary); + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>(dictionary); return this; }}"; } @@ -252,7 +253,7 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol) {{ dictionary[item.Key] = item.Value; }} - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.GetTypeFullNameForGenericParameter()}>(dictionary); + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>(dictionary); return this; }}"; } @@ -269,7 +270,7 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol) }} else {{ - collection = new {typedSymbol.GetTypeFullNameForGenericParameter()}(); + collection = new {typedSymbol.NonNullableTypeFullName}(); }} foreach (var item in items) @@ -277,7 +278,7 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol) collection.Add(item); }} - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.GetTypeFullNameForGenericParameter()}>(collection); + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>(collection); return this; }}"; } @@ -289,7 +290,7 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol) ? new System.Collections.Generic.List<{elementTypeName}>({fieldName}.Value.Object) : new System.Collections.Generic.List<{elementTypeName}>(); list.AddRange(items); - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.GetTypeFullNameForGenericParameter()}>(({typedSymbol.GetTypeFullNameForGenericParameter()})list); + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>(({typedSymbol.NonNullableTypeFullName})list); return this; }}"; } @@ -320,7 +321,7 @@ private string GenerateChildBuilderMethodDefinition(ITypedSymbol typedSymbol) var methodName = CreateMethodName(typedSymbol); var fieldName = typedSymbol.UnderScoreName; - var entityTypeName = typedSymbol.GetTypeFullNameForGenericParameter(); + var entityTypeName = typedSymbol.NonNullableTypeFullName; return $@"public {_builder.FullName} {methodName}(System.Func<{childBuilderName}, {childBuilderName}> configure{typedSymbol.SymbolPascalName}) {{ @@ -352,7 +353,7 @@ private string GenerateChildBuilderAddToMethodDefinition(ITypedSymbol typedSymbo }} else {{ - collection = new {typedSymbol.GetTypeFullNameForGenericParameter()}(); + collection = new {typedSymbol.NonNullableTypeFullName}(); }} foreach (var configure in configures) @@ -362,7 +363,7 @@ private string GenerateChildBuilderAddToMethodDefinition(ITypedSymbol typedSymbo collection.Add(childBuilder.Build()); }} - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.GetTypeFullNameForGenericParameter()}>(collection); + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>(collection); return this; }}"; } @@ -381,7 +382,7 @@ private string GenerateChildBuilderAddToMethodDefinition(ITypedSymbol typedSymbo list.Add(childBuilder.Build()); }} - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.GetTypeFullNameForGenericParameter()}>(({typedSymbol.GetTypeFullNameForGenericParameter()})list); + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>(({typedSymbol.NonNullableTypeFullName})list); return this; }}"; } diff --git a/Tests/Buildenator.IntegrationTests.SharedEntitiesNullable/EntityWithNullableDictionary.cs b/Tests/Buildenator.IntegrationTests.SharedEntitiesNullable/EntityWithNullableDictionary.cs index a36a9be..68da425 100644 --- a/Tests/Buildenator.IntegrationTests.SharedEntitiesNullable/EntityWithNullableDictionary.cs +++ b/Tests/Buildenator.IntegrationTests.SharedEntitiesNullable/EntityWithNullableDictionary.cs @@ -32,5 +32,10 @@ public class EntityWithNullableDictionary /// Simple non-dictionary property for comparison /// public string? Name { get; set; } + + /// + /// Nullable value type property to test int? handling + /// + public int? Count { get; set; } } } diff --git a/Tests/Buildenator.IntegrationTests/BuildersGeneratorNullableTests.cs b/Tests/Buildenator.IntegrationTests/BuildersGeneratorNullableTests.cs index b160562..738eb6d 100644 --- a/Tests/Buildenator.IntegrationTests/BuildersGeneratorNullableTests.cs +++ b/Tests/Buildenator.IntegrationTests/BuildersGeneratorNullableTests.cs @@ -332,5 +332,36 @@ public void BuildersGenerator_NullableDictionary_AddToMethodShouldAddItems(strin _ = result.Metadata![key1].Should().Be(value1); _ = result.Metadata[key2].Should().Be(value2); } + + [Theory] + [AutoData] + public void BuildersGenerator_NullableValueType_WithMethodShouldSetValue(int value) + { + // Arrange + var builder = EntityWithNullableDictionaryBuilder.EntityWithNullableDictionary; + + // Act + var result = builder + .WithCount(value) + .Build(); + + // Assert + _ = result.Count.Should().Be(value); + } + + [Fact] + public void BuildersGenerator_NullableValueType_WithNullValueShouldWork() + { + // Arrange + var builder = EntityWithNullableDictionaryBuilder.EntityWithNullableDictionary; + + // Act + var result = builder + .WithCount(null) + .Build(); + + // Assert + _ = result.Count.Should().BeNull(); + } } } From 280cdbf46dfa970cc8d4845f07d05d3cd5487615 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Dec 2025 12:43:00 +0000 Subject: [PATCH 7/8] Use NonNullableTypeFullName only for new instantiations, not NullBox generic parameters Co-authored-by: pmrogala <9459432+pmrogala@users.noreply.github.com> --- Buildenator/CodeAnalysis/TypedSymbol.cs | 4 ++-- .../Generators/ConstructorsGenerator.cs | 7 +++--- .../Generators/PropertiesStringGenerator.cs | 22 ++++++++----------- 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/Buildenator/CodeAnalysis/TypedSymbol.cs b/Buildenator/CodeAnalysis/TypedSymbol.cs index 863053a..bb620de 100644 --- a/Buildenator/CodeAnalysis/TypedSymbol.cs +++ b/Buildenator/CodeAnalysis/TypedSymbol.cs @@ -164,12 +164,12 @@ public string GenerateFieldType() } public string GenerateLazyFieldType() - => IsMockable() ? GenerateMockableFieldType() : $"{DefaultConstants.NullBox}<{NonNullableTypeFullName}>?"; + => IsMockable() ? GenerateMockableFieldType() : $"{DefaultConstants.NullBox}<{TypeFullName}>?"; public string GenerateLazyFieldValueReturn() => IsMockable() ? string.Format(_mockingProperties!.ReturnObjectFormat, UnderScoreName) - : @$"({UnderScoreName}.HasValue ? {UnderScoreName}.Value : new {DefaultConstants.NullBox}<{NonNullableTypeFullName}>({(IsFakeable() + : @$"({UnderScoreName}.HasValue ? {UnderScoreName}.Value : new {DefaultConstants.NullBox}<{TypeFullName}>({(IsFakeable() ? $"{string.Format(_fixtureProperties!.CreateSingleFormat, TypeFullName, SymbolName, DefaultConstants.FixtureLiteral)}" + (_nullableStrategy == NullableStrategy.Enabled ? "!" : "") : $"default({TypeFullName})")})).Object"; diff --git a/Buildenator/Generators/ConstructorsGenerator.cs b/Buildenator/Generators/ConstructorsGenerator.cs index 13d7345..19c7dc5 100644 --- a/Buildenator/Generators/ConstructorsGenerator.cs +++ b/Buildenator/Generators/ConstructorsGenerator.cs @@ -83,12 +83,13 @@ private static bool ShouldInitializeCollectionField(ITypedSymbol typedSymbol) private static string GenerateEmptyCollectionInitialization(ITypedSymbol typedSymbol, CollectionMetadata collectionMetadata) { var fieldName = typedSymbol.UnderScoreName; - var typeFullName = typedSymbol.NonNullableTypeFullName; + 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 @@ -103,7 +104,7 @@ private static string GenerateEmptyCollectionInitialization(ITypedSymbol typedSy // Standard .NET collections (List, HashSet, Collection, 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 diff --git a/Buildenator/Generators/PropertiesStringGenerator.cs b/Buildenator/Generators/PropertiesStringGenerator.cs index e18d839..ea5bcba 100644 --- a/Buildenator/Generators/PropertiesStringGenerator.cs +++ b/Buildenator/Generators/PropertiesStringGenerator.cs @@ -175,7 +175,7 @@ private string GenerateFieldInitializer(ITypedSymbol typedSymbol) if (defaultValueName is null) return string.Empty; - return $" = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>({defaultValueName})"; + return $" = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>({defaultValueName})"; } private string GenerateMethodDefinition(ITypedSymbol typedSymbol) @@ -193,10 +193,7 @@ private static string GenerateValueAssignment(ITypedSymbol typedSymbol) if (typedSymbol.IsMockable()) return $"{DefaultConstants.SetupActionLiteral}({typedSymbol.UnderScoreName})"; - // Add null-forgiving operator (!) for nullable reference types to suppress CS8604 warnings - // when assigning potentially null values to NullBox where T is non-nullable - var nullForgiving = typedSymbol.TypeFullName != typedSymbol.NonNullableTypeFullName ? "!" : ""; - return $"{typedSymbol.UnderScoreName} = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>({DefaultConstants.ValueLiteral}{nullForgiving})"; + return $"{typedSymbol.UnderScoreName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>({DefaultConstants.ValueLiteral})"; } private string CreateMethodName(ITypedSymbol property) => $"{_builder.BuildingMethodsPrefix}{property.SymbolPascalName}"; @@ -233,7 +230,7 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol) dictionary[item.Key] = item.Value; }} - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>(dictionary); + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>(dictionary); return this; }}"; } @@ -253,7 +250,7 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol) {{ dictionary[item.Key] = item.Value; }} - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>(dictionary); + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>(dictionary); return this; }}"; } @@ -278,7 +275,7 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol) collection.Add(item); }} - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>(collection); + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>(collection); return this; }}"; } @@ -290,7 +287,7 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol) ? new System.Collections.Generic.List<{elementTypeName}>({fieldName}.Value.Object) : new System.Collections.Generic.List<{elementTypeName}>(); list.AddRange(items); - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>(({typedSymbol.NonNullableTypeFullName})list); + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>(({typedSymbol.TypeFullName})list); return this; }}"; } @@ -321,13 +318,12 @@ private string GenerateChildBuilderMethodDefinition(ITypedSymbol typedSymbol) var methodName = CreateMethodName(typedSymbol); var fieldName = typedSymbol.UnderScoreName; - var entityTypeName = typedSymbol.NonNullableTypeFullName; 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; }}"; } @@ -363,7 +359,7 @@ private string GenerateChildBuilderAddToMethodDefinition(ITypedSymbol typedSymbo collection.Add(childBuilder.Build()); }} - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>(collection); + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>(collection); return this; }}"; } @@ -382,7 +378,7 @@ private string GenerateChildBuilderAddToMethodDefinition(ITypedSymbol typedSymbo list.Add(childBuilder.Build()); }} - {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.NonNullableTypeFullName}>(({typedSymbol.NonNullableTypeFullName})list); + {fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>(({typedSymbol.TypeFullName})list); return this; }}"; } From 1cd3601c16e44f9db59bd76565d3120801044b78 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Dec 2025 17:10:24 +0100 Subject: [PATCH 8/8] Fix: Generate AddTo methods for array properties with child builders (#115) * Initial plan * 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> * Remove unused imports from ParentWithChildArrayEntity Co-authored-by: pmrogala <9459432+pmrogala@users.noreply.github.com> * 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> * 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> * 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> * 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> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: pmrogala <9459432+pmrogala@users.noreply.github.com> Co-authored-by: Cranter --- Buildenator/CodeAnalysis/ITypedSymbol.cs | 11 ++ .../Configuration/CollectionMethodDetector.cs | 7 ++ .../Generators/PropertiesStringGenerator.cs | 99 +++++++++++++---- CHANGELOG.md | 9 +- .../ParentWithChildArrayEntity.cs | 21 ++++ .../ParentWithChildArrayEntityBuilder.cs | 13 +++ .../BuildersGeneratorTests.cs | 102 ++++++++++++++++++ 7 files changed, 238 insertions(+), 24 deletions(-) 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 acce241..731aea1 100644 --- a/Buildenator/CodeAnalysis/ITypedSymbol.cs +++ b/Buildenator/CodeAnalysis/ITypedSymbol.cs @@ -116,4 +116,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 ea5bcba..083177c 100644 --- a/Buildenator/Generators/PropertiesStringGenerator.cs +++ b/Buildenator/Generators/PropertiesStringGenerator.cs @@ -255,27 +255,45 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol) }}"; } - // For concrete types, use new() and .Add() method from ICollection - 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; }}"; } @@ -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; }}"; } diff --git a/CHANGELOG.md b/CHANGELOG.md index f216d04..e825538 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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?`, `IDictionary?`, `IReadOnlyDictionary?`). The generator now correctly handles nullable reference types in `NullBox` 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[]` 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?`, `IDictionary?`, `IReadOnlyDictionary?`). The generator now correctly handles nullable reference types in `NullBox` type parameters by stripping the nullable annotation. ## 8.7.0.0 - 2025-11-27 diff --git a/Tests/Buildenator.IntegrationTests.SharedEntities/ParentWithChildArrayEntity.cs b/Tests/Buildenator.IntegrationTests.SharedEntities/ParentWithChildArrayEntity.cs new file mode 100644 index 0000000..80b05a8 --- /dev/null +++ b/Tests/Buildenator.IntegrationTests.SharedEntities/ParentWithChildArrayEntity.cs @@ -0,0 +1,21 @@ +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