From 62a17f878662fc654938eae4a81940dc3593f811 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Dec 2025 10:24:58 +0000 Subject: [PATCH 1/8] Initial plan From a3cdf9a49e6030fa70c3fca0bbd87ed6f08e87cc 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 2/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 3bed741704c0512d01fccaf089a8cad68a0fd506 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 3/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 616c4f22c5fd2fc441c4318005a1d1cc755f0df8 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 4/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 a93a181c6ef374f65372b4d4d0aaf3c0457fc565 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 5/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 6518f17c17250765bd0336bcc988f74c461feead 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 6/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 8ffbe25aaa7f4235449b4ca8a537492bd54a52be 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 7/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 8346b4ad66066cd7250292bfcf2e7967b1ef6d6f 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 8/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; }}"; }