From c9ac2dfd18164cb6c3969a6e4c0c8fa314cee482 Mon Sep 17 00:00:00 2001 From: Taras Kovalenko Date: Sun, 26 Jul 2026 11:33:55 +0300 Subject: [PATCH 1/4] feat: write constants to another project via EntityLengthsOutputPath A source generator can only add code to the project it runs in, which does not work for Clean Architecture layouts where EF configurations live in Infrastructure but the constants belong in Domain. The generator now also writes the constants to a file in another project, which that project compiles as normal checked-in code. Configured with MSBuild properties, made compiler visible by a targets file shipped in the package: EntityLengthsOutputPath, EntityLengthsOutputNamespace and EntityLengthsEmitInProjectOutput. In-project output is turned off as soon as an output path is set, otherwise the same type would exist in two assemblies. The file is only touched when its content changes, and write failures are reported as ELG0001 instead of throwing out of the generator. Also fixes issues found while reviewing the pipeline: - Options were resolved inside the syntax transform from the Compilation, which is not equatable, so nothing downstream could be cached. Options are now their own equatable pipeline stage and namespace filtering moved to the emit stage. - The pipeline model carried ITypeSymbol, which roots the Compilation and has no value equality. It now carries strings and an EquatableArray. - A DbContext configuring several entities only produced constants for the first one; extractors now return every entity they find. - AttributeExtractor could add the same property twice when both a column type and a StringLength attribute were present. - Entities are no longer silently merged in the dark: colliding simple names report ELG0003 and conflicting lengths report ELG0004. - Records were skipped entirely, and every class in the project went through the semantic model; the predicate now matches classes and records that have a base list or attributed members. - The generator held an options provider in an instance field. --- README.md | 48 ++++ .../AnalyzerReleases.Unshipped.md | 11 + .../EntityLengthsOptionsProvider.cs | 106 +++++++- .../Configuration/MsBuildPropertyNames.cs | 25 ++ .../Core/EquatableArray.cs | 54 ++++ .../Core/IPropertyLengthExtractor.cs | 8 +- .../Core/IsExternalInit.cs | 11 + .../Core/NamespaceFilter.cs | 30 +-- .../Diagnostics/DiagnosticDescriptors.cs | 57 ++++ .../Emit/ConstantsSourceBuilder.cs | 90 +++++++ .../Emit/GeneratedFileWriter.cs | 78 ++++++ .../EntityLengths.Generator.csproj | 10 +- .../EntityMaxLengthGenerator.cs | 245 ++++++++++-------- .../Extensions/CompilationExtensions.cs | 8 +- .../Extensions/SymbolExtensions.cs | 8 + .../Extractors/AttributeExtractor.cs | 21 +- .../DbContextConfigurationExtractor.cs | 39 ++- .../Extractors/EntityTypeInfoFactory.cs | 72 +++++ .../FluentConfigurationExtractor.cs | 23 +- .../Models/EntityTypeInfo.cs | 27 +- .../Options/EntityLengthsGeneratorOptions.cs | 67 ++++- .../Options/EntityLengthsScanningOptions.cs | 35 ++- .../build/EntityLengths.Generator.targets | 18 ++ src/EntityLengths.Generator/docs/README.md | 50 +++- .../GeneratorBehaviorTests.cs | 201 ++++++++++++++ .../IncrementalCachingTests.cs | 69 +++++ .../Infrastructure/TestCompilation.cs | 100 +++++++ .../OutputPathTests.cs | 230 ++++++++++++++++ 28 files changed, 1566 insertions(+), 175 deletions(-) create mode 100644 src/EntityLengths.Generator/AnalyzerReleases.Unshipped.md create mode 100644 src/EntityLengths.Generator/Configuration/MsBuildPropertyNames.cs create mode 100644 src/EntityLengths.Generator/Core/EquatableArray.cs create mode 100644 src/EntityLengths.Generator/Core/IsExternalInit.cs create mode 100644 src/EntityLengths.Generator/Diagnostics/DiagnosticDescriptors.cs create mode 100644 src/EntityLengths.Generator/Emit/ConstantsSourceBuilder.cs create mode 100644 src/EntityLengths.Generator/Emit/GeneratedFileWriter.cs create mode 100644 src/EntityLengths.Generator/Extractors/EntityTypeInfoFactory.cs create mode 100644 src/EntityLengths.Generator/build/EntityLengths.Generator.targets create mode 100644 tests/EntityLengths.Generator.Tests/GeneratorBehaviorTests.cs create mode 100644 tests/EntityLengths.Generator.Tests/IncrementalCachingTests.cs create mode 100644 tests/EntityLengths.Generator.Tests/Infrastructure/TestCompilation.cs create mode 100644 tests/EntityLengths.Generator.Tests/OutputPathTests.cs diff --git a/README.md b/README.md index 0288393..fd274f8 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,9 @@ To learn more about the war and how you can help, [click here](https://war.ukrai - `[Column(TypeName = "nvarchar(200)")]` - `[Column(TypeName = "char(200)")]` - DbContext configurations (`OnModelCreating`) +- Works on `class` and `record` entities +- Can write the constants into another project, for Clean Architecture and similar layouts + (see [Writing the constants into another project](#writing-the-constants-into-another-project-clean-architecture)) ## Installation @@ -150,6 +153,51 @@ There are ways to configure EntityLengths.Generator. Configuration values are ne - `ScanEntitySuffix` - The suffix for the entity classes to scan. Default is `null`. - `Namespace` - The namespace for the generated class. Default is `null`. +### Writing the constants into another project (Clean Architecture) + +A source generator can only add code to the project it runs in. When the EF configurations live in +`Infrastructure` but the constants belong in `Domain` (which must not reference `Infrastructure`), +the generator can additionally write the constants to a file in that other project. The file is a +normal source file: commit it, and the other project compiles it like any other code. + +Configure it with MSBuild properties in the project that contains the EF configurations: + +```xml + + + + ../MyApp.Domain/Generated/EntityLengths.cs + + MyApp.Domain + +``` + +- `EntityLengthsOutputPath` - File the constants are written to. Relative paths resolve against the + project directory. Default is empty, meaning nothing is written to disk. +- `EntityLengthsOutputNamespace` - Namespace used in the written file. Falls back to the + `Namespace` attribute value and then to the assembly name (reported as `ELG0002`). +- `EntityLengthsEmitInProjectOutput` - Whether the constants are also compiled into the current + project. Default is `true`, and `false` as soon as `EntityLengthsOutputPath` is set, because + otherwise the same type would exist in both assemblies. Set it to `true` explicitly if the two + namespaces differ and you want both. + +Notes: + +- The file is written during compilation, so `Domain` picks up changes on the **next** build. Commit + the generated file and treat it as checked-in generated code. +- The file is only touched when its content actually changes, so editing in an IDE does not churn it. +- Fluent API and `OnModelCreating` lengths are read from source, so the generator must run in the + project that contains those configurations - it cannot read them from a referenced assembly. + +### Diagnostics + +| ID | Severity | Meaning | +|----|----------|---------| +| `ELG0001` | Warning | The constants file could not be written to `EntityLengthsOutputPath` | +| `ELG0002` | Warning | `EntityLengthsOutputPath` is set without a namespace, so the assembly name is used | +| `ELG0003` | Warning | Two entity types share a simple name, so their constants are merged into one nested class | +| `ELG0004` | Warning | A property has conflicting configured lengths; the first one found is used | + Generated output: ```csharp diff --git a/src/EntityLengths.Generator/AnalyzerReleases.Unshipped.md b/src/EntityLengths.Generator/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..230f640 --- /dev/null +++ b/src/EntityLengths.Generator/AnalyzerReleases.Unshipped.md @@ -0,0 +1,11 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +ELG0001 | EntityLengths | Warning | Could not write the generated constants file +ELG0002 | EntityLengths | Warning | No namespace configured for the generated constants file +ELG0003 | EntityLengths | Warning | Entity types share a simple name +ELG0004 | EntityLengths | Warning | Conflicting lengths for a property diff --git a/src/EntityLengths.Generator/Configuration/EntityLengthsOptionsProvider.cs b/src/EntityLengths.Generator/Configuration/EntityLengthsOptionsProvider.cs index b0cc5b0..3e8bb7e 100644 --- a/src/EntityLengths.Generator/Configuration/EntityLengthsOptionsProvider.cs +++ b/src/EntityLengths.Generator/Configuration/EntityLengthsOptionsProvider.cs @@ -1,8 +1,10 @@ -using System; +using System; using System.Collections.Immutable; +using System.IO; using System.Linq; using EntityLengths.Generator.Options; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; namespace EntityLengths.Generator.Configuration; @@ -11,7 +13,17 @@ internal sealed class EntityLengthsOptionsProvider(EntityLengthsGeneratorOptions private readonly EntityLengthsGeneratorOptions _defaultOptions = options ?? EntityLengthsGeneratorOptions.Default; - public EntityLengthsGeneratorOptions GetOptions(Compilation compilation) + public EntityLengthsGeneratorOptions GetOptions( + Compilation compilation, + AnalyzerConfigOptions? globalOptions = null + ) + { + var result = GetAttributeOptions(compilation); + ApplyMsBuildOptions(result, globalOptions); + return result; + } + + private EntityLengthsGeneratorOptions GetAttributeOptions(Compilation compilation) { var assemblyAttributes = compilation.Assembly.GetAttributes(); var configAttribute = assemblyAttributes.FirstOrDefault(attr => @@ -24,7 +36,7 @@ public EntityLengthsGeneratorOptions GetOptions(Compilation compilation) if (configAttribute == null) { - return _defaultOptions; + return Clone(_defaultOptions); } // Get attribute values @@ -70,13 +82,16 @@ public EntityLengthsGeneratorOptions GetOptions(Compilation compilation) LengthSuffix = lengthSuffix ?? _defaultOptions.LengthSuffix, GenerateDocumentation = generateDocs, Namespace = ns, + OutputPath = _defaultOptions.OutputPath, + OutputNamespace = _defaultOptions.OutputNamespace, + EmitInProjectOutput = _defaultOptions.EmitInProjectOutput, ScanningOptions = new EntityLengthsScanningOptions { IncludeNamespaces = includeNs.IsDefault - ? _defaultOptions.ScanningOptions.IncludeNamespaces + ? [.. _defaultOptions.ScanningOptions.IncludeNamespaces] : [.. includeNs], ExcludeNamespaces = excludeNs.IsDefault - ? _defaultOptions.ScanningOptions.ExcludeNamespaces + ? [.. _defaultOptions.ScanningOptions.ExcludeNamespaces] : [.. excludeNs], ScanNestedNamespaces = scanNested, EntitySuffix = entitySuffix ?? _defaultOptions.ScanningOptions.EntitySuffix, @@ -84,6 +99,87 @@ public EntityLengthsGeneratorOptions GetOptions(Compilation compilation) }; } + /// + /// Overlays the MSBuild properties on top of the assembly attribute values. MSBuild wins because + /// the output location is a build concern, not a source concern. + /// + private static void ApplyMsBuildOptions( + EntityLengthsGeneratorOptions target, + AnalyzerConfigOptions? globalOptions + ) + { + if (globalOptions is null) + { + return; + } + + var outputPath = + GetProperty(globalOptions, MsBuildPropertyNames.ResolvedOutputPath) + ?? Resolve( + GetProperty(globalOptions, MsBuildPropertyNames.OutputPath), + GetProperty(globalOptions, MsBuildPropertyNames.ProjectDir) + ); + + if (outputPath is not null) + { + target.OutputPath = outputPath; + // Writing to another project means that project compiles the constants, so emitting them + // here as well would produce the same type in two assemblies. + target.EmitInProjectOutput = false; + } + + var outputNamespace = GetProperty(globalOptions, MsBuildPropertyNames.OutputNamespace); + if (outputNamespace is not null) + { + target.OutputNamespace = outputNamespace; + } + + var emitInProject = GetProperty(globalOptions, MsBuildPropertyNames.EmitInProjectOutput); + if (emitInProject is not null && bool.TryParse(emitInProject, out var emit)) + { + target.EmitInProjectOutput = emit; + } + } + + private static string? Resolve(string? path, string? projectDir) + { + if (path is null) + { + return null; + } + + if (Path.IsPathRooted(path) || string.IsNullOrWhiteSpace(projectDir)) + { + return path; + } + + return Path.GetFullPath(Path.Combine(projectDir, path)); + } + + private static string? GetProperty(AnalyzerConfigOptions globalOptions, string key) => + globalOptions.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) + ? value.Trim() + : null; + + private static EntityLengthsGeneratorOptions Clone(EntityLengthsGeneratorOptions source) => + new() + { + GeneratedClassName = source.GeneratedClassName, + LengthSuffix = source.LengthSuffix, + GenerateDocumentation = source.GenerateDocumentation, + Namespace = source.Namespace, + OutputPath = source.OutputPath, + OutputNamespace = source.OutputNamespace, + EmitInProjectOutput = source.EmitInProjectOutput, + ScanningOptions = new EntityLengthsScanningOptions + { + IncludeNamespaces = [.. source.ScanningOptions.IncludeNamespaces], + ExcludeNamespaces = [.. source.ScanningOptions.ExcludeNamespaces], + ScanNestedNamespaces = source.ScanningOptions.ScanNestedNamespaces, + EntitySuffix = source.ScanningOptions.EntitySuffix, + }, + }; + private static T? GetNamedArgumentValue(AttributeData attribute, string name) { var argument = attribute.NamedArguments.FirstOrDefault(kvp => diff --git a/src/EntityLengths.Generator/Configuration/MsBuildPropertyNames.cs b/src/EntityLengths.Generator/Configuration/MsBuildPropertyNames.cs new file mode 100644 index 0000000..e30aabc --- /dev/null +++ b/src/EntityLengths.Generator/Configuration/MsBuildPropertyNames.cs @@ -0,0 +1,25 @@ +namespace EntityLengths.Generator.Configuration; + +/// +/// MSBuild property keys read from the analyzer config global options. They are made visible to the +/// compiler by the build/EntityLengths.Generator.targets file shipped in the NuGet package. +/// +internal static class MsBuildPropertyNames +{ + private const string Prefix = "build_property."; + + /// Absolute output path, pre-resolved by the shipped targets file. + public const string ResolvedOutputPath = Prefix + "EntityLengthsResolvedOutputPath"; + + /// Raw output path as written by the user, may be relative to the project directory. + public const string OutputPath = Prefix + "EntityLengthsOutputPath"; + + /// Namespace used for the file written to disk. + public const string OutputNamespace = Prefix + "EntityLengthsOutputNamespace"; + + /// Whether the constants are also added to the current compilation. + public const string EmitInProjectOutput = Prefix + "EntityLengthsEmitInProjectOutput"; + + /// Project directory, made compiler visible by the .NET SDK. + public const string ProjectDir = Prefix + "ProjectDir"; +} diff --git a/src/EntityLengths.Generator/Core/EquatableArray.cs b/src/EntityLengths.Generator/Core/EquatableArray.cs new file mode 100644 index 0000000..a3be753 --- /dev/null +++ b/src/EntityLengths.Generator/Core/EquatableArray.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace EntityLengths.Generator.Core; + +/// +/// An immutable array with structural equality, so it can safely flow through the +/// incremental generator pipeline without breaking output caching. +/// +public readonly struct EquatableArray(ImmutableArray values) + : IEquatable>, + IReadOnlyList + where T : IEquatable +{ + private readonly ImmutableArray _values = values; + + public static EquatableArray Empty => new(ImmutableArray.Empty); + + private ImmutableArray Values => _values.IsDefault ? ImmutableArray.Empty : _values; + + public int Count => Values.Length; + + public T this[int index] => Values[index]; + + public ImmutableArray AsImmutableArray() => Values; + + public bool Equals(EquatableArray other) => Values.SequenceEqual(other.Values); + + public override bool Equals(object? obj) => obj is EquatableArray other && Equals(other); + + public override int GetHashCode() + { + var hash = 17; + foreach (var value in Values) + { + hash = (hash * 31) + (value?.GetHashCode() ?? 0); + } + + return hash; + } + + public IEnumerator GetEnumerator() => ((IEnumerable)Values).GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} + +internal static class EquatableArrayExtensions +{ + public static EquatableArray ToEquatableArray(this IEnumerable source) + where T : IEquatable => new(source.ToImmutableArray()); +} diff --git a/src/EntityLengths.Generator/Core/IPropertyLengthExtractor.cs b/src/EntityLengths.Generator/Core/IPropertyLengthExtractor.cs index 27e99a9..b754505 100644 --- a/src/EntityLengths.Generator/Core/IPropertyLengthExtractor.cs +++ b/src/EntityLengths.Generator/Core/IPropertyLengthExtractor.cs @@ -1,9 +1,13 @@ -using EntityLengths.Generator.Models; +using EntityLengths.Generator.Models; using Microsoft.CodeAnalysis; namespace EntityLengths.Generator.Core; internal interface IPropertyLengthExtractor { - EntityTypeInfo? ExtractPropertyLengths(GeneratorSyntaxContext context); + /// + /// Extracts the lengths declared by the current syntax node. One node can configure several + /// entities - a DbContext usually configures all of them - so this returns a collection. + /// + EquatableArray ExtractPropertyLengths(GeneratorSyntaxContext context); } diff --git a/src/EntityLengths.Generator/Core/IsExternalInit.cs b/src/EntityLengths.Generator/Core/IsExternalInit.cs new file mode 100644 index 0000000..659ffb0 --- /dev/null +++ b/src/EntityLengths.Generator/Core/IsExternalInit.cs @@ -0,0 +1,11 @@ +using System.ComponentModel; + +// ReSharper disable once CheckNamespace +namespace System.Runtime.CompilerServices; + +/// +/// Polyfill required by records and init-only setters. Not part of netstandard2.0, which the +/// generator must target. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +internal static class IsExternalInit; diff --git a/src/EntityLengths.Generator/Core/NamespaceFilter.cs b/src/EntityLengths.Generator/Core/NamespaceFilter.cs index 81a68ca..e14d0ae 100644 --- a/src/EntityLengths.Generator/Core/NamespaceFilter.cs +++ b/src/EntityLengths.Generator/Core/NamespaceFilter.cs @@ -1,30 +1,18 @@ -using System.Linq; +using System; +using System.Linq; using EntityLengths.Generator.Options; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Syntax; namespace EntityLengths.Generator.Core; internal class NamespaceFilter(EntityLengthsScanningOptions options) { - public bool ShouldProcessNode(SyntaxNode node, SemanticModel semanticModel) + /// + /// Decides whether the type that declared the lengths passes the configured namespace and suffix + /// filters. Applied after extraction so the syntax transform stays independent of the options. + /// + public bool ShouldInclude(string declaringNamespace, string declaringTypeName) { - if (node is not ClassDeclarationSyntax classDeclaration) - { - return false; - } - - // Get containing namespace - var symbol = semanticModel.GetDeclaredSymbol(classDeclaration); - if (symbol == null) - { - return false; - } - - var namespaceName = symbol.ContainingNamespace.ToString(); - - // Check if we should process this namespace - if (!ShouldProcessNamespace(namespaceName)) + if (!ShouldProcessNamespace(declaringNamespace)) { return false; } @@ -32,7 +20,7 @@ public bool ShouldProcessNode(SyntaxNode node, SemanticModel semanticModel) // Check entity suffix if configured if (!string.IsNullOrEmpty(options.EntitySuffix)) { - return classDeclaration.Identifier.Text.EndsWith(options.EntitySuffix); + return declaringTypeName.EndsWith(options.EntitySuffix, StringComparison.Ordinal); } return true; diff --git a/src/EntityLengths.Generator/Diagnostics/DiagnosticDescriptors.cs b/src/EntityLengths.Generator/Diagnostics/DiagnosticDescriptors.cs new file mode 100644 index 0000000..75715f8 --- /dev/null +++ b/src/EntityLengths.Generator/Diagnostics/DiagnosticDescriptors.cs @@ -0,0 +1,57 @@ +using Microsoft.CodeAnalysis; + +namespace EntityLengths.Generator.Diagnostics; + +internal static class DiagnosticDescriptors +{ + private const string Category = "EntityLengths"; + + /// + /// The constants file could not be written to the configured output path. + /// + public static readonly DiagnosticDescriptor OutputWriteFailed = new( + id: "ELG0001", + title: "Could not write the generated constants file", + messageFormat: "Could not write the generated constants to '{0}': {1}", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + /// + /// An output path was configured without a namespace, so the current assembly name is used, which + /// is almost never right for a file compiled by another project. + /// + public static readonly DiagnosticDescriptor OutputNamespaceMissing = new( + id: "ELG0002", + title: "No namespace configured for the generated constants file", + messageFormat: "EntityLengthsOutputPath is set but no namespace is configured, so '{0}' is used. Set the EntityLengthsOutputNamespace MSBuild property to the namespace of the project that compiles the file.", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + /// + /// Two entity types share a simple name, so their constants end up in one nested class. + /// + public static readonly DiagnosticDescriptor DuplicateEntityName = new( + id: "ELG0003", + title: "Entity types share a simple name", + messageFormat: "Entity types {0} share the simple name '{1}', so their length constants are merged into a single nested class", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + /// + /// The same property has conflicting lengths from different sources. + /// + public static readonly DiagnosticDescriptor ConflictingPropertyLength = new( + id: "ELG0004", + title: "Conflicting lengths for a property", + messageFormat: "Property '{0}.{1}' has conflicting configured lengths ({2}); using {3}", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); +} diff --git a/src/EntityLengths.Generator/Emit/ConstantsSourceBuilder.cs b/src/EntityLengths.Generator/Emit/ConstantsSourceBuilder.cs new file mode 100644 index 0000000..42300db --- /dev/null +++ b/src/EntityLengths.Generator/Emit/ConstantsSourceBuilder.cs @@ -0,0 +1,90 @@ +using System.Collections.Generic; +using System.Text; +using EntityLengths.Generator.Models; +using EntityLengths.Generator.Options; + +namespace EntityLengths.Generator.Emit; + +/// +/// Renders the constants class. Used for both the in-compilation output and the file written to +/// another project, so the two can never drift apart. +/// +internal static class ConstantsSourceBuilder +{ + public static string Build( + string @namespace, + IReadOnlyList entities, + EntityLengthsGeneratorOptions options + ) + { + var sourceBuilder = new StringBuilder(); + sourceBuilder.AppendLine("// "); + sourceBuilder.AppendLine($"namespace {@namespace};"); + sourceBuilder.AppendLine(); + + if (options.GenerateDocumentation) + { + sourceBuilder.AppendLine("/// "); + sourceBuilder.AppendLine( + "/// Contains generated string length constants for entity properties" + ); + sourceBuilder.AppendLine("/// "); + } + + sourceBuilder.AppendLine( + $"public static partial class {options.GeneratedClassName} \r\n{{" + ); + + var isFirst = true; + foreach (var entity in entities) + { + if (!isFirst) + sourceBuilder.AppendLine(); + + if (options.GenerateDocumentation) + { + sourceBuilder.AppendLine("\t/// "); + sourceBuilder.AppendLine($"\t/// Length constants for {entity.Name}"); + sourceBuilder.AppendLine("\t/// "); + } + + AppendEntityClass(sourceBuilder, entity, options); + isFirst = false; + } + + sourceBuilder.AppendLine("}"); + + return sourceBuilder.ToString(); + } + + private static void AppendEntityClass( + StringBuilder sourceBuilder, + EntityConstants entity, + EntityLengthsGeneratorOptions options + ) + { + sourceBuilder.AppendLine($"\tpublic static partial class {entity.Name}"); + sourceBuilder.AppendLine("\t{"); + + foreach (var property in entity.Properties) + { + if (options.GenerateDocumentation) + { + sourceBuilder.AppendLine("\t\t/// "); + sourceBuilder.AppendLine($"\t\t/// Maximum length for {property.PropertyName}"); + sourceBuilder.AppendLine("\t\t/// "); + } + + sourceBuilder.AppendLine( + $"\t\tpublic const int {property.PropertyName}{options.LengthSuffix} = {property.MaxLength};" + ); + } + + sourceBuilder.AppendLine("\t}"); + } +} + +/// +/// The resolved, deduplicated constants of a single generated nested class. +/// +internal sealed record EntityConstants(string Name, IReadOnlyList Properties); diff --git a/src/EntityLengths.Generator/Emit/GeneratedFileWriter.cs b/src/EntityLengths.Generator/Emit/GeneratedFileWriter.cs new file mode 100644 index 0000000..c697025 --- /dev/null +++ b/src/EntityLengths.Generator/Emit/GeneratedFileWriter.cs @@ -0,0 +1,78 @@ +using System; +using System.IO; +using System.Text; + +namespace EntityLengths.Generator.Emit; + +/// +/// Writes the generated constants to a file outside the current project. +/// +/// +/// A generator normally must not touch the file system: it runs on every keystroke in the IDE and +/// once more during the build. Two mitigations keep that bearable - the file is only touched when its +/// content actually changes, and it is written through a temporary file so a concurrent reader never +/// sees a partial file. Any failure is reported as a diagnostic instead of throwing, because an +/// exception escaping a generator kills the whole compilation. +/// +// RS1035: analyzers must not do file IO. Writing the constants outside the current project is the +// whole point of the EntityLengthsOutputPath opt-in, and it is only reached when the user sets that +// property, so the ban is suppressed for this file only. +#pragma warning disable RS1035 +internal static class GeneratedFileWriter +{ + private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); + + public static bool TryWrite(string path, string content, out string? error) + { + error = null; + + try + { + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + if (IsUpToDate(path, content)) + { + return true; + } + + var temporaryPath = path + ".tmp"; + File.WriteAllText(temporaryPath, content, Utf8NoBom); + File.Copy(temporaryPath, path, overwrite: true); + File.Delete(temporaryPath); + + return true; + } + catch (Exception exception) + when (exception is IOException + or UnauthorizedAccessException + or NotSupportedException + or ArgumentException + ) + { + error = exception.Message; + return false; + } + } + + private static bool IsUpToDate(string path, string content) + { + if (!File.Exists(path)) + { + return false; + } + + try + { + return string.Equals(File.ReadAllText(path), content, StringComparison.Ordinal); + } + catch (IOException) + { + // The file is locked by another writer; treat it as stale and let the write attempt report. + return false; + } + } +} diff --git a/src/EntityLengths.Generator/EntityLengths.Generator.csproj b/src/EntityLengths.Generator/EntityLengths.Generator.csproj index be492f9..768d798 100644 --- a/src/EntityLengths.Generator/EntityLengths.Generator.csproj +++ b/src/EntityLengths.Generator/EntityLengths.Generator.csproj @@ -14,7 +14,7 @@ A C# source generator that automatically generates string length constants from Entity Framework configurations and data annotations. - 1.0.3 + 1.1.0 Taras Kovalenko Copyright Taras Kovalenko sourcegenerator;entityframework;stringlength;constants @@ -28,6 +28,9 @@ + + + @@ -47,4 +50,9 @@ + + + + + diff --git a/src/EntityLengths.Generator/EntityMaxLengthGenerator.cs b/src/EntityLengths.Generator/EntityMaxLengthGenerator.cs index 14e2f67..8b9cd12 100644 --- a/src/EntityLengths.Generator/EntityMaxLengthGenerator.cs +++ b/src/EntityLengths.Generator/EntityMaxLengthGenerator.cs @@ -1,10 +1,11 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; -using System.Text; using System.Threading; using EntityLengths.Generator.Configuration; using EntityLengths.Generator.Core; +using EntityLengths.Generator.Diagnostics; +using EntityLengths.Generator.Emit; using EntityLengths.Generator.Extensions; using EntityLengths.Generator.Extractors; using EntityLengths.Generator.Models; @@ -17,142 +18,184 @@ namespace EntityLengths.Generator; [Generator] public class EntityMaxLengthGenerator : IIncrementalGenerator { - private readonly EntityLengthsOptionsProvider _optionsProvider = new(); - public void Initialize(IncrementalGeneratorInitializationContext context) { - var typeDeclarations = context.SyntaxProvider.CreateSyntaxProvider( - predicate: IsSyntaxTargetForGeneration, - transform: (syntaxContext, _) => - { - var config = _optionsProvider.GetOptions(syntaxContext.SemanticModel.Compilation); - var filter = new NamespaceFilter(config.ScanningOptions); - - // Skip if namespace doesn't match filters - if (!filter.ShouldProcessNode(syntaxContext.Node, syntaxContext.SemanticModel)) - { - return null; - } - - var classNode = (ClassDeclarationSyntax)syntaxContext.Node; - - // Check for IEntityTypeConfiguration - if (classNode.IsEntityConfigurationClass()) - { - return new FluentConfigurationExtractor().ExtractPropertyLengths(syntaxContext); - } - - // Check for DbContext - if (classNode.IsDbContextClass()) - { - return new DbContextConfigurationExtractor().ExtractPropertyLengths( - syntaxContext - ); - } - - // Check for attributes - return new AttributeExtractor().ExtractPropertyLengths(syntaxContext); - } - ); + // Options depend on the compilation (assembly attribute) and on MSBuild properties. Kept out + // of the syntax transform on purpose: the transform must not see the Compilation, which is not + // equatable and would defeat caching. + var configuration = context + .CompilationProvider.Combine(context.AnalyzerConfigOptionsProvider) + .Select( + (pair, _) => + new GeneratorConfiguration( + new EntityLengthsOptionsProvider().GetOptions( + pair.Left, + pair.Right.GlobalOptions + ), + pair.Left.AssemblyName + ) + ); - var compiledTypes = typeDeclarations.Where(t => t != null).Collect(); + var typeDeclarations = context + .SyntaxProvider.CreateSyntaxProvider( + predicate: IsSyntaxTargetForGeneration, + transform: static (syntaxContext, _) => Transform(syntaxContext) + ) + .SelectMany(static (entities, _) => entities.AsImmutableArray()) + .Collect(); context.RegisterSourceOutput( - context.CompilationProvider.Combine(compiledTypes), - (spc, source) => GenerateOutput(spc, source.Left, source.Right) + configuration.Combine(typeDeclarations), + static (spc, source) => GenerateOutput(spc, source.Left, source.Right) ); } private static bool IsSyntaxTargetForGeneration(SyntaxNode node, CancellationToken _) => - node is ClassDeclarationSyntax; + // Only classes and records can carry lengths, and only through a base type (configuration or + // DbContext) or through attributes on their members. + node is TypeDeclarationSyntax typeDeclaration + && typeDeclaration is ClassDeclarationSyntax or RecordDeclarationSyntax + && ( + typeDeclaration.BaseList is not null + || typeDeclaration.Members.Any(static m => m.AttributeLists.Count > 0) + ); - private void GenerateOutput( - SourceProductionContext context, - Compilation compilation, - ImmutableArray types - ) + private static EquatableArray Transform(GeneratorSyntaxContext syntaxContext) { - var options = _optionsProvider.GetOptions(compilation); + var typeNode = (TypeDeclarationSyntax)syntaxContext.Node; - var sourceBuilder = new StringBuilder(); - sourceBuilder.AppendLine("// "); - - var ns = options.Namespace ?? compilation.AssemblyName; - sourceBuilder.AppendLine($"namespace {ns};"); - sourceBuilder.AppendLine(); + // Check for IEntityTypeConfiguration + if (typeNode.IsEntityConfigurationClass()) + { + return new FluentConfigurationExtractor().ExtractPropertyLengths(syntaxContext); + } - if (options.GenerateDocumentation) + // Check for DbContext + if (typeNode.IsDbContextClass()) { - sourceBuilder.AppendLine("/// "); - sourceBuilder.AppendLine( - "/// Contains generated string length constants for entity properties" - ); - sourceBuilder.AppendLine("/// "); + return new DbContextConfigurationExtractor().ExtractPropertyLengths(syntaxContext); } - sourceBuilder.AppendLine( - $"public static partial class {options.GeneratedClassName} \r\n{{" - ); + // Check for attributes + return new AttributeExtractor().ExtractPropertyLengths(syntaxContext); + } + + private static void GenerateOutput( + SourceProductionContext context, + GeneratorConfiguration configuration, + ImmutableArray types + ) + { + var options = configuration.Options; + var filter = new NamespaceFilter(options.ScanningOptions); - var entityGroups = types - .Where(t => t != null) - .GroupBy(t => t!.EntityType.Name) + var entities = types + .Where(t => filter.ShouldInclude(t.DeclaringNamespace, t.DeclaringTypeName)) + .GroupBy(t => t.EntityName) .OrderBy(g => g.Key) + .Select(g => Resolve(context, g.Key, [.. g])) .ToList(); - var isFirst = true; - foreach (var entityGroup in entityGroups) + if (options.EmitInProjectOutput) { - if (!isFirst) - sourceBuilder.AppendLine(); - - if (options.GenerateDocumentation) - { - sourceBuilder.AppendLine("\t/// "); - sourceBuilder.AppendLine($"\t/// Length constants for {entityGroup.Key}"); - sourceBuilder.AppendLine("\t/// "); - } - - var allProperties = entityGroup - .SelectMany(c => c!.StringProperties) - .GroupBy(p => p.PropertyName) - .Select(g => g.First()) - .OrderBy(p => p.PropertyName) - .ToList(); + var ns = options.Namespace ?? configuration.AssemblyName ?? Constants.ClassName; + context.AddSource( + $"{options.GeneratedClassName}.g.cs", + ConstantsSourceBuilder.Build(ns, entities, options) + ); + } - GenerateEntityClass(sourceBuilder, entityGroup.Key, allProperties, options); - isFirst = false; + if (options.OutputPath is null) + { + return; } - sourceBuilder.AppendLine("}"); + var outputNamespace = options.OutputNamespace ?? options.Namespace; + if (outputNamespace is null) + { + outputNamespace = configuration.AssemblyName ?? Constants.ClassName; + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.OutputNamespaceMissing, + Location.None, + outputNamespace + ) + ); + } - context.AddSource($"{options.GeneratedClassName}.g.cs", sourceBuilder.ToString()); + var content = ConstantsSourceBuilder.Build(outputNamespace, entities, options); + if (!GeneratedFileWriter.TryWrite(options.OutputPath, content, out var error)) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.OutputWriteFailed, + Location.None, + options.OutputPath, + error + ) + ); + } } - private static void GenerateEntityClass( - StringBuilder sourceBuilder, + /// + /// Collapses everything found for one entity name into a single nested class, reporting the + /// ambiguities instead of silently picking a value. + /// + private static EntityConstants Resolve( + SourceProductionContext context, string entityName, - IReadOnlyCollection properties, - EntityLengthsGeneratorOptions options + IReadOnlyList group ) { - sourceBuilder.AppendLine($"\tpublic static partial class {entityName}"); - sourceBuilder.AppendLine("\t{"); + var fullNames = group.Select(t => t.EntityFullName).Distinct().OrderBy(n => n).ToList(); + if (fullNames.Count > 1) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.DuplicateEntityName, + Location.None, + string.Join(", ", fullNames), + entityName + ) + ); + } - foreach (var prop in properties) + var properties = new List(); + foreach ( + var propertyGroup in group + .SelectMany(t => t.StringProperties) + .GroupBy(p => p.PropertyName) + .OrderBy(g => g.Key) + ) { - if (options.GenerateDocumentation) + var lengths = propertyGroup.Select(p => p.MaxLength).Distinct().ToList(); + var resolved = propertyGroup.First(); + + if (lengths.Count > 1) { - sourceBuilder.AppendLine("\t\t/// "); - sourceBuilder.AppendLine($"\t\t/// Maximum length for {prop.PropertyName}"); - sourceBuilder.AppendLine("\t\t/// "); + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.ConflictingPropertyLength, + Location.None, + entityName, + propertyGroup.Key, + string.Join(", ", lengths.OrderBy(l => l)), + resolved.MaxLength + ) + ); } - sourceBuilder.AppendLine( - $"\t\tpublic const int {prop.PropertyName}{options.LengthSuffix} = {prop.MaxLength};" - ); + properties.Add(resolved); } - sourceBuilder.AppendLine("\t}"); + return new EntityConstants(entityName, properties); } } + +/// +/// Everything the emit stage needs from the compilation, in an equatable shape. +/// +internal sealed record GeneratorConfiguration( + EntityLengthsGeneratorOptions Options, + string? AssemblyName +); diff --git a/src/EntityLengths.Generator/Extensions/CompilationExtensions.cs b/src/EntityLengths.Generator/Extensions/CompilationExtensions.cs index babf4c2..81843cd 100644 --- a/src/EntityLengths.Generator/Extensions/CompilationExtensions.cs +++ b/src/EntityLengths.Generator/Extensions/CompilationExtensions.cs @@ -9,7 +9,7 @@ namespace EntityLengths.Generator.Extensions; internal static class CompilationExtensions { public static ITypeSymbol? GetEntityTypeConfigurationBase( - this ClassDeclarationSyntax classSyntax, + this TypeDeclarationSyntax classSyntax, SemanticModel semanticModel ) { @@ -20,7 +20,7 @@ SemanticModel semanticModel } public static List FindMaxLengthProperties( - this ClassDeclarationSyntax classSyntax + this TypeDeclarationSyntax classSyntax ) { var maxLengthProperties = new List(); @@ -41,12 +41,12 @@ this ClassDeclarationSyntax classSyntax return maxLengthProperties; } - public static bool IsEntityConfigurationClass(this ClassDeclarationSyntax classNode) => + public static bool IsEntityConfigurationClass(this TypeDeclarationSyntax classNode) => classNode.BaseList?.Types.Any(t => t.Type.ToString().StartsWith(Constants.EntityTypeConfigurationInterface) ) == true; - public static bool IsDbContextClass(this ClassDeclarationSyntax classNode) => + public static bool IsDbContextClass(this TypeDeclarationSyntax classNode) => classNode.BaseList?.Types.Any(t => t.Type.ToString().Contains(Constants.DbContextClass)) == true; diff --git a/src/EntityLengths.Generator/Extensions/SymbolExtensions.cs b/src/EntityLengths.Generator/Extensions/SymbolExtensions.cs index a58a30d..41233d5 100644 --- a/src/EntityLengths.Generator/Extensions/SymbolExtensions.cs +++ b/src/EntityLengths.Generator/Extensions/SymbolExtensions.cs @@ -7,6 +7,14 @@ namespace EntityLengths.Generator.Extensions; internal static class SymbolExtensions { + /// + /// Namespace of the symbol, or an empty string for the global namespace. + /// + public static string GetNamespaceName(this ISymbol symbol) => + symbol.ContainingNamespace is null or { IsGlobalNamespace: true } + ? string.Empty + : symbol.ContainingNamespace.ToDisplayString(); + public static bool TryGetMaxLengthFromAttribute( this IPropertySymbol property, out int maxLength diff --git a/src/EntityLengths.Generator/Extractors/AttributeExtractor.cs b/src/EntityLengths.Generator/Extractors/AttributeExtractor.cs index 396ff27..66de690 100644 --- a/src/EntityLengths.Generator/Extractors/AttributeExtractor.cs +++ b/src/EntityLengths.Generator/Extractors/AttributeExtractor.cs @@ -1,25 +1,24 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using EntityLengths.Generator.Core; using EntityLengths.Generator.Extensions; using EntityLengths.Generator.Models; using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; namespace EntityLengths.Generator.Extractors; internal class AttributeExtractor : IPropertyLengthExtractor { - public EntityTypeInfo? ExtractPropertyLengths(GeneratorSyntaxContext context) + public EquatableArray ExtractPropertyLengths(GeneratorSyntaxContext context) { - var classSyntax = (ClassDeclarationSyntax)context.Node; + var typeSyntax = (TypeDeclarationSyntax)context.Node; var semanticModel = context.SemanticModel; - var classSymbol = semanticModel.GetDeclaredSymbol(classSyntax); + var classSymbol = semanticModel.GetDeclaredSymbol(typeSyntax) as INamedTypeSymbol; if (classSymbol is null) { - return null; + return EquatableArray.Empty; } var stringPropertiesWithMaxLength = new List(); @@ -40,6 +39,7 @@ internal class AttributeExtractor : IPropertyLengthExtractor if (member.TryGetMaxLengthFromColumnType(out maxLength)) { stringPropertiesWithMaxLength.Add(new PropertyMaxLength(member.Name, maxLength)); + continue; } if (member.TryGetStringLengthFromAttribute(out maxLength)) @@ -48,8 +48,11 @@ internal class AttributeExtractor : IPropertyLengthExtractor } } - return stringPropertiesWithMaxLength.Any() - ? new EntityTypeInfo(classSymbol, stringPropertiesWithMaxLength) - : null; + return EntityTypeInfoFactory.Create( + classSymbol, + typeSyntax, + semanticModel, + stringPropertiesWithMaxLength + ); } } diff --git a/src/EntityLengths.Generator/Extractors/DbContextConfigurationExtractor.cs b/src/EntityLengths.Generator/Extractors/DbContextConfigurationExtractor.cs index 0f0ddec..f7a375f 100644 --- a/src/EntityLengths.Generator/Extractors/DbContextConfigurationExtractor.cs +++ b/src/EntityLengths.Generator/Extractors/DbContextConfigurationExtractor.cs @@ -10,19 +10,19 @@ namespace EntityLengths.Generator.Extractors; internal class DbContextConfigurationExtractor : IPropertyLengthExtractor { - public EntityTypeInfo? ExtractPropertyLengths(GeneratorSyntaxContext context) + public EquatableArray ExtractPropertyLengths(GeneratorSyntaxContext context) { - var classSyntax = (ClassDeclarationSyntax)context.Node; + var typeSyntax = (TypeDeclarationSyntax)context.Node; var semanticModel = context.SemanticModel; - var classSymbol = semanticModel.GetDeclaredSymbol(classSyntax); + var classSymbol = semanticModel.GetDeclaredSymbol(typeSyntax); if (!IsDbContextClass(classSymbol)) { - return null; + return EquatableArray.Empty; } // Find OnModelCreating method - var onModelCreating = classSyntax + var onModelCreating = typeSyntax .Members.OfType() .FirstOrDefault(m => string.Equals( @@ -34,7 +34,7 @@ internal class DbContextConfigurationExtractor : IPropertyLengthExtractor if (onModelCreating == null) { - return null; + return EquatableArray.Empty; } // Find Entity calls and their subsequent configurations @@ -44,10 +44,16 @@ internal class DbContextConfigurationExtractor : IPropertyLengthExtractor .Where(IsEntityCall) .ToList(); + // A DbContext normally configures every entity, so all of them are collected instead of + // stopping at the first one. + var entities = + new List>>(); + var seenEntityTypes = new HashSet(SymbolEqualityComparer.Default); + foreach (var entityCall in entityCalls) { if ( - TryGetEntityTypeAndConfigurations( + !TryGetEntityTypeAndConfigurations( entityCall, semanticModel, onModelCreating, @@ -56,12 +62,25 @@ out var configs ) ) { - // Return the EntityTypeInfo for this entity type - return new EntityTypeInfo(entityType, configs); + continue; + } + + // Configurations are collected per entity type across the whole method, so a type used in + // several Entity() calls would otherwise be added more than once. + if (!seenEntityTypes.Add(entityType)) + { + continue; } + + entities.Add( + new KeyValuePair>( + entityType, + configs + ) + ); } - return null; + return EntityTypeInfoFactory.CreateMany(entities, typeSyntax, semanticModel); } private static bool IsDbContextClass(ISymbol? classSymbol) diff --git a/src/EntityLengths.Generator/Extractors/EntityTypeInfoFactory.cs b/src/EntityLengths.Generator/Extractors/EntityTypeInfoFactory.cs new file mode 100644 index 0000000..eed0fdf --- /dev/null +++ b/src/EntityLengths.Generator/Extractors/EntityTypeInfoFactory.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using EntityLengths.Generator.Core; +using EntityLengths.Generator.Extensions; +using EntityLengths.Generator.Models; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace EntityLengths.Generator.Extractors; + +internal static class EntityTypeInfoFactory +{ + /// + /// Builds the symbol-free pipeline model for a single entity. Yields nothing when no lengths were + /// found, so the entry drops out of the pipeline. + /// + public static EquatableArray Create( + ITypeSymbol entityType, + TypeDeclarationSyntax declaringNode, + SemanticModel semanticModel, + IReadOnlyCollection properties + ) => + properties.Count == 0 + ? EquatableArray.Empty + : new EquatableArray( + ImmutableArray.Create( + CreateSingle(entityType, declaringNode, semanticModel, properties) + ) + ); + + /// + /// Builds the pipeline model for a node that configures several entities, such as a DbContext. + /// + public static EquatableArray CreateMany( + IEnumerable>> entities, + TypeDeclarationSyntax declaringNode, + SemanticModel semanticModel + ) + { + var builder = ImmutableArray.CreateBuilder(); + + foreach (var entity in entities) + { + if (entity.Value.Count == 0) + { + continue; + } + + builder.Add(CreateSingle(entity.Key, declaringNode, semanticModel, entity.Value)); + } + + return new EquatableArray(builder.ToImmutable()); + } + + private static EntityTypeInfo CreateSingle( + ITypeSymbol entityType, + TypeDeclarationSyntax declaringNode, + SemanticModel semanticModel, + IReadOnlyCollection properties + ) + { + var declaringSymbol = semanticModel.GetDeclaredSymbol(declaringNode); + + return new EntityTypeInfo( + EntityName: entityType.Name, + EntityNamespace: entityType.GetNamespaceName(), + DeclaringNamespace: declaringSymbol?.GetNamespaceName() ?? string.Empty, + DeclaringTypeName: declaringNode.Identifier.Text, + StringProperties: properties.ToEquatableArray() + ); + } +} diff --git a/src/EntityLengths.Generator/Extractors/FluentConfigurationExtractor.cs b/src/EntityLengths.Generator/Extractors/FluentConfigurationExtractor.cs index 274952d..1a3aeab 100644 --- a/src/EntityLengths.Generator/Extractors/FluentConfigurationExtractor.cs +++ b/src/EntityLengths.Generator/Extractors/FluentConfigurationExtractor.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System.Linq; using EntityLengths.Generator.Core; using EntityLengths.Generator.Extensions; using EntityLengths.Generator.Models; @@ -9,27 +9,28 @@ namespace EntityLengths.Generator.Extractors; internal class FluentConfigurationExtractor : IPropertyLengthExtractor { - public EntityTypeInfo? ExtractPropertyLengths(GeneratorSyntaxContext context) + public EquatableArray ExtractPropertyLengths(GeneratorSyntaxContext context) { - var classSyntax = (ClassDeclarationSyntax)context.Node; + var typeSyntax = (TypeDeclarationSyntax)context.Node; var semanticModel = context.SemanticModel; - var baseType = classSyntax.GetEntityTypeConfigurationBase(semanticModel); + var baseType = typeSyntax.GetEntityTypeConfigurationBase(semanticModel); if (baseType is null) { - return null; + return EquatableArray.Empty; } var entityType = (baseType as INamedTypeSymbol)?.TypeArguments.FirstOrDefault(); if (entityType is null) { - return null; + return EquatableArray.Empty; } - var maxLengthProperties = classSyntax.FindMaxLengthProperties(); - - return maxLengthProperties.Any() - ? new EntityTypeInfo(entityType, maxLengthProperties) - : null; + return EntityTypeInfoFactory.Create( + entityType, + typeSyntax, + semanticModel, + typeSyntax.FindMaxLengthProperties() + ); } } diff --git a/src/EntityLengths.Generator/Models/EntityTypeInfo.cs b/src/EntityLengths.Generator/Models/EntityTypeInfo.cs index ddd56e5..5946eee 100644 --- a/src/EntityLengths.Generator/Models/EntityTypeInfo.cs +++ b/src/EntityLengths.Generator/Models/EntityTypeInfo.cs @@ -1,13 +1,28 @@ -using System.Collections.Generic; -using Microsoft.CodeAnalysis; +using EntityLengths.Generator.Core; namespace EntityLengths.Generator.Models; +/// +/// A fully value-equatable description of the length constants found for a single entity type. +/// Deliberately holds no Roslyn symbols: symbols root the +/// and are not equatable, which would disable incremental caching. +/// +/// Simple name of the entity type, used as the generated nested class name. +/// Namespace of the entity type, empty for the global namespace. +/// Namespace of the type that declared the lengths (entity, configuration or DbContext). +/// Simple name of the type that declared the lengths. +/// The discovered property lengths. public sealed record EntityTypeInfo( - ITypeSymbol EntityType, - List StringProperties + string EntityName, + string EntityNamespace, + string DeclaringNamespace, + string DeclaringTypeName, + EquatableArray StringProperties ) { - public ITypeSymbol EntityType { get; } = EntityType; - public List StringProperties { get; } = StringProperties; + /// + /// Namespace-qualified entity name, used to detect distinct entities that share a simple name. + /// + public string EntityFullName => + EntityNamespace.Length == 0 ? EntityName : $"{EntityNamespace}.{EntityName}"; } diff --git a/src/EntityLengths.Generator/Options/EntityLengthsGeneratorOptions.cs b/src/EntityLengths.Generator/Options/EntityLengthsGeneratorOptions.cs index 2012ff8..0528efe 100644 --- a/src/EntityLengths.Generator/Options/EntityLengthsGeneratorOptions.cs +++ b/src/EntityLengths.Generator/Options/EntityLengthsGeneratorOptions.cs @@ -1,9 +1,11 @@ -namespace EntityLengths.Generator.Options; +using System; + +namespace EntityLengths.Generator.Options; /// /// Generator options for the EntityLengths generator. /// -public class EntityLengthsGeneratorOptions +public class EntityLengthsGeneratorOptions : IEquatable { /// /// The name of the generated static class. Default is "EntityLengths" @@ -25,6 +27,29 @@ public class EntityLengthsGeneratorOptions /// public string? Namespace { get; set; } + /// + /// Optional absolute file path the constants are additionally written to, so another project + /// (for example the Domain layer of a Clean Architecture solution) can compile them as a normal + /// checked-in file. Configured through the EntityLengthsOutputPath MSBuild property. + /// If null, the constants are only added to the current compilation. + /// + public string? OutputPath { get; set; } + + /// + /// Namespace used for the file written to . Configured through the + /// EntityLengthsOutputNamespace MSBuild property. Falls back to + /// and then to the current assembly name. + /// + public string? OutputNamespace { get; set; } + + /// + /// If true, the constants are also added to the current compilation. Defaults to true, and to + /// false when is set - otherwise the same type would exist both in this + /// assembly and in the project that compiles the written file. Configured through the + /// EntityLengthsEmitInProjectOutput MSBuild property. + /// + public bool EmitInProjectOutput { get; set; } = true; + /// /// Scanning options for the EntityLengths generator. /// @@ -32,4 +57,42 @@ public class EntityLengthsGeneratorOptions EntityLengthsScanningOptions.Default; public static EntityLengthsGeneratorOptions Default => new(); + + public bool Equals(EntityLengthsGeneratorOptions? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return string.Equals(GeneratedClassName, other.GeneratedClassName, StringComparison.Ordinal) + && string.Equals(LengthSuffix, other.LengthSuffix, StringComparison.Ordinal) + && GenerateDocumentation == other.GenerateDocumentation + && string.Equals(Namespace, other.Namespace, StringComparison.Ordinal) + && string.Equals(OutputPath, other.OutputPath, StringComparison.Ordinal) + && string.Equals(OutputNamespace, other.OutputNamespace, StringComparison.Ordinal) + && EmitInProjectOutput == other.EmitInProjectOutput + && ScanningOptions.Equals(other.ScanningOptions); + } + + public override bool Equals(object? obj) => Equals(obj as EntityLengthsGeneratorOptions); + + public override int GetHashCode() + { + var hash = 17; + hash = (hash * 31) + GeneratedClassName.GetHashCode(); + hash = (hash * 31) + LengthSuffix.GetHashCode(); + hash = (hash * 31) + GenerateDocumentation.GetHashCode(); + hash = (hash * 31) + (Namespace?.GetHashCode() ?? 0); + hash = (hash * 31) + (OutputPath?.GetHashCode() ?? 0); + hash = (hash * 31) + (OutputNamespace?.GetHashCode() ?? 0); + hash = (hash * 31) + EmitInProjectOutput.GetHashCode(); + hash = (hash * 31) + ScanningOptions.GetHashCode(); + return hash; + } } diff --git a/src/EntityLengths.Generator/Options/EntityLengthsScanningOptions.cs b/src/EntityLengths.Generator/Options/EntityLengthsScanningOptions.cs index 411add2..d9a278c 100644 --- a/src/EntityLengths.Generator/Options/EntityLengthsScanningOptions.cs +++ b/src/EntityLengths.Generator/Options/EntityLengthsScanningOptions.cs @@ -1,11 +1,12 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; namespace EntityLengths.Generator.Options; /// /// Scanning options for the EntityLengths generator. /// -public class EntityLengthsScanningOptions +public class EntityLengthsScanningOptions : IEquatable { /// /// List of namespaces to include in scanning. If empty, all namespaces will be scanned. @@ -28,4 +29,34 @@ public class EntityLengthsScanningOptions public string? EntitySuffix { get; set; } public static EntityLengthsScanningOptions Default => new(); + + public bool Equals(EntityLengthsScanningOptions? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return ScanNestedNamespaces == other.ScanNestedNamespaces + && string.Equals(EntitySuffix, other.EntitySuffix, StringComparison.Ordinal) + && IncludeNamespaces.SetEquals(other.IncludeNamespaces) + && ExcludeNamespaces.SetEquals(other.ExcludeNamespaces); + } + + public override bool Equals(object? obj) => Equals(obj as EntityLengthsScanningOptions); + + public override int GetHashCode() + { + var hash = 17; + hash = (hash * 31) + ScanNestedNamespaces.GetHashCode(); + hash = (hash * 31) + (EntitySuffix?.GetHashCode() ?? 0); + hash = (hash * 31) + IncludeNamespaces.Count; + hash = (hash * 31) + ExcludeNamespaces.Count; + return hash; + } } diff --git a/src/EntityLengths.Generator/build/EntityLengths.Generator.targets b/src/EntityLengths.Generator/build/EntityLengths.Generator.targets new file mode 100644 index 0000000..534add6 --- /dev/null +++ b/src/EntityLengths.Generator/build/EntityLengths.Generator.targets @@ -0,0 +1,18 @@ + + + + + + $([MSBuild]::NormalizePath('$(MSBuildProjectDirectory)', '$(EntityLengthsOutputPath)')) + + + + + + + + + diff --git a/src/EntityLengths.Generator/docs/README.md b/src/EntityLengths.Generator/docs/README.md index eedf62b..fd274f8 100644 --- a/src/EntityLengths.Generator/docs/README.md +++ b/src/EntityLengths.Generator/docs/README.md @@ -5,7 +5,7 @@ [![EntityLengths.Generator NuGet current](https://img.shields.io/nuget/v/EntityLengths.Generator?label=EntityLengths.Generator)](https://www.nuget.org/packages/EntityLengths.Generator/) ## Goals -This library is a C# source generator designed to automatically generate string length constants from Entity Framework configurations and data annotations. +This library is a C# source generator designed to automatically generate string length constants from Entity Framework configurations and data annotations. By analyzing your model configurations, it eliminates the need for manual constant maintenance and reduces the risk of hardcoded length values across your application. ## Terms of use @@ -39,6 +39,9 @@ To learn more about the war and how you can help, [click here](https://war.ukrai - `[Column(TypeName = "nvarchar(200)")]` - `[Column(TypeName = "char(200)")]` - DbContext configurations (`OnModelCreating`) +- Works on `class` and `record` entities +- Can write the constants into another project, for Clean Architecture and similar layouts + (see [Writing the constants into another project](#writing-the-constants-into-another-project-clean-architecture)) ## Installation @@ -150,6 +153,51 @@ There are ways to configure EntityLengths.Generator. Configuration values are ne - `ScanEntitySuffix` - The suffix for the entity classes to scan. Default is `null`. - `Namespace` - The namespace for the generated class. Default is `null`. +### Writing the constants into another project (Clean Architecture) + +A source generator can only add code to the project it runs in. When the EF configurations live in +`Infrastructure` but the constants belong in `Domain` (which must not reference `Infrastructure`), +the generator can additionally write the constants to a file in that other project. The file is a +normal source file: commit it, and the other project compiles it like any other code. + +Configure it with MSBuild properties in the project that contains the EF configurations: + +```xml + + + + ../MyApp.Domain/Generated/EntityLengths.cs + + MyApp.Domain + +``` + +- `EntityLengthsOutputPath` - File the constants are written to. Relative paths resolve against the + project directory. Default is empty, meaning nothing is written to disk. +- `EntityLengthsOutputNamespace` - Namespace used in the written file. Falls back to the + `Namespace` attribute value and then to the assembly name (reported as `ELG0002`). +- `EntityLengthsEmitInProjectOutput` - Whether the constants are also compiled into the current + project. Default is `true`, and `false` as soon as `EntityLengthsOutputPath` is set, because + otherwise the same type would exist in both assemblies. Set it to `true` explicitly if the two + namespaces differ and you want both. + +Notes: + +- The file is written during compilation, so `Domain` picks up changes on the **next** build. Commit + the generated file and treat it as checked-in generated code. +- The file is only touched when its content actually changes, so editing in an IDE does not churn it. +- Fluent API and `OnModelCreating` lengths are read from source, so the generator must run in the + project that contains those configurations - it cannot read them from a referenced assembly. + +### Diagnostics + +| ID | Severity | Meaning | +|----|----------|---------| +| `ELG0001` | Warning | The constants file could not be written to `EntityLengthsOutputPath` | +| `ELG0002` | Warning | `EntityLengthsOutputPath` is set without a namespace, so the assembly name is used | +| `ELG0003` | Warning | Two entity types share a simple name, so their constants are merged into one nested class | +| `ELG0004` | Warning | A property has conflicting configured lengths; the first one found is used | + Generated output: ```csharp diff --git a/tests/EntityLengths.Generator.Tests/GeneratorBehaviorTests.cs b/tests/EntityLengths.Generator.Tests/GeneratorBehaviorTests.cs new file mode 100644 index 0000000..3e4cd12 --- /dev/null +++ b/tests/EntityLengths.Generator.Tests/GeneratorBehaviorTests.cs @@ -0,0 +1,201 @@ +using System.Linq; +using EntityLengths.Generator.Tests.Infrastructure; +using Xunit; + +namespace EntityLengths.Generator.Tests; + +public class GeneratorBehaviorTests +{ + [Fact] + public void Generates_Constants_For_Record_Entities() + { + const string source = + @" +using System.ComponentModel.DataAnnotations; + +namespace TestNamespace; + +public record User +{ + [MaxLength(50)] + public string Name { get; init; } = string.Empty; +}"; + + var output = GetGeneratedSource(source); + + Assert.Contains("public static partial class User", output); + Assert.Contains("public const int NameLength = 50;", output); + } + + [Fact] + public void Generates_Constants_For_Every_Entity_Configured_By_A_DbContext() + { + const string source = + @" +using Microsoft.EntityFrameworkCore; + +namespace TestNamespace; + +public class User +{ + public string Name { get; set; } = string.Empty; +} + +public class Order +{ + public string Reference { get; set; } = string.Empty; +} + +public class SampleDbContext : DbContext +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().Property(u => u.Name).HasMaxLength(50); + modelBuilder.Entity().Property(o => o.Reference).HasMaxLength(30); + } +}"; + + var output = GetGeneratedSource(source); + + Assert.Contains("public const int NameLength = 50;", output); + Assert.Contains("public const int ReferenceLength = 30;", output); + } + + [Fact] + public void Reports_ELG0003_When_Two_Entities_Share_A_Simple_Name() + { + const string source = + @" +using System.ComponentModel.DataAnnotations; + +namespace Billing +{ + public class Invoice + { + [MaxLength(50)] + public string Number { get; set; } = string.Empty; + } +} + +namespace Sales +{ + public class Invoice + { + [MaxLength(20)] + public string Code { get; set; } = string.Empty; + } +}"; + + var result = TestDriver.Run(TestCompilation.Create(source)); + + var diagnostic = Assert.Single(result.Diagnostics); + Assert.Equal("ELG0003", diagnostic.Id); + Assert.Contains("Billing.Invoice, Sales.Invoice", diagnostic.GetMessage()); + } + + [Fact] + public void Reports_ELG0004_When_A_Property_Has_Conflicting_Lengths() + { + const string source = + @" +using System.ComponentModel.DataAnnotations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace TestNamespace; + +public class User +{ + [MaxLength(50)] + public string Name { get; set; } = string.Empty; +} + +public class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(u => u.Name).HasMaxLength(100); + } +}"; + + var result = TestDriver.Run(TestCompilation.Create(source)); + + var diagnostic = Assert.Single(result.Diagnostics); + Assert.Equal("ELG0004", diagnostic.Id); + Assert.Contains("50, 100", diagnostic.GetMessage()); + } + + [Fact] + public void Excluded_Namespaces_Are_Not_Scanned() + { + const string source = + @" +using System.ComponentModel.DataAnnotations; +using EntityLengths.Generator.Configuration; + +[assembly: EntityLengthsGenerator(ExcludeNamespaces = new[] { ""TestNamespace.Ignored"" })] + +namespace TestNamespace.Kept +{ + public class User + { + [MaxLength(50)] + public string Name { get; set; } = string.Empty; + } +} + +namespace TestNamespace.Ignored +{ + public class Secret + { + [MaxLength(10)] + public string Token { get; set; } = string.Empty; + } +}"; + + var output = GetGeneratedSource(source); + + Assert.Contains("public static partial class User", output); + Assert.DoesNotContain("Secret", output); + } + + [Fact] + public void Entity_Suffix_Filter_Is_Applied() + { + const string source = + @" +using System.ComponentModel.DataAnnotations; +using EntityLengths.Generator.Configuration; + +[assembly: EntityLengthsGenerator(ScanEntitySuffix = ""Entity"")] + +namespace TestNamespace; + +public class UserEntity +{ + [MaxLength(50)] + public string Name { get; set; } = string.Empty; +} + +public class Helper +{ + [MaxLength(10)] + public string Token { get; set; } = string.Empty; +}"; + + var output = GetGeneratedSource(source); + + Assert.Contains("public static partial class UserEntity", output); + Assert.DoesNotContain("Helper", output); + } + + private static string GetGeneratedSource(string source) + { + var result = TestDriver.Run(TestCompilation.Create(source)); + + return result + .GeneratedTrees.Single(t => t.FilePath.EndsWith("EntityLengths.g.cs")) + .GetText() + .ToString(); + } +} diff --git a/tests/EntityLengths.Generator.Tests/IncrementalCachingTests.cs b/tests/EntityLengths.Generator.Tests/IncrementalCachingTests.cs new file mode 100644 index 0000000..ea09177 --- /dev/null +++ b/tests/EntityLengths.Generator.Tests/IncrementalCachingTests.cs @@ -0,0 +1,69 @@ +using System.Collections.Immutable; +using System.Linq; +using EntityLengths.Generator.Tests.Infrastructure; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Xunit; + +namespace EntityLengths.Generator.Tests; + +public class IncrementalCachingTests +{ + private const string Source = + @" +using System.ComponentModel.DataAnnotations; + +namespace TestNamespace; + +public class User +{ + [MaxLength(50)] + public string Name { get; set; } = string.Empty; +}"; + + [Fact] + public void Reuses_Cached_Output_When_An_Unrelated_File_Changes() + { + var compilation = TestCompilation.Create(Source); + + var driver = CSharpGeneratorDriver + .Create( + generators: ImmutableArray.Create( + new EntityMaxLengthGenerator().AsSourceGenerator() + ), + additionalTexts: null, + parseOptions: null, + optionsProvider: null, + driverOptions: new GeneratorDriverOptions( + IncrementalGeneratorOutputKind.None, + trackIncrementalGeneratorSteps: true + ) + ) + .RunGenerators(compilation); + + // An edit that cannot affect any length must not re-run the emit stage. + var edited = compilation.AddSyntaxTrees( + CSharpSyntaxTree.ParseText("// nothing to do with entities") + ); + + var result = driver.RunGenerators(edited).GetRunResult().Results.Single(); + + var outputReasons = result + .TrackedOutputSteps.SelectMany(step => step.Value) + .SelectMany(step => step.Outputs) + .Select(output => output.Reason) + .ToList(); + + Assert.NotEmpty(outputReasons); + Assert.All( + outputReasons, + reason => + Assert.True( + reason + is IncrementalStepRunReason.Cached + or IncrementalStepRunReason.Unchanged, + $"Expected the output to be cached but it was {reason}" + ) + ); + } +} diff --git a/tests/EntityLengths.Generator.Tests/Infrastructure/TestCompilation.cs b/tests/EntityLengths.Generator.Tests/Infrastructure/TestCompilation.cs new file mode 100644 index 0000000..812eb3d --- /dev/null +++ b/tests/EntityLengths.Generator.Tests/Infrastructure/TestCompilation.cs @@ -0,0 +1,100 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Runtime.InteropServices; +using EntityLengths.Generator.Configuration; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace EntityLengths.Generator.Tests.Infrastructure; + +internal static class TestCompilation +{ + public static CSharpCompilation Create(string sourceCode, string assemblyName = "TestAssembly") + { + var runtimePath = Path.Combine( + RuntimeEnvironment.GetRuntimeDirectory(), + "System.Runtime.dll" + ); + + var references = new List + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(runtimePath), + MetadataReference.CreateFromFile(typeof(MaxLengthAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(DbContext).Assembly.Location), + MetadataReference.CreateFromFile(typeof(EntityTypeBuilder).Assembly.Location), + // Needed so [assembly: EntityLengthsGenerator(...)] in test sources binds. + MetadataReference.CreateFromFile( + typeof(EntityLengthsGeneratorAttribute).Assembly.Location + ), + }; + + var netstandardPath = Path.Combine( + RuntimeEnvironment.GetRuntimeDirectory(), + "netstandard.dll" + ); + if (File.Exists(netstandardPath)) + { + references.Add(MetadataReference.CreateFromFile(netstandardPath)); + } + + return CSharpCompilation.Create( + assemblyName, + [CSharpSyntaxTree.ParseText(sourceCode)], + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + } +} + +/// +/// Feeds MSBuild properties to the generator the same way the shipped .targets file does. +/// +internal sealed class TestAnalyzerConfigOptionsProvider(Dictionary globalOptions) + : AnalyzerConfigOptionsProvider +{ + public override AnalyzerConfigOptions GlobalOptions { get; } = + new TestAnalyzerConfigOptions(globalOptions); + + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => + new TestAnalyzerConfigOptions(new Dictionary()); + + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => + new TestAnalyzerConfigOptions(new Dictionary()); + + private sealed class TestAnalyzerConfigOptions(Dictionary options) + : AnalyzerConfigOptions + { + public override bool TryGetValue(string key, out string value) => + options.TryGetValue(key, out value!); + + public override IEnumerable Keys => options.Keys; + } +} + +internal static class TestDriver +{ + public static GeneratorDriverRunResult Run( + Compilation compilation, + Dictionary? msBuildProperties = null + ) + { + var driver = CSharpGeneratorDriver.Create( + generators: ImmutableArray.Create( + new EntityMaxLengthGenerator().AsSourceGenerator() + ), + additionalTexts: null, + parseOptions: null, + optionsProvider: msBuildProperties is null + ? null + : new TestAnalyzerConfigOptionsProvider(msBuildProperties) + ); + + return driver.RunGenerators(compilation).GetRunResult(); + } +} diff --git a/tests/EntityLengths.Generator.Tests/OutputPathTests.cs b/tests/EntityLengths.Generator.Tests/OutputPathTests.cs new file mode 100644 index 0000000..f69c306 --- /dev/null +++ b/tests/EntityLengths.Generator.Tests/OutputPathTests.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using EntityLengths.Generator.Tests.Infrastructure; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace EntityLengths.Generator.Tests; + +public sealed class OutputPathTests : IDisposable +{ + private readonly string _directory = Path.Combine( + Path.GetTempPath(), + $"entitylengths-{Guid.NewGuid():N}" + ); + + private string OutputPath => Path.Combine(_directory, "Generated", "EntityLengths.cs"); + + public void Dispose() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + } + + [Fact] + public void Writes_Constants_To_Configured_Path() + { + var result = Run( + new Dictionary + { + ["build_property.EntityLengthsResolvedOutputPath"] = OutputPath, + ["build_property.EntityLengthsOutputNamespace"] = "MyApp.Domain", + } + ); + + Assert.Empty(result.Diagnostics); + Assert.True(File.Exists(OutputPath)); + + var content = File.ReadAllText(OutputPath); + Assert.Contains("namespace MyApp.Domain;", content); + Assert.Contains("public const int NameLength = 50;", content); + } + + [Fact] + public void Does_Not_Add_Source_To_Current_Compilation_When_Path_Configured() + { + var result = Run( + new Dictionary + { + ["build_property.EntityLengthsResolvedOutputPath"] = OutputPath, + ["build_property.EntityLengthsOutputNamespace"] = "MyApp.Domain", + } + ); + + // The other project compiles the written file, so emitting the same type here too would + // produce it in two assemblies. + Assert.Empty(result.GeneratedTrees); + } + + [Fact] + public void Adds_Source_And_Writes_File_When_In_Project_Output_Requested() + { + var result = Run( + new Dictionary + { + ["build_property.EntityLengthsResolvedOutputPath"] = OutputPath, + ["build_property.EntityLengthsOutputNamespace"] = "MyApp.Domain", + ["build_property.EntityLengthsEmitInProjectOutput"] = "true", + } + ); + + Assert.Single(result.GeneratedTrees); + Assert.True(File.Exists(OutputPath)); + } + + [Fact] + public void Adds_Source_Only_When_No_Path_Configured() + { + var result = Run(new Dictionary()); + + Assert.Single(result.GeneratedTrees); + Assert.False(Directory.Exists(_directory)); + } + + [Fact] + public void Resolves_Relative_Path_Against_Project_Directory() + { + var result = Run( + new Dictionary + { + ["build_property.EntityLengthsOutputPath"] = Path.Combine( + "Generated", + "EntityLengths.cs" + ), + ["build_property.ProjectDir"] = _directory, + ["build_property.EntityLengthsOutputNamespace"] = "MyApp.Domain", + } + ); + + Assert.Empty(result.Diagnostics); + Assert.True(File.Exists(OutputPath)); + } + + [Fact] + public void Reports_ELG0002_And_Falls_Back_To_Assembly_Name_When_Namespace_Missing() + { + var result = Run( + new Dictionary + { + ["build_property.EntityLengthsResolvedOutputPath"] = OutputPath, + } + ); + + var diagnostic = Assert.Single(result.Diagnostics); + Assert.Equal("ELG0002", diagnostic.Id); + Assert.Contains("namespace TestAssembly;", File.ReadAllText(OutputPath)); + } + + [Fact] + public void Uses_Attribute_Namespace_When_Output_Namespace_Missing() + { + var source = + @" +using System.ComponentModel.DataAnnotations; +using EntityLengths.Generator.Configuration; + +[assembly: EntityLengthsGenerator(Namespace = ""MyApp.Domain"")] + +namespace TestNamespace; + +public class User +{ + [MaxLength(50)] + public string Name { get; set; } = string.Empty; +}"; + + var result = TestDriver.Run( + TestCompilation.Create(source), + new Dictionary + { + ["build_property.EntityLengthsResolvedOutputPath"] = OutputPath, + } + ); + + Assert.Empty(result.Diagnostics); + Assert.Contains("namespace MyApp.Domain;", File.ReadAllText(OutputPath)); + } + + [Fact] + public void Leaves_File_Untouched_When_Content_Is_Unchanged() + { + var properties = new Dictionary + { + ["build_property.EntityLengthsResolvedOutputPath"] = OutputPath, + ["build_property.EntityLengthsOutputNamespace"] = "MyApp.Domain", + }; + + Run(properties); + var firstWrite = File.GetLastWriteTimeUtc(OutputPath); + + // The IDE runs the generator on every keystroke, so an unchanged result must not touch the file. + File.SetLastWriteTimeUtc(OutputPath, firstWrite.AddDays(-1)); + var stamped = File.GetLastWriteTimeUtc(OutputPath); + + Run(properties); + + Assert.Equal(stamped, File.GetLastWriteTimeUtc(OutputPath)); + } + + [Fact] + public void Rewrites_File_When_Content_Changes() + { + var properties = new Dictionary + { + ["build_property.EntityLengthsResolvedOutputPath"] = OutputPath, + ["build_property.EntityLengthsOutputNamespace"] = "MyApp.Domain", + }; + + Run(properties); + Assert.Contains("NameLength = 50", File.ReadAllText(OutputPath)); + + TestDriver.Run( + TestCompilation.Create(EntityWithLength(120)), + properties + ); + + Assert.Contains("NameLength = 120", File.ReadAllText(OutputPath)); + } + + [Fact] + public void Reports_ELG0001_Instead_Of_Throwing_When_Path_Is_Unusable() + { + // A file where a directory is expected makes directory creation fail. + Directory.CreateDirectory(_directory); + var blocker = Path.Combine(_directory, "Generated"); + File.WriteAllText(blocker, "not a directory"); + + var result = Run( + new Dictionary + { + ["build_property.EntityLengthsResolvedOutputPath"] = OutputPath, + ["build_property.EntityLengthsOutputNamespace"] = "MyApp.Domain", + } + ); + + Assert.Contains(result.Diagnostics, d => d.Id == "ELG0001"); + Assert.Contains( + result.Diagnostics, + d => d.Severity == DiagnosticSeverity.Warning && d.Id == "ELG0001" + ); + } + + private GeneratorDriverRunResult Run(Dictionary properties) => + TestDriver.Run(TestCompilation.Create(EntityWithLength(50)), properties); + + private static string EntityWithLength(int length) => + $@" +using System.ComponentModel.DataAnnotations; + +namespace TestNamespace; + +public class User +{{ + [MaxLength({length})] + public string Name {{ get; set; }} = string.Empty; +}}"; +} From 269bb7adb83eca5497bb5efe89d06de8b3e887ba Mon Sep 17 00:00:00 2001 From: Taras Kovalenko Date: Sun, 26 Jul 2026 11:54:22 +0300 Subject: [PATCH 2/4] build: central package management, net8-10 support, clean architecture sample Package versions move to Directory.Packages.props. EF Core is declared per target framework so multi-targeted projects build against the EF line that matches the framework (8.0.29 / 9.0.18 / 10.0.10) instead of only the newest. Directory.Build.props holds $(SupportedTargetFrameworks) = net8.0;net9.0;net10.0, used by the tests and both samples, so adding a framework is a one-line change. CI installs all three runtimes. The generator itself stays on netstandard2.0, which is what Roslyn loads analyzers from. Adds samples/CleanArchitecture, a three-project sample for the new EntityLengthsOutputPath feature: Domain holds plain entities and the committed Generated/EntityLengths.cs, Infrastructure holds the EF configurations and the output path settings, Api consumes the constants through Domain. Verified that a clean checkout builds in one pass and that the committed file is byte for byte what the generator produces. Two fixes found while wiring this up: - The writer used a fixed ".tmp" temporary file, which collides when the inner builds of a multi-targeted project write the same output in parallel. The temporary name is now unique per write. - Dropped PublishAot from the single-project sample: EF Core is not AOT or trim safe and it only produced IL2026/IL3050 warnings. --- .github/workflows/dotnet.yml | 6 +- Directory.Build.props | 11 +++ Directory.Packages.props | 38 +++++++++ EntityLengths.Generator.sln | 78 +++++++++++++++++++ README.md | 8 ++ global.json | 2 +- .../MyApp.Api/MyApp.Api.csproj | 17 ++++ .../CleanArchitecture/MyApp.Api/Program.cs | 23 ++++++ .../MyApp.Domain/Entities/Customer.cs | 16 ++++ .../MyApp.Domain/Entities/Order.cs | 10 +++ .../MyApp.Domain/Generated/EntityLengths.cs | 18 +++++ .../MyApp.Domain/MyApp.Domain.csproj | 16 ++++ .../Validation/CustomerValidator.cs | 32 ++++++++ .../MyApp.Infrastructure.csproj | 45 +++++++++++ .../Persistence/AppDbContext.cs | 18 +++++ .../Configurations/CustomerConfiguration.cs | 21 +++++ .../Configurations/OrderConfiguration.cs | 16 ++++ samples/CleanArchitecture/README.md | 65 ++++++++++++++++ .../EntityLengths.Generator.Sample.csproj | 7 +- .../Emit/GeneratedFileWriter.cs | 4 +- .../EntityLengths.Generator.csproj | 5 +- src/EntityLengths.Generator/docs/README.md | 8 ++ .../EntityLengths.Generator.Tests.csproj | 14 ++-- 23 files changed, 463 insertions(+), 15 deletions(-) create mode 100644 Directory.Build.props create mode 100644 Directory.Packages.props create mode 100644 samples/CleanArchitecture/MyApp.Api/MyApp.Api.csproj create mode 100644 samples/CleanArchitecture/MyApp.Api/Program.cs create mode 100644 samples/CleanArchitecture/MyApp.Domain/Entities/Customer.cs create mode 100644 samples/CleanArchitecture/MyApp.Domain/Entities/Order.cs create mode 100644 samples/CleanArchitecture/MyApp.Domain/Generated/EntityLengths.cs create mode 100644 samples/CleanArchitecture/MyApp.Domain/MyApp.Domain.csproj create mode 100644 samples/CleanArchitecture/MyApp.Domain/Validation/CustomerValidator.cs create mode 100644 samples/CleanArchitecture/MyApp.Infrastructure/MyApp.Infrastructure.csproj create mode 100644 samples/CleanArchitecture/MyApp.Infrastructure/Persistence/AppDbContext.cs create mode 100644 samples/CleanArchitecture/MyApp.Infrastructure/Persistence/Configurations/CustomerConfiguration.cs create mode 100644 samples/CleanArchitecture/MyApp.Infrastructure/Persistence/Configurations/OrderConfiguration.cs create mode 100644 samples/CleanArchitecture/README.md diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 7fcd6dc..6236000 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -19,7 +19,11 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 9.0.x + # The tests and samples multi-target net8.0-net10.0, so every runtime must be present. + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x - name: Restore dependencies run: dotnet restore - name: Build diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..dc87160 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,11 @@ + + + + + net8.0;net9.0;net10.0 + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..d1771d1 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,38 @@ + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EntityLengths.Generator.sln b/EntityLengths.Generator.sln index ca9fa63..94a1583 100644 --- a/EntityLengths.Generator.sln +++ b/EntityLengths.Generator.sln @@ -10,28 +10,106 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityLengths.Generator.Tes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityLengths.Generator.Sample", "src\EntityLengths.Generator.Sample\EntityLengths.Generator.Sample.csproj", "{BB1E9BFB-AAC4-4539-884F-A98BAFCA75BE}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyApp.Domain", "samples\CleanArchitecture\MyApp.Domain\MyApp.Domain.csproj", "{8730DF44-3CD8-4AAC-8121-9A3D688AD1C0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyApp.Infrastructure", "samples\CleanArchitecture\MyApp.Infrastructure\MyApp.Infrastructure.csproj", "{114FC924-E94B-4E08-9712-D7427E85FD72}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyApp.Api", "samples\CleanArchitecture\MyApp.Api\MyApp.Api.csproj", "{228539D1-6D4B-4FEF-81B6-C097315848E9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {F7847EAA-4D47-4180-9C86-0F1738A45573}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F7847EAA-4D47-4180-9C86-0F1738A45573}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F7847EAA-4D47-4180-9C86-0F1738A45573}.Debug|x64.ActiveCfg = Debug|Any CPU + {F7847EAA-4D47-4180-9C86-0F1738A45573}.Debug|x64.Build.0 = Debug|Any CPU + {F7847EAA-4D47-4180-9C86-0F1738A45573}.Debug|x86.ActiveCfg = Debug|Any CPU + {F7847EAA-4D47-4180-9C86-0F1738A45573}.Debug|x86.Build.0 = Debug|Any CPU {F7847EAA-4D47-4180-9C86-0F1738A45573}.Release|Any CPU.ActiveCfg = Release|Any CPU {F7847EAA-4D47-4180-9C86-0F1738A45573}.Release|Any CPU.Build.0 = Release|Any CPU + {F7847EAA-4D47-4180-9C86-0F1738A45573}.Release|x64.ActiveCfg = Release|Any CPU + {F7847EAA-4D47-4180-9C86-0F1738A45573}.Release|x64.Build.0 = Release|Any CPU + {F7847EAA-4D47-4180-9C86-0F1738A45573}.Release|x86.ActiveCfg = Release|Any CPU + {F7847EAA-4D47-4180-9C86-0F1738A45573}.Release|x86.Build.0 = Release|Any CPU {71E8CAA4-F0B9-4066-8DF8-078E1B9CC476}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {71E8CAA4-F0B9-4066-8DF8-078E1B9CC476}.Debug|Any CPU.Build.0 = Debug|Any CPU + {71E8CAA4-F0B9-4066-8DF8-078E1B9CC476}.Debug|x64.ActiveCfg = Debug|Any CPU + {71E8CAA4-F0B9-4066-8DF8-078E1B9CC476}.Debug|x64.Build.0 = Debug|Any CPU + {71E8CAA4-F0B9-4066-8DF8-078E1B9CC476}.Debug|x86.ActiveCfg = Debug|Any CPU + {71E8CAA4-F0B9-4066-8DF8-078E1B9CC476}.Debug|x86.Build.0 = Debug|Any CPU {71E8CAA4-F0B9-4066-8DF8-078E1B9CC476}.Release|Any CPU.ActiveCfg = Release|Any CPU {71E8CAA4-F0B9-4066-8DF8-078E1B9CC476}.Release|Any CPU.Build.0 = Release|Any CPU + {71E8CAA4-F0B9-4066-8DF8-078E1B9CC476}.Release|x64.ActiveCfg = Release|Any CPU + {71E8CAA4-F0B9-4066-8DF8-078E1B9CC476}.Release|x64.Build.0 = Release|Any CPU + {71E8CAA4-F0B9-4066-8DF8-078E1B9CC476}.Release|x86.ActiveCfg = Release|Any CPU + {71E8CAA4-F0B9-4066-8DF8-078E1B9CC476}.Release|x86.Build.0 = Release|Any CPU {BB1E9BFB-AAC4-4539-884F-A98BAFCA75BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {BB1E9BFB-AAC4-4539-884F-A98BAFCA75BE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BB1E9BFB-AAC4-4539-884F-A98BAFCA75BE}.Debug|x64.ActiveCfg = Debug|Any CPU + {BB1E9BFB-AAC4-4539-884F-A98BAFCA75BE}.Debug|x64.Build.0 = Debug|Any CPU + {BB1E9BFB-AAC4-4539-884F-A98BAFCA75BE}.Debug|x86.ActiveCfg = Debug|Any CPU + {BB1E9BFB-AAC4-4539-884F-A98BAFCA75BE}.Debug|x86.Build.0 = Debug|Any CPU {BB1E9BFB-AAC4-4539-884F-A98BAFCA75BE}.Release|Any CPU.ActiveCfg = Release|Any CPU {BB1E9BFB-AAC4-4539-884F-A98BAFCA75BE}.Release|Any CPU.Build.0 = Release|Any CPU + {BB1E9BFB-AAC4-4539-884F-A98BAFCA75BE}.Release|x64.ActiveCfg = Release|Any CPU + {BB1E9BFB-AAC4-4539-884F-A98BAFCA75BE}.Release|x64.Build.0 = Release|Any CPU + {BB1E9BFB-AAC4-4539-884F-A98BAFCA75BE}.Release|x86.ActiveCfg = Release|Any CPU + {BB1E9BFB-AAC4-4539-884F-A98BAFCA75BE}.Release|x86.Build.0 = Release|Any CPU + {8730DF44-3CD8-4AAC-8121-9A3D688AD1C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8730DF44-3CD8-4AAC-8121-9A3D688AD1C0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8730DF44-3CD8-4AAC-8121-9A3D688AD1C0}.Debug|x64.ActiveCfg = Debug|Any CPU + {8730DF44-3CD8-4AAC-8121-9A3D688AD1C0}.Debug|x64.Build.0 = Debug|Any CPU + {8730DF44-3CD8-4AAC-8121-9A3D688AD1C0}.Debug|x86.ActiveCfg = Debug|Any CPU + {8730DF44-3CD8-4AAC-8121-9A3D688AD1C0}.Debug|x86.Build.0 = Debug|Any CPU + {8730DF44-3CD8-4AAC-8121-9A3D688AD1C0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8730DF44-3CD8-4AAC-8121-9A3D688AD1C0}.Release|Any CPU.Build.0 = Release|Any CPU + {8730DF44-3CD8-4AAC-8121-9A3D688AD1C0}.Release|x64.ActiveCfg = Release|Any CPU + {8730DF44-3CD8-4AAC-8121-9A3D688AD1C0}.Release|x64.Build.0 = Release|Any CPU + {8730DF44-3CD8-4AAC-8121-9A3D688AD1C0}.Release|x86.ActiveCfg = Release|Any CPU + {8730DF44-3CD8-4AAC-8121-9A3D688AD1C0}.Release|x86.Build.0 = Release|Any CPU + {114FC924-E94B-4E08-9712-D7427E85FD72}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {114FC924-E94B-4E08-9712-D7427E85FD72}.Debug|Any CPU.Build.0 = Debug|Any CPU + {114FC924-E94B-4E08-9712-D7427E85FD72}.Debug|x64.ActiveCfg = Debug|Any CPU + {114FC924-E94B-4E08-9712-D7427E85FD72}.Debug|x64.Build.0 = Debug|Any CPU + {114FC924-E94B-4E08-9712-D7427E85FD72}.Debug|x86.ActiveCfg = Debug|Any CPU + {114FC924-E94B-4E08-9712-D7427E85FD72}.Debug|x86.Build.0 = Debug|Any CPU + {114FC924-E94B-4E08-9712-D7427E85FD72}.Release|Any CPU.ActiveCfg = Release|Any CPU + {114FC924-E94B-4E08-9712-D7427E85FD72}.Release|Any CPU.Build.0 = Release|Any CPU + {114FC924-E94B-4E08-9712-D7427E85FD72}.Release|x64.ActiveCfg = Release|Any CPU + {114FC924-E94B-4E08-9712-D7427E85FD72}.Release|x64.Build.0 = Release|Any CPU + {114FC924-E94B-4E08-9712-D7427E85FD72}.Release|x86.ActiveCfg = Release|Any CPU + {114FC924-E94B-4E08-9712-D7427E85FD72}.Release|x86.Build.0 = Release|Any CPU + {228539D1-6D4B-4FEF-81B6-C097315848E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {228539D1-6D4B-4FEF-81B6-C097315848E9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {228539D1-6D4B-4FEF-81B6-C097315848E9}.Debug|x64.ActiveCfg = Debug|Any CPU + {228539D1-6D4B-4FEF-81B6-C097315848E9}.Debug|x64.Build.0 = Debug|Any CPU + {228539D1-6D4B-4FEF-81B6-C097315848E9}.Debug|x86.ActiveCfg = Debug|Any CPU + {228539D1-6D4B-4FEF-81B6-C097315848E9}.Debug|x86.Build.0 = Debug|Any CPU + {228539D1-6D4B-4FEF-81B6-C097315848E9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {228539D1-6D4B-4FEF-81B6-C097315848E9}.Release|Any CPU.Build.0 = Release|Any CPU + {228539D1-6D4B-4FEF-81B6-C097315848E9}.Release|x64.ActiveCfg = Release|Any CPU + {228539D1-6D4B-4FEF-81B6-C097315848E9}.Release|x64.Build.0 = Release|Any CPU + {228539D1-6D4B-4FEF-81B6-C097315848E9}.Release|x86.ActiveCfg = Release|Any CPU + {228539D1-6D4B-4FEF-81B6-C097315848E9}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {F7847EAA-4D47-4180-9C86-0F1738A45573} = {D9D6AABE-DCD5-495B-BB51-1AF62B40444D} {71E8CAA4-F0B9-4066-8DF8-078E1B9CC476} = {6DF2B2EA-130F-427B-A61D-E153B5D938BC} {BB1E9BFB-AAC4-4539-884F-A98BAFCA75BE} = {D9D6AABE-DCD5-495B-BB51-1AF62B40444D} + {8730DF44-3CD8-4AAC-8121-9A3D688AD1C0} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + {114FC924-E94B-4E08-9712-D7427E85FD72} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + {228539D1-6D4B-4FEF-81B6-C097315848E9} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index fd274f8..689d772 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,12 @@ To learn more about the war and how you can help, [click here](https://war.ukrai - Can write the constants into another project, for Clean Architecture and similar layouts (see [Writing the constants into another project](#writing-the-constants-into-another-project-clean-architecture)) +## Supported frameworks + +The generator ships as a `netstandard2.0` analyzer, which is what Roslyn loads, and works in projects +targeting **.NET 8, .NET 9 and .NET 10**. Tests and samples are built and run against all three, each +with the matching EF Core line (8.0.x, 9.0.x, 10.0.x). + ## Installation Install the library via NuGet Package Manager: @@ -185,6 +191,8 @@ Notes: - The file is written during compilation, so `Domain` picks up changes on the **next** build. Commit the generated file and treat it as checked-in generated code. +- A runnable end-to-end example is in + [`samples/CleanArchitecture`](samples/CleanArchitecture/README.md) (Domain / Infrastructure / Api). - The file is only touched when its content actually changes, so editing in an IDE does not churn it. - Fluent API and `OnModelCreating` lengths are read from source, so the generator must run in the project that contains those configurations - it cannot read them from a referenced assembly. diff --git a/global.json b/global.json index 93681ff..90e5a42 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "9.0.0", + "version": "10.0.0", "rollForward": "latestMinor", "allowPrerelease": false } diff --git a/samples/CleanArchitecture/MyApp.Api/MyApp.Api.csproj b/samples/CleanArchitecture/MyApp.Api/MyApp.Api.csproj new file mode 100644 index 0000000..83fc2c0 --- /dev/null +++ b/samples/CleanArchitecture/MyApp.Api/MyApp.Api.csproj @@ -0,0 +1,17 @@ + + + + Exe + $(SupportedTargetFrameworks) + enable + enable + MyApp.Api + + + + + + + + + diff --git a/samples/CleanArchitecture/MyApp.Api/Program.cs b/samples/CleanArchitecture/MyApp.Api/Program.cs new file mode 100644 index 0000000..36667f9 --- /dev/null +++ b/samples/CleanArchitecture/MyApp.Api/Program.cs @@ -0,0 +1,23 @@ +using MyApp.Domain; +using MyApp.Domain.Entities; +using MyApp.Domain.Validation; + +// The constants come from MyApp.Domain, even though the lengths are configured by the EF +// configurations in MyApp.Infrastructure, which Domain does not reference. +Console.WriteLine($"Customer.Name max {EntityLengths.Customer.NameLength}"); +Console.WriteLine($"Customer.Email max {EntityLengths.Customer.EmailLength}"); +Console.WriteLine($"Customer.Notes max {EntityLengths.Customer.NotesLength}"); +Console.WriteLine($"Order.Reference max {EntityLengths.Order.ReferenceLength}"); +Console.WriteLine($"Order.ShippingAddr max {EntityLengths.Order.ShippingAddressLength}"); + +var customer = new Customer +{ + Id = Guid.NewGuid(), + Name = new string('x', EntityLengths.Customer.NameLength + 1), + Email = "customer@example.com", +}; + +foreach (var error in CustomerValidator.Validate(customer)) +{ + Console.WriteLine($"invalid: {error}"); +} diff --git a/samples/CleanArchitecture/MyApp.Domain/Entities/Customer.cs b/samples/CleanArchitecture/MyApp.Domain/Entities/Customer.cs new file mode 100644 index 0000000..612ffd6 --- /dev/null +++ b/samples/CleanArchitecture/MyApp.Domain/Entities/Customer.cs @@ -0,0 +1,16 @@ +namespace MyApp.Domain.Entities; + +/// +/// Plain domain entity: no data annotations, no EF Core reference. The lengths live in the EF +/// configuration in MyApp.Infrastructure. +/// +public class Customer +{ + public Guid Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public string Email { get; set; } = string.Empty; + + public string? Notes { get; set; } +} diff --git a/samples/CleanArchitecture/MyApp.Domain/Entities/Order.cs b/samples/CleanArchitecture/MyApp.Domain/Entities/Order.cs new file mode 100644 index 0000000..3f8e093 --- /dev/null +++ b/samples/CleanArchitecture/MyApp.Domain/Entities/Order.cs @@ -0,0 +1,10 @@ +namespace MyApp.Domain.Entities; + +public record Order +{ + public Guid Id { get; init; } + + public string Reference { get; init; } = string.Empty; + + public string ShippingAddress { get; init; } = string.Empty; +} diff --git a/samples/CleanArchitecture/MyApp.Domain/Generated/EntityLengths.cs b/samples/CleanArchitecture/MyApp.Domain/Generated/EntityLengths.cs new file mode 100644 index 0000000..f84f0bd --- /dev/null +++ b/samples/CleanArchitecture/MyApp.Domain/Generated/EntityLengths.cs @@ -0,0 +1,18 @@ +// +namespace MyApp.Domain; + +public static partial class EntityLengths +{ + public static partial class Customer + { + public const int EmailLength = 256; + public const int NameLength = 100; + public const int NotesLength = 2000; + } + + public static partial class Order + { + public const int ReferenceLength = 32; + public const int ShippingAddressLength = 400; + } +} diff --git a/samples/CleanArchitecture/MyApp.Domain/MyApp.Domain.csproj b/samples/CleanArchitecture/MyApp.Domain/MyApp.Domain.csproj new file mode 100644 index 0000000..81793d5 --- /dev/null +++ b/samples/CleanArchitecture/MyApp.Domain/MyApp.Domain.csproj @@ -0,0 +1,16 @@ + + + + $(SupportedTargetFrameworks) + enable + enable + MyApp.Domain + + + + + diff --git a/samples/CleanArchitecture/MyApp.Domain/Validation/CustomerValidator.cs b/samples/CleanArchitecture/MyApp.Domain/Validation/CustomerValidator.cs new file mode 100644 index 0000000..c01e372 --- /dev/null +++ b/samples/CleanArchitecture/MyApp.Domain/Validation/CustomerValidator.cs @@ -0,0 +1,32 @@ +using MyApp.Domain.Entities; + +namespace MyApp.Domain.Validation; + +/// +/// Domain validation using the generated constants. The lengths are configured once, in the EF +/// configuration, and no number is repeated here. +/// +public static class CustomerValidator +{ + public static IReadOnlyList Validate(Customer customer) + { + var errors = new List(); + + if (customer.Name.Length is 0 or > EntityLengths.Customer.NameLength) + { + errors.Add($"Name must be 1 to {EntityLengths.Customer.NameLength} characters."); + } + + if (customer.Email.Length > EntityLengths.Customer.EmailLength) + { + errors.Add($"Email must be at most {EntityLengths.Customer.EmailLength} characters."); + } + + if (customer.Notes?.Length > EntityLengths.Customer.NotesLength) + { + errors.Add($"Notes must be at most {EntityLengths.Customer.NotesLength} characters."); + } + + return errors; + } +} diff --git a/samples/CleanArchitecture/MyApp.Infrastructure/MyApp.Infrastructure.csproj b/samples/CleanArchitecture/MyApp.Infrastructure/MyApp.Infrastructure.csproj new file mode 100644 index 0000000..28c1800 --- /dev/null +++ b/samples/CleanArchitecture/MyApp.Infrastructure/MyApp.Infrastructure.csproj @@ -0,0 +1,45 @@ + + + + $(SupportedTargetFrameworks) + enable + enable + MyApp.Infrastructure + + + + + ../MyApp.Domain/Generated/EntityLengths.cs + MyApp.Domain + + + + + + + + + + + + + + + + + + + + diff --git a/samples/CleanArchitecture/MyApp.Infrastructure/Persistence/AppDbContext.cs b/samples/CleanArchitecture/MyApp.Infrastructure/Persistence/AppDbContext.cs new file mode 100644 index 0000000..1e8433b --- /dev/null +++ b/samples/CleanArchitecture/MyApp.Infrastructure/Persistence/AppDbContext.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using MyApp.Domain.Entities; +using MyApp.Infrastructure.Persistence.Configurations; + +namespace MyApp.Infrastructure.Persistence; + +public class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Customers => Set(); + + public DbSet Orders => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfiguration(new CustomerConfiguration()); + modelBuilder.ApplyConfiguration(new OrderConfiguration()); + } +} diff --git a/samples/CleanArchitecture/MyApp.Infrastructure/Persistence/Configurations/CustomerConfiguration.cs b/samples/CleanArchitecture/MyApp.Infrastructure/Persistence/Configurations/CustomerConfiguration.cs new file mode 100644 index 0000000..3bb7bde --- /dev/null +++ b/samples/CleanArchitecture/MyApp.Infrastructure/Persistence/Configurations/CustomerConfiguration.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MyApp.Domain.Entities; + +namespace MyApp.Infrastructure.Persistence.Configurations; + +/// +/// Single source of truth for the Customer string lengths. The generator reads these calls and writes +/// the matching constants into MyApp.Domain. +/// +public class CustomerConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(c => c.Id); + + builder.Property(c => c.Name).HasMaxLength(100).IsRequired(); + builder.Property(c => c.Email).HasMaxLength(256).IsRequired(); + builder.Property(c => c.Notes).HasMaxLength(2000); + } +} diff --git a/samples/CleanArchitecture/MyApp.Infrastructure/Persistence/Configurations/OrderConfiguration.cs b/samples/CleanArchitecture/MyApp.Infrastructure/Persistence/Configurations/OrderConfiguration.cs new file mode 100644 index 0000000..be01943 --- /dev/null +++ b/samples/CleanArchitecture/MyApp.Infrastructure/Persistence/Configurations/OrderConfiguration.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MyApp.Domain.Entities; + +namespace MyApp.Infrastructure.Persistence.Configurations; + +public class OrderConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(o => o.Id); + + builder.Property(o => o.Reference).HasMaxLength(32).IsRequired(); + builder.Property(o => o.ShippingAddress).HasMaxLength(400).IsRequired(); + } +} diff --git a/samples/CleanArchitecture/README.md b/samples/CleanArchitecture/README.md new file mode 100644 index 0000000..23d68a5 --- /dev/null +++ b/samples/CleanArchitecture/README.md @@ -0,0 +1,65 @@ +# Clean Architecture sample + +Shows `EntityLengthsOutputPath`: the EF configurations live in the Infrastructure layer, but the +generated constants end up in the Domain layer, which does not reference Infrastructure. + +``` +MyApp.Domain no EF Core, no generator, no Infrastructure reference +├── Entities/ plain POCOs (Customer, Order) +├── Validation/ uses EntityLengths.Customer.NameLength +└── Generated/ <- EntityLengths.cs is written here and committed + +MyApp.Infrastructure references Domain + EF Core + the generator +└── Persistence/ AppDbContext and IEntityTypeConfiguration classes (the lengths) + +MyApp.Api composition root, references Domain + Infrastructure +``` + +The relevant part of `MyApp.Infrastructure.csproj`: + +```xml + + ../MyApp.Domain/Generated/EntityLengths.cs + MyApp.Domain + +``` + +Because an output path is set, the generator does **not** add the constants to +`MyApp.Infrastructure` as well - otherwise `MyApp.Domain.EntityLengths` would exist in two +assemblies. Infrastructure sees them through its Domain reference. + +## Running it + +```bash +dotnet run --project samples/CleanArchitecture/MyApp.Api --framework net9.0 +``` + +Output: + +``` +Customer.Name max 100 +Customer.Email max 256 +Customer.Notes max 2000 +Order.Reference max 32 +Order.ShippingAddr max 400 +invalid: Name must be 1 to 100 characters. +``` + +## Changing a length + +Edit `CustomerConfiguration.Configure`, for example `HasMaxLength(100)` to `HasMaxLength(120)`, then +build twice: + +1. The first build writes the new `Generated/EntityLengths.cs` while Infrastructure compiles - too + late for the Domain compilation that already ran in the same build. +2. The second build compiles Domain against the new value. + +That is why the file is committed: a clean clone builds in one pass, and a length change shows up in +a reviewable diff. Treat it as checked-in generated code and never edit it by hand. + +## Multi-targeting + +All three projects build for `net8.0`, `net9.0` and `net10.0` (see `$(SupportedTargetFrameworks)` in +`Directory.Build.props`), with the EF Core version following the target framework. The inner builds +run in parallel and all write the same constants file; the generator only rewrites it when the +content actually changes. diff --git a/src/EntityLengths.Generator.Sample/EntityLengths.Generator.Sample.csproj b/src/EntityLengths.Generator.Sample/EntityLengths.Generator.Sample.csproj index 4361f68..d20336b 100644 --- a/src/EntityLengths.Generator.Sample/EntityLengths.Generator.Sample.csproj +++ b/src/EntityLengths.Generator.Sample/EntityLengths.Generator.Sample.csproj @@ -2,10 +2,10 @@ Exe - net8.0 + $(SupportedTargetFrameworks) enable enable - true + true EntityLengths.Generator.Sample @@ -17,6 +17,7 @@ - + + diff --git a/src/EntityLengths.Generator/Emit/GeneratedFileWriter.cs b/src/EntityLengths.Generator/Emit/GeneratedFileWriter.cs index c697025..9bd0a1b 100644 --- a/src/EntityLengths.Generator/Emit/GeneratedFileWriter.cs +++ b/src/EntityLengths.Generator/Emit/GeneratedFileWriter.cs @@ -39,7 +39,9 @@ public static bool TryWrite(string path, string content, out string? error) return true; } - var temporaryPath = path + ".tmp"; + // Unique per write: a multi-targeted project runs one inner build per target framework in + // parallel, and they all write the same output path. + var temporaryPath = $"{path}.{Guid.NewGuid():N}.tmp"; File.WriteAllText(temporaryPath, content, Utf8NoBom); File.Copy(temporaryPath, path, overwrite: true); File.Delete(temporaryPath); diff --git a/src/EntityLengths.Generator/EntityLengths.Generator.csproj b/src/EntityLengths.Generator/EntityLengths.Generator.csproj index 768d798..f4e21b4 100644 --- a/src/EntityLengths.Generator/EntityLengths.Generator.csproj +++ b/src/EntityLengths.Generator/EntityLengths.Generator.csproj @@ -34,12 +34,13 @@ - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/EntityLengths.Generator/docs/README.md b/src/EntityLengths.Generator/docs/README.md index fd274f8..689d772 100644 --- a/src/EntityLengths.Generator/docs/README.md +++ b/src/EntityLengths.Generator/docs/README.md @@ -43,6 +43,12 @@ To learn more about the war and how you can help, [click here](https://war.ukrai - Can write the constants into another project, for Clean Architecture and similar layouts (see [Writing the constants into another project](#writing-the-constants-into-another-project-clean-architecture)) +## Supported frameworks + +The generator ships as a `netstandard2.0` analyzer, which is what Roslyn loads, and works in projects +targeting **.NET 8, .NET 9 and .NET 10**. Tests and samples are built and run against all three, each +with the matching EF Core line (8.0.x, 9.0.x, 10.0.x). + ## Installation Install the library via NuGet Package Manager: @@ -185,6 +191,8 @@ Notes: - The file is written during compilation, so `Domain` picks up changes on the **next** build. Commit the generated file and treat it as checked-in generated code. +- A runnable end-to-end example is in + [`samples/CleanArchitecture`](samples/CleanArchitecture/README.md) (Domain / Infrastructure / Api). - The file is only touched when its content actually changes, so editing in an IDE does not churn it. - Fluent API and `OnModelCreating` lengths are read from source, so the generator must run in the project that contains those configurations - it cannot read them from a referenced assembly. diff --git a/tests/EntityLengths.Generator.Tests/EntityLengths.Generator.Tests.csproj b/tests/EntityLengths.Generator.Tests/EntityLengths.Generator.Tests.csproj index 0663f3e..48faaf3 100644 --- a/tests/EntityLengths.Generator.Tests/EntityLengths.Generator.Tests.csproj +++ b/tests/EntityLengths.Generator.Tests/EntityLengths.Generator.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + $(SupportedTargetFrameworks) enable false @@ -10,18 +10,18 @@ - - - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + - From 70625d7d2e84a8f2f0225513c5c3414848f16503 Mon Sep 17 00:00:00 2001 From: Taras Kovalenko Date: Sun, 26 Jul 2026 12:02:54 +0300 Subject: [PATCH 3/4] docs: correct the README examples and fix what checking them uncovered The Usage example claimed an output that declared SurnameLength twice, which would not compile, and listed a 200 that no example configured. It is now one entity whose five properties each use a different mechanism, matching the real output including its alphabetical ordering, and a test locks that block so it cannot drift again. The sample output at the end of the README was also missing constants the sample actually generates. Adds a Samples section, a note that conflicting lengths are reported as ELG0004, and a Building this repository section covering central package management and $(SupportedTargetFrameworks). Verifying the README against a real build exposed two defects: - ConstantsSourceBuilder emitted one literal "\r\n" while every other line went through AppendLine, so the file had mixed line endings. For a file that is meant to be committed that means it shows up as permanently modified on another platform. All lines now go through AppendLine, and .gitattributes normalizes the repository. - The parallel inner builds of a multi-targeted project raced on the destination copy, not just the temporary file, and reported a spurious ELG0001. Writes now retry and are accepted when the file already holds the wanted content, which is the normal outcome of that race. Covered by a test that writes the same path from eight threads. --- .gitattributes | 12 ++ README.md | 87 ++++++++++---- .../Emit/ConstantsSourceBuilder.cs | 7 +- .../Emit/GeneratedFileWriter.cs | 111 +++++++++++++----- src/EntityLengths.Generator/docs/README.md | 87 ++++++++++---- .../GeneratorBehaviorTests.cs | 75 ++++++++++++ .../OutputPathTests.cs | 27 +++++ 7 files changed, 319 insertions(+), 87 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8c905c5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# Normalize line endings so files written by the generator and committed to a repository +# (see EntityLengthsOutputPath) do not show up as modified on a different platform. +* text=auto + +*.cs text eol=lf +*.csproj text eol=lf +*.props text eol=lf +*.targets text eol=lf +*.md text eol=lf +*.json text eol=lf +*.yml text eol=lf +*.sln text eol=crlf diff --git a/README.md b/README.md index 689d772..28a702f 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,14 @@ To learn more about the war and how you can help, [click here](https://war.ukrai - Can write the constants into another project, for Clean Architecture and similar layouts (see [Writing the constants into another project](#writing-the-constants-into-another-project-clean-architecture)) +## Samples + +- [`samples/CleanArchitecture`](samples/CleanArchitecture/README.md) - three projects + (Domain / Infrastructure / Api) showing the constants generated into a different layer than the one + holding the EF configurations. +- [`src/EntityLengths.Generator.Sample`](src/EntityLengths.Generator.Sample) - single project showing + every supported way to declare a length. + ## Supported frameworks The generator ships as a `netstandard2.0` analyzer, which is what Roslyn loads, and works in projects @@ -59,28 +67,29 @@ dotnet add package EntityLengths.Generator ## Usage -The generator supports a few ways to define string lengths: +The generator supports a few ways to define string lengths, and combines everything it finds for one +entity into a single nested class: ```csharp -// Using MaxLength attribute public class User { + // Using MaxLength attribute [MaxLength(50)] - public string Name { get; set; } -} + public required string Name { get; set; } -// Using StringLength attribute -public class User -{ - [StringLength(50)] - public string Surname { get; set; } -} + // Using StringLength attribute + [StringLength(150)] + public required string Surname { get; set; } -// Using Column attribute -public class User -{ + // Using Column attribute [Column(TypeName = "varchar(200)")] - public string Url { get; set; } + public required string Url { get; set; } + + // Configured by the Fluent API below + public required string Description { get; set; } + + // Configured in OnModelCreating below + public required string Code { get; set; } } // Using Fluent API @@ -88,17 +97,12 @@ public class UserConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { - builder.Property(p => p.Name) - .HasMaxLength(50); + builder.Property(p => p.Description) + .HasMaxLength(500); } } // DbContext configuration -public class User -{ - public required string Surname { get; set; } -} - public class UserDbContext : DbContext { public DbSet Users { get; set; } = null!; @@ -106,32 +110,36 @@ public class UserDbContext : DbContext protected override void OnModelCreating(ModelBuilder modelBuilder) { // fluent API style - modelBuilder.Entity().Property(b => b.Surname).HasMaxLength(150).IsRequired(); + modelBuilder.Entity().Property(b => b.Code).HasMaxLength(20).IsRequired(); // or lambda API modelBuilder.Entity(entity => { - entity.Property(e => e.Surname).HasMaxLength(150).IsRequired(); + entity.Property(e => e.Code).HasMaxLength(20).IsRequired(); }); } } ``` -Generated output: +Generated output (entities and properties are sorted by name): ```csharp public static partial class EntityLengths { public static partial class User { + public const int CodeLength = 20; + public const int DescriptionLength = 500; public const int NameLength = 50; - public const int SurnameLength = 50; + public const int SurnameLength = 150; public const int UrlLength = 200; - public const int SurnameLength = 200; } } ``` +Configuring the same property twice with different lengths, for example `[MaxLength(50)]` plus a +`HasMaxLength(100)` in the Fluent API, is reported as `ELG0004` instead of being silently resolved. + ## Configuration There are ways to configure EntityLengths.Generator. Configuration values are needed during compile-time since this is a source generator: @@ -256,10 +264,22 @@ public static partial class Constants /// public static partial class DbContextUser { + /// + /// Maximum length for Description + /// + public const int DescriptionLength = 500; + /// + /// Maximum length for Description2 + /// + public const int Description2Length = 500; /// /// Maximum length for Name /// public const int NameLength = 50; + /// + /// Maximum length for Name2 + /// + public const int Name2Length = 50; } /// @@ -267,6 +287,10 @@ public static partial class Constants /// public static partial class FluentUser { + /// + /// Maximum length for Description + /// + public const int DescriptionLength = 500; /// /// Maximum length for Name /// @@ -274,3 +298,14 @@ public static partial class Constants } } ``` + +## Building this repository + +```bash +dotnet build # generator, tests and both samples, for net8.0 / net9.0 / net10.0 +dotnet test +``` + +Package versions are managed centrally in `Directory.Packages.props`, so a `PackageReference` in a +project file must not carry a `Version` attribute. The target frameworks come from +`$(SupportedTargetFrameworks)` in `Directory.Build.props`. diff --git a/src/EntityLengths.Generator/Emit/ConstantsSourceBuilder.cs b/src/EntityLengths.Generator/Emit/ConstantsSourceBuilder.cs index 42300db..fcce30a 100644 --- a/src/EntityLengths.Generator/Emit/ConstantsSourceBuilder.cs +++ b/src/EntityLengths.Generator/Emit/ConstantsSourceBuilder.cs @@ -31,9 +31,10 @@ EntityLengthsGeneratorOptions options sourceBuilder.AppendLine("/// "); } - sourceBuilder.AppendLine( - $"public static partial class {options.GeneratedClassName} \r\n{{" - ); + // One AppendLine per line: an embedded "\r\n" here would mix line endings inside the file, + // which shows up as a permanently modified file once it is committed. + sourceBuilder.AppendLine($"public static partial class {options.GeneratedClassName} "); + sourceBuilder.AppendLine("{"); var isFirst = true; foreach (var entity in entities) diff --git a/src/EntityLengths.Generator/Emit/GeneratedFileWriter.cs b/src/EntityLengths.Generator/Emit/GeneratedFileWriter.cs index 9bd0a1b..7d77204 100644 --- a/src/EntityLengths.Generator/Emit/GeneratedFileWriter.cs +++ b/src/EntityLengths.Generator/Emit/GeneratedFileWriter.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Text; +using System.Threading; namespace EntityLengths.Generator.Emit; @@ -8,11 +9,12 @@ namespace EntityLengths.Generator.Emit; /// Writes the generated constants to a file outside the current project. /// /// -/// A generator normally must not touch the file system: it runs on every keystroke in the IDE and -/// once more during the build. Two mitigations keep that bearable - the file is only touched when its -/// content actually changes, and it is written through a temporary file so a concurrent reader never -/// sees a partial file. Any failure is reported as a diagnostic instead of throwing, because an -/// exception escaping a generator kills the whole compilation. +/// A generator normally must not touch the file system: it runs on every keystroke in the IDE and once +/// more per target framework during the build. Three mitigations keep that bearable - the file is only +/// touched when its content actually changes, it is written through a temporary file so a concurrent +/// reader never sees a partial file, and a write that loses a race against another writer is retried +/// and then accepted if that writer produced the same content. Any remaining failure is reported as a +/// diagnostic instead of throwing, because an exception escaping a generator kills the compilation. /// // RS1035: analyzers must not do file IO. Writing the constants outside the current project is the // whole point of the EntityLengthsOutputPath opt-in, and it is only reached when the user sets that @@ -20,60 +22,105 @@ namespace EntityLengths.Generator.Emit; #pragma warning disable RS1035 internal static class GeneratedFileWriter { + private const int MaxAttempts = 3; + private const int RetryDelayMilliseconds = 20; + private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); public static bool TryWrite(string path, string content, out string? error) { error = null; - try + for (var attempt = 1; ; attempt++) { - var directory = Path.GetDirectoryName(path); - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + try { - Directory.CreateDirectory(directory); - } + EnsureDirectory(path); - if (IsUpToDate(path, content)) - { + if (IsUpToDate(path, content)) + { + return true; + } + + Write(path, content); return true; } + catch (Exception exception) + when (exception is IOException + or UnauthorizedAccessException + or NotSupportedException + or ArgumentException + ) + { + // The inner builds of a multi-targeted project write the same path at the same time, + // so losing the race is expected. Accept it once the file holds the wanted content. + if (IsUpToDate(path, content)) + { + return true; + } + + if (attempt >= MaxAttempts) + { + error = exception.Message; + return false; + } + + Thread.Sleep(RetryDelayMilliseconds); + } + } + } + + private static void EnsureDirectory(string path) + { + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + } - // Unique per write: a multi-targeted project runs one inner build per target framework in - // parallel, and they all write the same output path. - var temporaryPath = $"{path}.{Guid.NewGuid():N}.tmp"; + private static void Write(string path, string content) + { + // Unique per write: a multi-targeted project runs one inner build per target framework in + // parallel, and they all write the same output path. + var temporaryPath = $"{path}.{Guid.NewGuid():N}.tmp"; + + try + { File.WriteAllText(temporaryPath, content, Utf8NoBom); File.Copy(temporaryPath, path, overwrite: true); - File.Delete(temporaryPath); - - return true; } - catch (Exception exception) - when (exception is IOException - or UnauthorizedAccessException - or NotSupportedException - or ArgumentException - ) + finally { - error = exception.Message; - return false; + TryDelete(temporaryPath); } } - private static bool IsUpToDate(string path, string content) + private static void TryDelete(string path) { - if (!File.Exists(path)) + try { - return false; + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + // A leftover temporary file is not worth failing or warning about. } + } + private static bool IsUpToDate(string path, string content) + { try { - return string.Equals(File.ReadAllText(path), content, StringComparison.Ordinal); + return File.Exists(path) + && string.Equals(File.ReadAllText(path), content, StringComparison.Ordinal); } - catch (IOException) + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { - // The file is locked by another writer; treat it as stale and let the write attempt report. + // The file is locked by another writer; treat it as stale and let the caller retry. return false; } } diff --git a/src/EntityLengths.Generator/docs/README.md b/src/EntityLengths.Generator/docs/README.md index 689d772..28a702f 100644 --- a/src/EntityLengths.Generator/docs/README.md +++ b/src/EntityLengths.Generator/docs/README.md @@ -43,6 +43,14 @@ To learn more about the war and how you can help, [click here](https://war.ukrai - Can write the constants into another project, for Clean Architecture and similar layouts (see [Writing the constants into another project](#writing-the-constants-into-another-project-clean-architecture)) +## Samples + +- [`samples/CleanArchitecture`](samples/CleanArchitecture/README.md) - three projects + (Domain / Infrastructure / Api) showing the constants generated into a different layer than the one + holding the EF configurations. +- [`src/EntityLengths.Generator.Sample`](src/EntityLengths.Generator.Sample) - single project showing + every supported way to declare a length. + ## Supported frameworks The generator ships as a `netstandard2.0` analyzer, which is what Roslyn loads, and works in projects @@ -59,28 +67,29 @@ dotnet add package EntityLengths.Generator ## Usage -The generator supports a few ways to define string lengths: +The generator supports a few ways to define string lengths, and combines everything it finds for one +entity into a single nested class: ```csharp -// Using MaxLength attribute public class User { + // Using MaxLength attribute [MaxLength(50)] - public string Name { get; set; } -} + public required string Name { get; set; } -// Using StringLength attribute -public class User -{ - [StringLength(50)] - public string Surname { get; set; } -} + // Using StringLength attribute + [StringLength(150)] + public required string Surname { get; set; } -// Using Column attribute -public class User -{ + // Using Column attribute [Column(TypeName = "varchar(200)")] - public string Url { get; set; } + public required string Url { get; set; } + + // Configured by the Fluent API below + public required string Description { get; set; } + + // Configured in OnModelCreating below + public required string Code { get; set; } } // Using Fluent API @@ -88,17 +97,12 @@ public class UserConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { - builder.Property(p => p.Name) - .HasMaxLength(50); + builder.Property(p => p.Description) + .HasMaxLength(500); } } // DbContext configuration -public class User -{ - public required string Surname { get; set; } -} - public class UserDbContext : DbContext { public DbSet Users { get; set; } = null!; @@ -106,32 +110,36 @@ public class UserDbContext : DbContext protected override void OnModelCreating(ModelBuilder modelBuilder) { // fluent API style - modelBuilder.Entity().Property(b => b.Surname).HasMaxLength(150).IsRequired(); + modelBuilder.Entity().Property(b => b.Code).HasMaxLength(20).IsRequired(); // or lambda API modelBuilder.Entity(entity => { - entity.Property(e => e.Surname).HasMaxLength(150).IsRequired(); + entity.Property(e => e.Code).HasMaxLength(20).IsRequired(); }); } } ``` -Generated output: +Generated output (entities and properties are sorted by name): ```csharp public static partial class EntityLengths { public static partial class User { + public const int CodeLength = 20; + public const int DescriptionLength = 500; public const int NameLength = 50; - public const int SurnameLength = 50; + public const int SurnameLength = 150; public const int UrlLength = 200; - public const int SurnameLength = 200; } } ``` +Configuring the same property twice with different lengths, for example `[MaxLength(50)]` plus a +`HasMaxLength(100)` in the Fluent API, is reported as `ELG0004` instead of being silently resolved. + ## Configuration There are ways to configure EntityLengths.Generator. Configuration values are needed during compile-time since this is a source generator: @@ -256,10 +264,22 @@ public static partial class Constants /// public static partial class DbContextUser { + /// + /// Maximum length for Description + /// + public const int DescriptionLength = 500; + /// + /// Maximum length for Description2 + /// + public const int Description2Length = 500; /// /// Maximum length for Name /// public const int NameLength = 50; + /// + /// Maximum length for Name2 + /// + public const int Name2Length = 50; } /// @@ -267,6 +287,10 @@ public static partial class Constants /// public static partial class FluentUser { + /// + /// Maximum length for Description + /// + public const int DescriptionLength = 500; /// /// Maximum length for Name /// @@ -274,3 +298,14 @@ public static partial class Constants } } ``` + +## Building this repository + +```bash +dotnet build # generator, tests and both samples, for net8.0 / net9.0 / net10.0 +dotnet test +``` + +Package versions are managed centrally in `Directory.Packages.props`, so a `PackageReference` in a +project file must not carry a `Version` attribute. The target frameworks come from +`$(SupportedTargetFrameworks)` in `Directory.Build.props`. diff --git a/tests/EntityLengths.Generator.Tests/GeneratorBehaviorTests.cs b/tests/EntityLengths.Generator.Tests/GeneratorBehaviorTests.cs index 3e4cd12..a82b741 100644 --- a/tests/EntityLengths.Generator.Tests/GeneratorBehaviorTests.cs +++ b/tests/EntityLengths.Generator.Tests/GeneratorBehaviorTests.cs @@ -189,6 +189,81 @@ public class Helper Assert.DoesNotContain("Helper", output); } + [Fact] + public void Generates_The_Output_Documented_In_The_Readme() + { + const string source = + @" +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace TestNamespace; + +public class User +{ + [MaxLength(50)] + public required string Name { get; set; } + + [StringLength(150)] + public required string Surname { get; set; } + + [Column(TypeName = ""varchar(200)"")] + public required string Url { get; set; } + + public required string Description { get; set; } + + public required string Code { get; set; } +} + +public class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(p => p.Description).HasMaxLength(500); + } +} + +public class UserDbContext : DbContext +{ + public DbSet Users { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().Property(b => b.Code).HasMaxLength(20).IsRequired(); + + modelBuilder.Entity(entity => + { + entity.Property(e => e.Code).HasMaxLength(20).IsRequired(); + }); + } +}"; + + var result = TestDriver.Run(TestCompilation.Create(source)); + var output = result + .GeneratedTrees.Single(t => t.FilePath.EndsWith("EntityLengths.g.cs")) + .GetText() + .ToString(); + + // Keeps the README example honest, including the alphabetical ordering it claims. + const string expected = + "public static partial class EntityLengths \r\n" + + "{\r\n" + + "\tpublic static partial class User\r\n" + + "\t{\r\n" + + "\t\tpublic const int CodeLength = 20;\r\n" + + "\t\tpublic const int DescriptionLength = 500;\r\n" + + "\t\tpublic const int NameLength = 50;\r\n" + + "\t\tpublic const int SurnameLength = 150;\r\n" + + "\t\tpublic const int UrlLength = 200;\r\n" + + "\t}\r\n" + + "}\r\n"; + + Assert.Empty(result.Diagnostics); + Assert.Contains(expected.ReplaceLineEndings(), output.ReplaceLineEndings()); + } + private static string GetGeneratedSource(string source) { var result = TestDriver.Run(TestCompilation.Create(source)); diff --git a/tests/EntityLengths.Generator.Tests/OutputPathTests.cs b/tests/EntityLengths.Generator.Tests/OutputPathTests.cs index f69c306..51f7522 100644 --- a/tests/EntityLengths.Generator.Tests/OutputPathTests.cs +++ b/tests/EntityLengths.Generator.Tests/OutputPathTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading.Tasks; using EntityLengths.Generator.Tests.Infrastructure; using Microsoft.CodeAnalysis; using Xunit; @@ -213,6 +214,32 @@ public void Reports_ELG0001_Instead_Of_Throwing_When_Path_Is_Unusable() ); } + [Fact] + public void Concurrent_Writes_To_The_Same_Path_Succeed() + { + // What a multi-targeted project does: one inner build per target framework, in parallel, all + // writing the same output file. + var results = new GeneratorDriverRunResult[8]; + + Parallel.For( + 0, + results.Length, + i => + results[i] = TestDriver.Run( + TestCompilation.Create(EntityWithLength(50)), + new Dictionary + { + ["build_property.EntityLengthsResolvedOutputPath"] = OutputPath, + ["build_property.EntityLengthsOutputNamespace"] = "MyApp.Domain", + } + ) + ); + + Assert.All(results, result => Assert.Empty(result.Diagnostics)); + Assert.Contains("NameLength = 50", File.ReadAllText(OutputPath)); + Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(OutputPath)!, "*.tmp")); + } + private GeneratorDriverRunResult Run(Dictionary properties) => TestDriver.Run(TestCompilation.Create(EntityWithLength(50)), properties); From b6b213839a49f53dacdef10c1ab1c0258467c754 Mon Sep 17 00:00:00 2001 From: Taras Kovalenko Date: Sun, 26 Jul 2026 12:05:27 +0300 Subject: [PATCH 4/4] refactor: keep the reshaped pipeline models out of the public API EntityTypeInfo changed shape in this release (ITypeSymbol and List became strings and an EquatableArray, so the model can flow through the incremental pipeline with value equality). It is an implementation detail of the generator, so making it internal now avoids shipping a public type whose shape already broke once. EquatableArray is new in this release and only exists to serve that model, so it becomes internal too. The public surface is now the five types that shipped in 1.0.3, verified by reading the assembly metadata: EntityLengthsGeneratorAttribute EntityMaxLengthGenerator PropertyMaxLength EntityLengthsGeneratorOptions EntityLengthsScanningOptions --- src/EntityLengths.Generator/Core/EquatableArray.cs | 2 +- src/EntityLengths.Generator/Models/EntityTypeInfo.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/EntityLengths.Generator/Core/EquatableArray.cs b/src/EntityLengths.Generator/Core/EquatableArray.cs index a3be753..d1f1345 100644 --- a/src/EntityLengths.Generator/Core/EquatableArray.cs +++ b/src/EntityLengths.Generator/Core/EquatableArray.cs @@ -10,7 +10,7 @@ namespace EntityLengths.Generator.Core; /// An immutable array with structural equality, so it can safely flow through the /// incremental generator pipeline without breaking output caching. /// -public readonly struct EquatableArray(ImmutableArray values) +internal readonly struct EquatableArray(ImmutableArray values) : IEquatable>, IReadOnlyList where T : IEquatable diff --git a/src/EntityLengths.Generator/Models/EntityTypeInfo.cs b/src/EntityLengths.Generator/Models/EntityTypeInfo.cs index 5946eee..aed3284 100644 --- a/src/EntityLengths.Generator/Models/EntityTypeInfo.cs +++ b/src/EntityLengths.Generator/Models/EntityTypeInfo.cs @@ -12,7 +12,7 @@ namespace EntityLengths.Generator.Models; /// Namespace of the type that declared the lengths (entity, configuration or DbContext). /// Simple name of the type that declared the lengths. /// The discovered property lengths. -public sealed record EntityTypeInfo( +internal sealed record EntityTypeInfo( string EntityName, string EntityNamespace, string DeclaringNamespace,