From 0b47d1dadaf2fc7cd100151167c557bc2eb80eb5 Mon Sep 17 00:00:00 2001 From: Przemek Rogala Date: Fri, 6 Mar 2026 23:01:27 +0100 Subject: [PATCH 1/4] feat: add builderConstructorMandatoryParameters option to enforce mandatory constructor parameters in generated builders --- .../BuildenatorConfigurationAttribute.cs | 4 +- .../MakeBuilderAttribute.cs | 4 +- Buildenator/AnalyzerReleases.Unshipped.md | 3 +- .../Configuration/BuilderProperties.cs | 7 ++- .../Contract/IBuilderProperties.cs | 1 + .../BuildenatorDiagnosticDescriptors.cs | 8 +++ .../BuilderSourceStringGenerator.cs | 11 +++- .../Generators/ConstructorsGenerator.cs | 59 +++++++++++++++++ Buildenator/MakeBuilderAttributeInternal.cs | 7 ++- CHANGELOG.md | 31 +++++++++ README.md | 63 ++++++++++++++++++- ...ntityWithMandatoryConstructorParameters.cs | 16 +++++ ...thMandatoryConstructorParametersBuilder.cs | 10 +++ .../Buildenator.IntegrationTests.csproj | 1 + ...ildersGeneratorMandatoryParametersTests.cs | 53 ++++++++++++++++ 15 files changed, 268 insertions(+), 10 deletions(-) create mode 100644 Tests/Buildenator.IntegrationTests.SharedEntities/EntityWithMandatoryConstructorParameters.cs create mode 100644 Tests/Buildenator.IntegrationTests.SourceWithoutAssemblyInfo/EntityWithMandatoryConstructorParametersBuilder.cs create mode 100644 Tests/Buildenator.IntegrationTests/BuildersGeneratorMandatoryParametersTests.cs diff --git a/Buildenator.Abstraction/BuildenatorConfigurationAttribute.cs b/Buildenator.Abstraction/BuildenatorConfigurationAttribute.cs index 4504bbf..8fc1984 100644 --- a/Buildenator.Abstraction/BuildenatorConfigurationAttribute.cs +++ b/Buildenator.Abstraction/BuildenatorConfigurationAttribute.cs @@ -17,6 +17,7 @@ public sealed class BuildenatorConfigurationAttribute : Attribute /// If you want to generate static property that will return a new builder instance. /// If true, collection fields will be initialized with empty collections in the constructor instead of null. /// If true, generates additional With methods that accept Func<ChildBuilder, ChildBuilder> for properties that have their own builders. + /// If true, all generated builders in this assembly will require the entity's constructor parameters as mandatory builder constructor arguments. Incompatible with mocking. public BuildenatorConfigurationAttribute( string buildingMethodsPrefix = "With", bool generateDefaultBuildMethod = true, @@ -25,7 +26,8 @@ public BuildenatorConfigurationAttribute( bool implicitCast = false, bool generateStaticPropertyForBuilderCreation = false, bool initializeCollectionsWithEmpty = true, - bool useChildBuilders = true) + bool useChildBuilders = true, + bool builderConstructorMandatoryParameters = false) { } } \ No newline at end of file diff --git a/Buildenator.Abstraction/MakeBuilderAttribute.cs b/Buildenator.Abstraction/MakeBuilderAttribute.cs index a01e3fd..0a0ab31 100644 --- a/Buildenator.Abstraction/MakeBuilderAttribute.cs +++ b/Buildenator.Abstraction/MakeBuilderAttribute.cs @@ -19,6 +19,7 @@ public sealed class MakeBuilderAttribute : Attribute /// If you want to generate static property that will return a new builder instance. /// If true, collection fields will be initialized with empty collections in the constructor instead of null. /// If true, generates additional With methods that accept Func<ChildBuilder, ChildBuilder> for properties that have their own builders. + /// If true, the generated builder constructor will require the entity's constructor parameters as mandatory arguments instead of using a parameterless constructor. Incompatible with mocking. public MakeBuilderAttribute( Type typeForBuilder, string? buildingMethodsPrefix = "With", @@ -29,7 +30,8 @@ public MakeBuilderAttribute( string? staticFactoryMethodName = null, object? generateStaticPropertyForBuilderCreation = null, object? initializeCollectionsWithEmpty = null, - object? useChildBuilders = null + object? useChildBuilders = null, + object? builderConstructorMandatoryParameters = null ) { } diff --git a/Buildenator/AnalyzerReleases.Unshipped.md b/Buildenator/AnalyzerReleases.Unshipped.md index 2e0db60..a7f1728 100644 --- a/Buildenator/AnalyzerReleases.Unshipped.md +++ b/Buildenator/AnalyzerReleases.Unshipped.md @@ -9,4 +9,5 @@ BDN002 | Buildenator | Error | BuildersGenerator BDN003 | Buildenator | Info | BuildenatorDiagnosticDescriptors BDN004 | Buildenator | Info | BuildenatorDiagnosticDescriptors BDN005 | Buildenator | Info | BuildenatorDiagnosticDescriptors -BDN006 | Buildenator | Info | BuildenatorDiagnosticDescriptors \ No newline at end of file +BDN006 | Buildenator | Info | BuildenatorDiagnosticDescriptors +BDN007 | Buildenator | Error | BuildenatorDiagnosticDescriptors \ No newline at end of file diff --git a/Buildenator/Configuration/BuilderProperties.cs b/Buildenator/Configuration/BuilderProperties.cs index 6ff659d..82fc18f 100644 --- a/Buildenator/Configuration/BuilderProperties.cs +++ b/Buildenator/Configuration/BuilderProperties.cs @@ -31,6 +31,7 @@ public static BuilderProperties Create( bool? generateStaticPropertyForBuilderCreation = null; bool? initializeCollectionsWithEmpty = null; bool? useChildBuilders = null; + bool? builderConstructorMandatoryParameters = null; if (globalAttributes.HasValue) { @@ -42,6 +43,7 @@ public static BuilderProperties Create( generateStaticPropertyForBuilderCreation = globalAttributes.Value.GetOrThrow(5, nameof(MakeBuilderAttributeInternal.GenerateStaticPropertyForBuilderCreation)); initializeCollectionsWithEmpty = globalAttributes.Value.GetOrThrow(6, nameof(MakeBuilderAttributeInternal.InitializeCollectionsWithEmpty)); useChildBuilders = globalAttributes.Value.GetOrThrow(7, nameof(MakeBuilderAttributeInternal.UseChildBuilders)); + builderConstructorMandatoryParameters = globalAttributes.Value.GetOrThrow(8, nameof(MakeBuilderAttributeInternal.BuilderConstructorMandatoryParameters)); } nullableStrategy = builderAttribute.NullableStrategy is null ? nullableStrategy: builderAttribute.NullableStrategy; @@ -64,7 +66,8 @@ public static BuilderProperties Create( builderAttribute.StaticFactoryMethodName, builderAttribute.GenerateStaticPropertyForBuilderCreation ?? generateStaticPropertyForBuilderCreation, builderAttribute.InitializeCollectionsWithEmpty ?? initializeCollectionsWithEmpty, - builderAttribute.UseChildBuilders ?? useChildBuilders)); + builderAttribute.UseChildBuilders ?? useChildBuilders, + builderAttribute.BuilderConstructorMandatoryParameters ?? builderConstructorMandatoryParameters)); } private BuilderProperties(INamespaceOrTypeSymbol builderSymbol, MakeBuilderAttributeInternal attributeData) @@ -82,6 +85,7 @@ private BuilderProperties(INamespaceOrTypeSymbol builderSymbol, MakeBuilderAttri GenerateStaticPropertyForBuilderCreation = attributeData.GenerateStaticPropertyForBuilderCreation ?? false; InitializeCollectionsWithEmpty = attributeData.InitializeCollectionsWithEmpty ?? true; UseChildBuilders = attributeData.UseChildBuilders ?? true; + BuilderConstructorMandatoryParameters = attributeData.BuilderConstructorMandatoryParameters ?? false; if (string.IsNullOrWhiteSpace(BuildingMethodsPrefix)) throw new ArgumentNullException(nameof(attributeData), "Prefix name shouldn't be empty!"); @@ -182,6 +186,7 @@ private static bool IsAccessibleDefaultValueMember(bool isStatic, Accessibility public bool GenerateStaticPropertyForBuilderCreation { get; } public bool InitializeCollectionsWithEmpty { get; } public bool UseChildBuilders { get; } + public bool BuilderConstructorMandatoryParameters { get; } public IReadOnlyDictionary> BuildingMethods => _buildingMethods; public IReadOnlyDictionary Fields => _fields; diff --git a/Buildenator/Configuration/Contract/IBuilderProperties.cs b/Buildenator/Configuration/Contract/IBuilderProperties.cs index 727ae7a..4d1ac35 100644 --- a/Buildenator/Configuration/Contract/IBuilderProperties.cs +++ b/Buildenator/Configuration/Contract/IBuilderProperties.cs @@ -27,6 +27,7 @@ internal interface IBuilderProperties bool GenerateStaticPropertyForBuilderCreation { get; } bool InitializeCollectionsWithEmpty { get; } bool UseChildBuilders { get; } + bool BuilderConstructorMandatoryParameters { get; } /// /// Gets the set of user-defined default value names available in this builder. diff --git a/Buildenator/Diagnostics/BuildenatorDiagnosticDescriptors.cs b/Buildenator/Diagnostics/BuildenatorDiagnosticDescriptors.cs index 8abb374..c5a6047 100644 --- a/Buildenator/Diagnostics/BuildenatorDiagnosticDescriptors.cs +++ b/Buildenator/Diagnostics/BuildenatorDiagnosticDescriptors.cs @@ -52,4 +52,12 @@ internal static class BuildenatorDiagnosticDescriptors "Buildenator", DiagnosticSeverity.Info, true); + + internal static readonly DiagnosticDescriptor MandatoryParametersWithMockingDiagnostic = new( + "BDN007", + "BuilderConstructorMandatoryParameters is incompatible with mocking", + "The builder '{0}' has BuilderConstructorMandatoryParameters = true, which is incompatible with a mocking configuration. Remove the mocking configuration or disable BuilderConstructorMandatoryParameters.", + "Buildenator", + DiagnosticSeverity.Error, + true); } \ No newline at end of file diff --git a/Buildenator/Generators/BuilderSourceStringGenerator.cs b/Buildenator/Generators/BuilderSourceStringGenerator.cs index 740a6be..8b917de 100644 --- a/Buildenator/Generators/BuilderSourceStringGenerator.cs +++ b/Buildenator/Generators/BuilderSourceStringGenerator.cs @@ -34,6 +34,13 @@ public BuilderSourceStringGenerator( { _diagnostics.Add(privateConstructorDiagnostic); } + if (_builder.BuilderConstructorMandatoryParameters && mockingConfiguration != null) + { + _diagnostics.Add(new BuildenatorDiagnostic( + BuildenatorDiagnosticDescriptors.MandatoryParametersWithMockingDiagnostic, + _builder.OriginalLocation, + _builder.Name)); + } _diagnostics.AddRange(_builder.Diagnostics); _diagnostics.AddRange(_entity.Diagnostics); @@ -67,11 +74,11 @@ namespace {_builder.ContainingNamespace} {GenerateGlobalNullable()}{GenerateBuilderDefinition()} {{ {(_fixtureConfiguration is null ? string.Empty : $" private readonly {_fixtureConfiguration.Name} {DefaultConstants.FixtureLiteral} = new {_fixtureConfiguration.Name}({_fixtureConfiguration.ConstructorParameters});")} -{(_builder.IsDefaultConstructorOverriden ? string.Empty : GenerateConstructor(_builder.Name, _entity, _fixtureConfiguration, _builder.InitializeCollectionsWithEmpty))} +{(_builder.IsDefaultConstructorOverriden ? string.Empty : GenerateConstructor(_builder.Name, _entity, _fixtureConfiguration, _builder.InitializeCollectionsWithEmpty, _builder.BuilderConstructorMandatoryParameters))} {_propertiesStringGenerator.GeneratePropertiesCode()} {(_builder.IsBuildMethodOverriden ? string.Empty : _entity.GenerateBuildsCode(_builder.ShouldGenerateMethodsForUnreachableProperties))} {(_builder.IsBuildManyMethodOverriden ? string.Empty : GenerateBuildManyCode())} -{(_builder.GenerateStaticPropertyForBuilderCreation ? $" public static {_builder.FullName} {_entity.Name} => new {_builder.FullName}();" : "")} +{(_builder.GenerateStaticPropertyForBuilderCreation && !_builder.BuilderConstructorMandatoryParameters ? $" public static {_builder.FullName} {_entity.Name} => new {_builder.FullName}();" : "")} {(_builder.GenerateDefaultBuildMethod ? _entity.GenerateDefaultBuildsCode() : string.Empty)} {(_builder.ImplicitCast ? GenerateImplicitCastCode() : string.Empty)} {GeneratePreBuildMethod()} diff --git a/Buildenator/Generators/ConstructorsGenerator.cs b/Buildenator/Generators/ConstructorsGenerator.cs index 19c7dc5..5681af5 100644 --- a/Buildenator/Generators/ConstructorsGenerator.cs +++ b/Buildenator/Generators/ConstructorsGenerator.cs @@ -9,6 +9,65 @@ namespace Buildenator.Generators; internal static class ConstructorsGenerator { internal static string GenerateConstructor( + string builderName, + IEntityToBuild entity, + IFixtureProperties? fixtureConfiguration, + bool initializeCollectionsWithEmpty, + bool builderConstructorMandatoryParameters) + { + return builderConstructorMandatoryParameters + ? GenerateMandatoryParamConstructor(builderName, entity, initializeCollectionsWithEmpty) + : GenerateParameterlessConstructor(builderName, entity, fixtureConfiguration, initializeCollectionsWithEmpty); + } + + private static string GenerateMandatoryParamConstructor( + string builderName, + IEntityToBuild entity, + bool initializeCollectionsWithEmpty) + { + var constructorToBuild = entity.ConstructorToBuild; + var mandatoryParams = constructorToBuild?.Parameters.ToList() ?? []; + + var signatureParams = string.Join(", ", mandatoryParams.Select(p => $"{p.TypeFullName} {p.SymbolName}")); + + var output = new StringBuilder(); + output.AppendLine($@"{CommentsGenerator.GenerateSummaryOverrideComment()} + public {builderName}({signatureParams}) + {{"); + + foreach (var p in mandatoryParams) + { + output.AppendLine($@" {p.UnderScoreName} = new {DefaultConstants.NullBox}<{p.TypeFullName}>({p.SymbolName});"); + } + + var settableProperties = entity.AllUniqueSettablePropertiesAndParameters + .Where(p => constructorToBuild?.ContainsParameter(p.SymbolName) != true) + .ToList(); + + foreach (var typedSymbol in settableProperties.Where(a => a.NeedsFieldInit())) + { + output.AppendLine($@" {typedSymbol.GenerateFieldInitialization()}"); + } + + if (initializeCollectionsWithEmpty) + { + foreach (var typedSymbol in settableProperties.Where(ShouldInitializeCollectionField)) + { + var collectionMetadata = typedSymbol.GetCollectionMetadata(); + if (collectionMetadata != null) + { + var initCode = GenerateEmptyCollectionInitialization(typedSymbol, collectionMetadata); + if (!string.IsNullOrEmpty(initCode)) + output.AppendLine($@" {initCode}"); + } + } + } + + output.AppendLine($@" }}"); + return output.ToString(); + } + + private static string GenerateParameterlessConstructor( string builderName, IEntityToBuild entity, IFixtureProperties? fixtureConfiguration, diff --git a/Buildenator/MakeBuilderAttributeInternal.cs b/Buildenator/MakeBuilderAttributeInternal.cs index 5622a80..39900c3 100644 --- a/Buildenator/MakeBuilderAttributeInternal.cs +++ b/Buildenator/MakeBuilderAttributeInternal.cs @@ -13,7 +13,8 @@ internal readonly struct MakeBuilderAttributeInternal( string? staticFactoryMethodName, bool? generateStaticPropertyForBuilderCreation, bool? initializeCollectionsWithEmpty, - bool? useChildBuilders) + bool? useChildBuilders, + bool? builderConstructorMandatoryParameters) { public MakeBuilderAttributeInternal(AttributeData attribute) @@ -29,7 +30,8 @@ attribute.ConstructorArguments[3].Value is null (string?)attribute.ConstructorArguments[6].Value, (bool?)attribute.ConstructorArguments[7].Value, (bool?)attribute.ConstructorArguments[8].Value, - (bool?)attribute.ConstructorArguments[9].Value) + (bool?)attribute.ConstructorArguments[9].Value, + (bool?)attribute.ConstructorArguments[10].Value) { } @@ -43,5 +45,6 @@ attribute.ConstructorArguments[3].Value is null public bool? GenerateStaticPropertyForBuilderCreation { get; } = generateStaticPropertyForBuilderCreation; public bool? InitializeCollectionsWithEmpty { get; } = initializeCollectionsWithEmpty; public bool? UseChildBuilders { get; } = useChildBuilders; + public bool? BuilderConstructorMandatoryParameters { get; } = builderConstructorMandatoryParameters; internal string? StaticFactoryMethodName { get; } = staticFactoryMethodName; } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e825538..4755c04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,37 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 8.8.0.0 - 2026-3-6 + +### Added +- **`builderConstructorMandatoryParameters` option**: New configuration option that makes the generated builder enforce mandatory parameters by requiring the entity's constructor parameters as arguments to the builder's own constructor. + - Enabled per builder: `[MakeBuilder(typeof(MyClass), builderConstructorMandatoryParameters: true)]` + - Enabled globally: `[assembly: BuildenatorConfiguration(builderConstructorMandatoryParameters: true)]` + - When enabled: + - The builder gets a **parameterized constructor** mirroring the entity's primary constructor instead of a parameterless one. + - The **static factory property** (`generateStaticPropertyForBuilderCreation`) is automatically suppressed, since it would call a parameterless constructor that no longer exists. + - All `With...` methods remain available and can still override the values passed to the constructor. + - **Mocking is incompatible** — combining this option with a mocking configuration (e.g. `MoqConfiguration`) produces a compile-time error **BDN007**. + - Example: + ```csharp + public class Dto + { + public Dto(int lineNumber, string activity) { LineNumber = lineNumber; Activity = activity; } + public int LineNumber { get; set; } + public string Activity { get; set; } + public List Tags { get; set; } = []; + } + + [MakeBuilder(typeof(Dto), builderConstructorMandatoryParameters: true)] + public partial class DtoBuilder { } + + // Usage — constructor enforces required values, With methods allow overrides + var dto = new DtoBuilder(lineNumber: 1, activity: "shipping") + .AddToTags("express") + .Build(); + ``` +- **BDN007 diagnostic error**: Emitted when `builderConstructorMandatoryParameters = true` is combined with a mocking configuration, since the two features are fundamentally incompatible. + ## 8.7.1.0 - 2025-12-4 ### Fixed diff --git a/README.md b/README.md index cedc936..a13c7d5 100644 --- a/README.md +++ b/README.md @@ -301,7 +301,8 @@ using Buildenator.Abstraction.Moq; implicitCast: false, generateStaticPropertyForBuilderCreation: true, initializeCollectionsWithEmpty: true, // Default: true - collections are initialized with empty instead of null - useChildBuilders: true // Default: true - generates Func methods + useChildBuilders: true, // Default: true - generates Func methods + builderConstructorMandatoryParameters: false // Default: false - require entity constructor params in builder constructor )] // Optional: AutoFixture configuration @@ -328,7 +329,8 @@ Override global settings for specific builders: staticFactoryMethodName: nameof(User.CreateUser), // Use static factory method generateStaticPropertyForBuilderCreation: true, // Generate static User property initializeCollectionsWithEmpty: true, // Default: true - Initialize collections with empty instead of null - useChildBuilders: true // Default: true - Generate Func methods + useChildBuilders: true, // Default: true - Generate Func methods + builderConstructorMandatoryParameters: false // Default: false - Require entity constructor params in builder constructor )] public partial class UserBuilder { } ``` @@ -547,10 +549,67 @@ var parent2 = ParentBuilder.Parent .Build(); ``` +#### `builderConstructorMandatoryParameters` (default: `false`) +When `true`, the generated builder constructor takes the entity's primary constructor parameters as required arguments instead of using a parameterless constructor. This communicates intent and prevents constructing invalid objects in tests. + +- The static factory property is automatically suppressed (it would need a parameterless constructor). +- All `With...` methods are still generated and can override the values. +- **Incompatible with mocking** — causes compiler error **BDN007** if combined with a mocking configuration. + +See the [Mandatory Constructor Parameters](#mandatory-constructor-parameters) section in Advanced Usage for a full example. + --- ## Advanced Usage +### Mandatory Constructor Parameters + +Enforce required initialization values through the builder's own constructor by setting `builderConstructorMandatoryParameters: true`. This is especially useful for DTOs or value objects where constructing an instance without specific required values is either invalid or meaningless. + +```csharp +public class Dto +{ + public Dto(int lineNumber, string activity) + { + LineNumber = lineNumber; + Activity = activity; + } + + public int LineNumber { get; set; } + public string Activity { get; set; } + public List Tags { get; set; } = []; +} + +[MakeBuilder(typeof(Dto), builderConstructorMandatoryParameters: true)] +public partial class DtoBuilder { } +``` + +The generated builder constructor mirrors the entity constructor: + +```csharp +// Generated: +// public DtoBuilder(int lineNumber, string activity) { ... } + +// Usage — required parameters are enforced at the call site +var dto = new DtoBuilder(lineNumber: 1, activity: "shipping") + .WithLineNumber(99) // With methods still work and override constructor values + .AddToTags("express") + .Build(); +``` + +**Behaviour:** +- The builder gets a **parameterized constructor** matching the entity's primary constructor (the one with the most parameters). +- All `With...` and `AddTo...` methods are generated as usual and override the constructor values. +- The **static factory property** (`generateStaticPropertyForBuilderCreation`) is automatically suppressed, since it would require a parameterless constructor that no longer exists. +- **Mocking is incompatible** — combining this option with a mocking configuration produces compiler error **BDN007**. + +**Global configuration:** + +```csharp +[assembly: BuildenatorConfiguration(builderConstructorMandatoryParameters: true)] +``` + + ### Working with Collections Buildenator provides powerful collection handling with both `With` and `AddTo` methods. diff --git a/Tests/Buildenator.IntegrationTests.SharedEntities/EntityWithMandatoryConstructorParameters.cs b/Tests/Buildenator.IntegrationTests.SharedEntities/EntityWithMandatoryConstructorParameters.cs new file mode 100644 index 0000000..914eb7c --- /dev/null +++ b/Tests/Buildenator.IntegrationTests.SharedEntities/EntityWithMandatoryConstructorParameters.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; + +namespace Buildenator.IntegrationTests.SharedEntities; + +public class EntityWithMandatoryConstructorParameters +{ + public EntityWithMandatoryConstructorParameters(int lineNumber, string activity) + { + LineNumber = lineNumber; + Activity = activity; + } + + public int LineNumber { get; set; } + public string Activity { get; set; } + public List Tags { get; set; } = new List(); +} diff --git a/Tests/Buildenator.IntegrationTests.SourceWithoutAssemblyInfo/EntityWithMandatoryConstructorParametersBuilder.cs b/Tests/Buildenator.IntegrationTests.SourceWithoutAssemblyInfo/EntityWithMandatoryConstructorParametersBuilder.cs new file mode 100644 index 0000000..2fa665e --- /dev/null +++ b/Tests/Buildenator.IntegrationTests.SourceWithoutAssemblyInfo/EntityWithMandatoryConstructorParametersBuilder.cs @@ -0,0 +1,10 @@ +using Buildenator.Abstraction; +using Buildenator.IntegrationTests.SharedEntities; + +namespace Buildenator.IntegrationTests.SourceWithoutAssemblyInfo +{ + [MakeBuilder(typeof(EntityWithMandatoryConstructorParameters), builderConstructorMandatoryParameters: true)] + public partial class EntityWithMandatoryConstructorParametersBuilder + { + } +} diff --git a/Tests/Buildenator.IntegrationTests/Buildenator.IntegrationTests.csproj b/Tests/Buildenator.IntegrationTests/Buildenator.IntegrationTests.csproj index 9b58edb..801610c 100644 --- a/Tests/Buildenator.IntegrationTests/Buildenator.IntegrationTests.csproj +++ b/Tests/Buildenator.IntegrationTests/Buildenator.IntegrationTests.csproj @@ -26,6 +26,7 @@ + diff --git a/Tests/Buildenator.IntegrationTests/BuildersGeneratorMandatoryParametersTests.cs b/Tests/Buildenator.IntegrationTests/BuildersGeneratorMandatoryParametersTests.cs new file mode 100644 index 0000000..e7ef298 --- /dev/null +++ b/Tests/Buildenator.IntegrationTests/BuildersGeneratorMandatoryParametersTests.cs @@ -0,0 +1,53 @@ +using Buildenator.IntegrationTests.SharedEntities; +using Buildenator.IntegrationTests.SourceWithoutAssemblyInfo; +using FluentAssertions; +using Xunit; + +namespace Buildenator.IntegrationTests; + +public class BuildersGeneratorMandatoryParametersTests +{ + [Fact] + public void BuildersGenerator_MandatoryParameters_BuilderRequiresConstructorArguments() + { + var result = new EntityWithMandatoryConstructorParametersBuilder(42, "shipping") + .Build(); + + result.LineNumber.Should().Be(42); + result.Activity.Should().Be("shipping"); + } + + [Fact] + public void BuildersGenerator_MandatoryParameters_WithMethodsOverrideConstructorValues() + { + var result = new EntityWithMandatoryConstructorParametersBuilder(1, "initial") + .WithLineNumber(99) + .WithActivity("updated") + .Build(); + + result.LineNumber.Should().Be(99); + result.Activity.Should().Be("updated"); + } + + [Fact] + public void BuildersGenerator_MandatoryParameters_CollectionPropertiesStillWork() + { + var result = new EntityWithMandatoryConstructorParametersBuilder(1, "test") + .AddToTags("tag1", "tag2") + .Build(); + + result.Tags.Should().BeEquivalentTo(new[] { "tag1", "tag2" }); + } + + [Fact] + public void BuildersGenerator_MandatoryParameters_NoStaticFactoryPropertyGenerated() + { + var builderType = typeof(EntityWithMandatoryConstructorParametersBuilder); + + var staticProperty = builderType.GetProperty( + nameof(EntityWithMandatoryConstructorParameters), + System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public); + + staticProperty.Should().BeNull("static factory property is incompatible with mandatory constructor parameters"); + } +} From d36abe3825bedf086e80775feab9d11f73305394 Mon Sep 17 00:00:00 2001 From: Przemek Rogala Date: Fri, 6 Mar 2026 23:04:54 +0100 Subject: [PATCH 2/4] chore: update project version to 8.8.0.0 --- Buildenator/Buildenator.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Buildenator/Buildenator.csproj b/Buildenator/Buildenator.csproj index 40e987d..6bd7832 100644 --- a/Buildenator/Buildenator.csproj +++ b/Buildenator/Buildenator.csproj @@ -9,7 +9,7 @@ false true - 8.5.0.2 + 8.8.0.0 From 8327332c8a3bb6c0d5ecd443bd78e9e299c76189 Mon Sep 17 00:00:00 2001 From: Przemek Rogala Date: Fri, 6 Mar 2026 23:35:59 +0100 Subject: [PATCH 3/4] feat: add diagnostic for fixture configuration incompatibility with mandatory constructor parameters fix: update changelog and README for new diagnostic errors BDN007 and BDN008 refactor: modify EntityWithMandatoryConstructorParameters to include tags and update related tests --- Buildenator/AnalyzerReleases.Unshipped.md | 3 ++- .../Diagnostics/BuildenatorDiagnosticDescriptors.cs | 8 ++++++++ Buildenator/Generators/BuilderSourceStringGenerator.cs | 7 +++++++ Buildenator/Generators/ConstructorsGenerator.cs | 7 +------ CHANGELOG.md | 5 +++++ README.md | 2 ++ .../EntityWithMandatoryConstructorParameters.cs | 5 +++-- .../EntityWithMandatoryConstructorParametersBuilder.cs | 5 ++++- .../BuildersGeneratorMandatoryParametersTests.cs | 7 ++++--- 9 files changed, 36 insertions(+), 13 deletions(-) diff --git a/Buildenator/AnalyzerReleases.Unshipped.md b/Buildenator/AnalyzerReleases.Unshipped.md index a7f1728..03696d6 100644 --- a/Buildenator/AnalyzerReleases.Unshipped.md +++ b/Buildenator/AnalyzerReleases.Unshipped.md @@ -10,4 +10,5 @@ BDN003 | Buildenator | Info | BuildenatorDiagnosticDescriptors BDN004 | Buildenator | Info | BuildenatorDiagnosticDescriptors BDN005 | Buildenator | Info | BuildenatorDiagnosticDescriptors BDN006 | Buildenator | Info | BuildenatorDiagnosticDescriptors -BDN007 | Buildenator | Error | BuildenatorDiagnosticDescriptors \ No newline at end of file +BDN007 | Buildenator | Error | BuildenatorDiagnosticDescriptors +BDN008 | Buildenator | Error | BuildenatorDiagnosticDescriptors \ No newline at end of file diff --git a/Buildenator/Diagnostics/BuildenatorDiagnosticDescriptors.cs b/Buildenator/Diagnostics/BuildenatorDiagnosticDescriptors.cs index c5a6047..c74c1da 100644 --- a/Buildenator/Diagnostics/BuildenatorDiagnosticDescriptors.cs +++ b/Buildenator/Diagnostics/BuildenatorDiagnosticDescriptors.cs @@ -60,4 +60,12 @@ internal static class BuildenatorDiagnosticDescriptors "Buildenator", DiagnosticSeverity.Error, true); + + internal static readonly DiagnosticDescriptor MandatoryParametersWithFixtureDiagnostic = new( + "BDN008", + "BuilderConstructorMandatoryParameters is incompatible with fixture configuration", + "The builder '{0}' has BuilderConstructorMandatoryParameters = true, which is incompatible with a fixture configuration. Remove the fixture configuration or disable BuilderConstructorMandatoryParameters.", + "Buildenator", + DiagnosticSeverity.Error, + true); } \ No newline at end of file diff --git a/Buildenator/Generators/BuilderSourceStringGenerator.cs b/Buildenator/Generators/BuilderSourceStringGenerator.cs index 8b917de..2f6b456 100644 --- a/Buildenator/Generators/BuilderSourceStringGenerator.cs +++ b/Buildenator/Generators/BuilderSourceStringGenerator.cs @@ -41,6 +41,13 @@ public BuilderSourceStringGenerator( _builder.OriginalLocation, _builder.Name)); } + if (_builder.BuilderConstructorMandatoryParameters && fixtureConfiguration != null) + { + _diagnostics.Add(new BuildenatorDiagnostic( + BuildenatorDiagnosticDescriptors.MandatoryParametersWithFixtureDiagnostic, + _builder.OriginalLocation, + _builder.Name)); + } _diagnostics.AddRange(_builder.Diagnostics); _diagnostics.AddRange(_entity.Diagnostics); diff --git a/Buildenator/Generators/ConstructorsGenerator.cs b/Buildenator/Generators/ConstructorsGenerator.cs index 5681af5..611f502 100644 --- a/Buildenator/Generators/ConstructorsGenerator.cs +++ b/Buildenator/Generators/ConstructorsGenerator.cs @@ -41,14 +41,9 @@ private static string GenerateMandatoryParamConstructor( } var settableProperties = entity.AllUniqueSettablePropertiesAndParameters - .Where(p => constructorToBuild?.ContainsParameter(p.SymbolName) != true) + .Where(p => constructorToBuild?.ContainsParameter(p.SymbolPascalName) != true) .ToList(); - foreach (var typedSymbol in settableProperties.Where(a => a.NeedsFieldInit())) - { - output.AppendLine($@" {typedSymbol.GenerateFieldInitialization()}"); - } - if (initializeCollectionsWithEmpty) { foreach (var typedSymbol in settableProperties.Where(ShouldInitializeCollectionField)) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4755c04..c9b43ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The **static factory property** (`generateStaticPropertyForBuilderCreation`) is automatically suppressed, since it would call a parameterless constructor that no longer exists. - All `With...` methods remain available and can still override the values passed to the constructor. - **Mocking is incompatible** — combining this option with a mocking configuration (e.g. `MoqConfiguration`) produces a compile-time error **BDN007**. + - **Fixture configuration is incompatible** — combining this option with a fixture configuration (e.g. `AutoFixtureConfiguration`) produces a compile-time error **BDN008**. - Example: ```csharp public class Dto @@ -34,6 +35,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 .Build(); ``` - **BDN007 diagnostic error**: Emitted when `builderConstructorMandatoryParameters = true` is combined with a mocking configuration, since the two features are fundamentally incompatible. +- **BDN008 diagnostic error**: Emitted when `builderConstructorMandatoryParameters = true` is combined with a fixture configuration, since fixture auto-generation of unset values is incompatible with explicit constructor-driven initialization. + +### Fixed +- **`builderConstructorMandatoryParameters` field re-initialization bug**: Constructor parameter fields were incorrectly included in the `initializeCollectionsWithEmpty` block inside the generated mandatory constructor, potentially overwriting the value provided by the caller. The generator now correctly excludes constructor parameters (matched by their PascalCase name) from subsequent field initialization. ## 8.7.1.0 - 2025-12-4 diff --git a/README.md b/README.md index a13c7d5..e1c68d2 100644 --- a/README.md +++ b/README.md @@ -555,6 +555,7 @@ When `true`, the generated builder constructor takes the entity's primary constr - The static factory property is automatically suppressed (it would need a parameterless constructor). - All `With...` methods are still generated and can override the values. - **Incompatible with mocking** — causes compiler error **BDN007** if combined with a mocking configuration. +- **Incompatible with fixture configuration** — causes compiler error **BDN008** if combined with an AutoFixture configuration. See the [Mandatory Constructor Parameters](#mandatory-constructor-parameters) section in Advanced Usage for a full example. @@ -602,6 +603,7 @@ var dto = new DtoBuilder(lineNumber: 1, activity: "shipping") - All `With...` and `AddTo...` methods are generated as usual and override the constructor values. - The **static factory property** (`generateStaticPropertyForBuilderCreation`) is automatically suppressed, since it would require a parameterless constructor that no longer exists. - **Mocking is incompatible** — combining this option with a mocking configuration produces compiler error **BDN007**. +- **Fixture configuration is incompatible** — combining this option with an AutoFixture configuration produces compiler error **BDN008**. **Global configuration:** diff --git a/Tests/Buildenator.IntegrationTests.SharedEntities/EntityWithMandatoryConstructorParameters.cs b/Tests/Buildenator.IntegrationTests.SharedEntities/EntityWithMandatoryConstructorParameters.cs index 914eb7c..a6ec526 100644 --- a/Tests/Buildenator.IntegrationTests.SharedEntities/EntityWithMandatoryConstructorParameters.cs +++ b/Tests/Buildenator.IntegrationTests.SharedEntities/EntityWithMandatoryConstructorParameters.cs @@ -4,13 +4,14 @@ namespace Buildenator.IntegrationTests.SharedEntities; public class EntityWithMandatoryConstructorParameters { - public EntityWithMandatoryConstructorParameters(int lineNumber, string activity) + public EntityWithMandatoryConstructorParameters(int lineNumber, string activity, List tags) { LineNumber = lineNumber; Activity = activity; + Tags = tags; } public int LineNumber { get; set; } public string Activity { get; set; } - public List Tags { get; set; } = new List(); + public List Tags { get; set; } } diff --git a/Tests/Buildenator.IntegrationTests.SourceWithoutAssemblyInfo/EntityWithMandatoryConstructorParametersBuilder.cs b/Tests/Buildenator.IntegrationTests.SourceWithoutAssemblyInfo/EntityWithMandatoryConstructorParametersBuilder.cs index 2fa665e..e0b257b 100644 --- a/Tests/Buildenator.IntegrationTests.SourceWithoutAssemblyInfo/EntityWithMandatoryConstructorParametersBuilder.cs +++ b/Tests/Buildenator.IntegrationTests.SourceWithoutAssemblyInfo/EntityWithMandatoryConstructorParametersBuilder.cs @@ -3,7 +3,10 @@ namespace Buildenator.IntegrationTests.SourceWithoutAssemblyInfo { - [MakeBuilder(typeof(EntityWithMandatoryConstructorParameters), builderConstructorMandatoryParameters: true)] + [MakeBuilder( + typeof(EntityWithMandatoryConstructorParameters), + builderConstructorMandatoryParameters: true, + initializeCollectionsWithEmpty: true)] public partial class EntityWithMandatoryConstructorParametersBuilder { } diff --git a/Tests/Buildenator.IntegrationTests/BuildersGeneratorMandatoryParametersTests.cs b/Tests/Buildenator.IntegrationTests/BuildersGeneratorMandatoryParametersTests.cs index e7ef298..56a768f 100644 --- a/Tests/Buildenator.IntegrationTests/BuildersGeneratorMandatoryParametersTests.cs +++ b/Tests/Buildenator.IntegrationTests/BuildersGeneratorMandatoryParametersTests.cs @@ -10,17 +10,18 @@ public class BuildersGeneratorMandatoryParametersTests [Fact] public void BuildersGenerator_MandatoryParameters_BuilderRequiresConstructorArguments() { - var result = new EntityWithMandatoryConstructorParametersBuilder(42, "shipping") + var result = new EntityWithMandatoryConstructorParametersBuilder(42, "shipping", ["tag1", "tag2"]) .Build(); result.LineNumber.Should().Be(42); result.Activity.Should().Be("shipping"); + result.Tags.Should().BeEquivalentTo(["tag1", "tag2"]); } [Fact] public void BuildersGenerator_MandatoryParameters_WithMethodsOverrideConstructorValues() { - var result = new EntityWithMandatoryConstructorParametersBuilder(1, "initial") + var result = new EntityWithMandatoryConstructorParametersBuilder(1, "initial", null) .WithLineNumber(99) .WithActivity("updated") .Build(); @@ -32,7 +33,7 @@ public void BuildersGenerator_MandatoryParameters_WithMethodsOverrideConstructor [Fact] public void BuildersGenerator_MandatoryParameters_CollectionPropertiesStillWork() { - var result = new EntityWithMandatoryConstructorParametersBuilder(1, "test") + var result = new EntityWithMandatoryConstructorParametersBuilder(1, "test", null) .AddToTags("tag1", "tag2") .Build(); From 7571c30a3bc30ded993b212a252689f663289d00 Mon Sep 17 00:00:00 2001 From: Przemek Rogala Date: Fri, 6 Mar 2026 23:38:23 +0100 Subject: [PATCH 4/4] fix: remove incorrect field re-initialization for builderConstructorMandatoryParameters in generated constructors --- CHANGELOG.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9b43ae..5469e8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,9 +37,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BDN007 diagnostic error**: Emitted when `builderConstructorMandatoryParameters = true` is combined with a mocking configuration, since the two features are fundamentally incompatible. - **BDN008 diagnostic error**: Emitted when `builderConstructorMandatoryParameters = true` is combined with a fixture configuration, since fixture auto-generation of unset values is incompatible with explicit constructor-driven initialization. -### Fixed -- **`builderConstructorMandatoryParameters` field re-initialization bug**: Constructor parameter fields were incorrectly included in the `initializeCollectionsWithEmpty` block inside the generated mandatory constructor, potentially overwriting the value provided by the caller. The generator now correctly excludes constructor parameters (matched by their PascalCase name) from subsequent field initialization. - ## 8.7.1.0 - 2025-12-4 ### Fixed