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

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

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

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

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

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

public string TypeName => Type.Name;

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

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

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

// For interface collection types, create a List<T>
Expand Down
18 changes: 10 additions & 8 deletions Buildenator/Generators/PropertiesStringGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -189,9 +189,12 @@ private string GenerateMethodDefinitionHeader(ITypedSymbol typedSymbol)
=> $"public {_builder.FullName} {CreateMethodName(typedSymbol)}({typedSymbol.GenerateMethodParameterDefinition()})";

private static string GenerateValueAssignment(ITypedSymbol typedSymbol)
=> typedSymbol.IsMockable()
? $"{DefaultConstants.SetupActionLiteral}({typedSymbol.UnderScoreName})"
: $"{typedSymbol.UnderScoreName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>({DefaultConstants.ValueLiteral})";
{
if (typedSymbol.IsMockable())
return $"{DefaultConstants.SetupActionLiteral}({typedSymbol.UnderScoreName})";

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

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

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

foreach (var item in items)
Expand Down Expand Up @@ -264,7 +267,7 @@ private string GenerateAddToMethodDefinition(ITypedSymbol typedSymbol)
}}
else
{{
collection = new {typedSymbol.TypeFullName}();
collection = new {typedSymbol.NonNullableTypeFullName}();
}}

foreach (var item in items)
Expand Down Expand Up @@ -315,13 +318,12 @@ private string GenerateChildBuilderMethodDefinition(ITypedSymbol typedSymbol)

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

return $@"public {_builder.FullName} {methodName}(System.Func<{childBuilderName}, {childBuilderName}> configure{typedSymbol.SymbolPascalName})
{{
var childBuilder = new {childBuilderName}();
childBuilder = configure{typedSymbol.SymbolPascalName}(childBuilder);
{fieldName} = new {DefaultConstants.NullBox}<{entityTypeName}>(childBuilder.Build());
{fieldName} = new {DefaultConstants.NullBox}<{typedSymbol.TypeFullName}>(childBuilder.Build());
return this;
}}";
}
Expand All @@ -347,7 +349,7 @@ private string GenerateChildBuilderAddToMethodDefinition(ITypedSymbol typedSymbo
}}
else
{{
collection = new {typedSymbol.TypeFullName}();
collection = new {typedSymbol.NonNullableTypeFullName}();
}}

foreach (var configure in configures)
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>?`, `IDictionary<int, string>?`, `IReadOnlyDictionary<string, object>?`). The generator now correctly handles nullable reference types in `NullBox<T>` type parameters by stripping the nullable annotation.

## 8.7.0.0 - 2025-11-27

### Added
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System.Collections.Generic;

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

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

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

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

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

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

namespace Buildenator.IntegrationTests.SourceNullable.Builders
{
[MakeBuilder(typeof(EntityWithNullableDictionary))]
public partial class EntityWithNullableDictionaryBuilder
{
}
}
105 changes: 105 additions & 0 deletions Tests/Buildenator.IntegrationTests/BuildersGeneratorNullableTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -258,5 +258,110 @@ 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<string, string> { { 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<int, string> { { 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<string, string>(key1, value1),
new KeyValuePair<string, string>(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);
}

[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();
}
}
}
Loading