diff --git a/README.md b/README.md index 440365e..d351aa5 100644 --- a/README.md +++ b/README.md @@ -82,10 +82,16 @@ The package orchestrates a MSBuild pipeline with these stages: ### Core Capabilities -- **🔄 Incremental Builds** - Only regenerates when database schema or configuration changes +- **🔄 Incremental Builds** - Smart fingerprinting detects when regeneration is needed based on: + - Library or tool version changes + - Database schema modifications + - Configuration file changes + - MSBuild property overrides (`EfcptConfig*`) + - Template file changes + - Generated file changes (optional) - **🎨 T4 Template Support** - Customize code generation with your own templates - **📁 Smart File Organization** - Schema-based folders and namespaces -- **🔧 Highly Configurable** - Override namespaces, output paths, and generation options +- **🔧 Highly Configurable** - Override namespaces, output paths, and generation options via MSBuild properties - **🌐 Multi-Schema Support** - Generate models across multiple database schemas - **📦 NuGet Ready** - Enterprise-ready package for production use diff --git a/docs/user-guide/api-reference.md b/docs/user-guide/api-reference.md index bc4044f..162f0ef 100644 --- a/docs/user-guide/api-reference.md +++ b/docs/user-guide/api-reference.md @@ -135,13 +135,17 @@ Computes a composite fingerprint to detect when regeneration is needed. | `RenamingPath` | Yes | Path to renaming file | | `TemplateDir` | Yes | Path to templates | | `FingerprintFile` | Yes | Path to fingerprint cache file | +| `ToolVersion` | No | EF Core Power Tools CLI version | +| `GeneratedDir` | No | Directory containing generated files | +| `DetectGeneratedFileChanges` | No | Whether to detect changes to generated files (default: false) | +| `ConfigPropertyOverrides` | No | JSON string of MSBuild property overrides | | `LogVerbosity` | No | Logging level | **Outputs:** | Output | Description | |--------|-------------| -| `Fingerprint` | Computed XxHash64 hash | +| `Fingerprint` | Computed XxHash64 hash including library version, tool version, schema, config, overrides, templates, and optionally generated files | | `HasChanged` | Whether fingerprint changed | ### RunEfcpt @@ -315,6 +319,7 @@ Applies MSBuild property overrides to the staged `efcpt-config.json` file. This | `EfcptDumpResolvedInputs` | `false` | Write resolved inputs to JSON | | `EfcptFingerprintFile` | `$(EfcptOutput)fingerprint.txt` | Fingerprint cache location | | `EfcptStampFile` | `$(EfcptOutput).efcpt.stamp` | Generation stamp file | +| `EfcptDetectGeneratedFileChanges` | `false` | Detect changes to generated `.g.cs` files and trigger regeneration. **Warning**: When enabled, manual edits to generated files will be overwritten. | ### Config Override Properties diff --git a/docs/user-guide/core-concepts.md b/docs/user-guide/core-concepts.md index cd2fcdb..55fd42f 100644 --- a/docs/user-guide/core-concepts.md +++ b/docs/user-guide/core-concepts.md @@ -134,12 +134,18 @@ Fingerprinting is a key optimization that prevents unnecessary code regeneration ### What's Included in the Fingerprint +The fingerprint includes multiple sources to ensure regeneration when any relevant input changes: + +- **Library version** - Version of JD.Efcpt.Build.Tasks assembly +- **Tool version** - EF Core Power Tools CLI version (`EfcptToolVersion`) - **DACPAC content** (in .sqlproj mode) or **schema metadata** (in connection string mode) -- **efcpt-config.json** - Generation options, namespaces, table selection (including MSBuild overrides) +- **efcpt-config.json** - Generation options, namespaces, table selection +- **MSBuild property overrides** - All `EfcptConfig*` properties set in the .csproj - **efcpt.renaming.json** - Custom naming rules - **T4 templates** - All template files and their contents +- **Generated files** (optional) - When `EfcptDetectGeneratedFileChanges=true`, includes fingerprints of generated `.g.cs` files -Note: The fingerprint is computed after MSBuild property overrides are applied, so changing an override property (like `EfcptConfigRootNamespace`) will trigger regeneration. +**Important**: The fingerprint is computed after MSBuild property overrides are applied, so changing any `EfcptConfig*` property (like `EfcptConfigRootNamespace`) will automatically trigger regeneration. All hashing uses XxHash64, a fast non-cryptographic hash algorithm. @@ -147,23 +153,55 @@ All hashing uses XxHash64, a fast non-cryptographic hash algorithm. ``` Build 1 (first run): - Fingerprint = Hash(DACPAC/Schema + config + renaming + templates) + Fingerprint = Hash(library + tool + DACPAC/Schema + config + overrides + renaming + templates) → No previous fingerprint exists → Generate models → Store fingerprint Build 2 (no changes): - Fingerprint = Hash(DACPAC/Schema + config + renaming + templates) + Fingerprint = Hash(library + tool + DACPAC/Schema + config + overrides + renaming + templates) → Same as stored fingerprint → Skip generation (fast build) Build 3 (schema changed): - Fingerprint = Hash(new DACPAC/Schema + config + renaming + templates) + Fingerprint = Hash(library + tool + new DACPAC/Schema + config + overrides + renaming + templates) → Different from stored fingerprint → Regenerate models → Store new fingerprint + +Build 4 (config property changed): + Fingerprint = Hash(library + tool + DACPAC/Schema + config + new overrides + renaming + templates) + → Different from stored fingerprint (overrides changed) + → Regenerate models + → Store new fingerprint +``` + +### Regeneration Triggers + +The following changes will automatically trigger model regeneration: + +1. **Library upgrade** - When you update the JD.Efcpt.Build NuGet package +2. **Tool version change** - When you change `` in your .csproj +3. **Database schema change** - Tables, columns, or relationships modified +4. **Config file change** - efcpt-config.json or efcpt.renaming.json modified +5. **MSBuild property change** - Any `` property changed in .csproj +6. **Template change** - T4 template files added, removed, or modified +7. **Generated file change** (optional) - When `true` is set + +### Detecting Manual Edits (Optional) + +By default, the system **does not** detect changes to generated files. This prevents accidentally overwriting manual edits you might make to generated code. + +To enable detection of changes to generated files (useful in some workflows): + +```xml + + true + ``` +**Warning**: When enabled, any manual edits to `.g.cs` files will trigger regeneration, overwriting your changes. Only enable this if your workflow never involves manual edits to generated code. + ### Forcing Regeneration To force regeneration regardless of fingerprint: diff --git a/src/JD.Efcpt.Build.Tasks/ComputeFingerprint.cs b/src/JD.Efcpt.Build.Tasks/ComputeFingerprint.cs index eae0b99..d58dcdf 100644 --- a/src/JD.Efcpt.Build.Tasks/ComputeFingerprint.cs +++ b/src/JD.Efcpt.Build.Tasks/ComputeFingerprint.cs @@ -1,3 +1,4 @@ +using System.Reflection; using System.Text; using JD.Efcpt.Build.Tasks.Decorators; using JD.Efcpt.Build.Tasks.Extensions; @@ -11,10 +12,19 @@ namespace JD.Efcpt.Build.Tasks; /// /// /// -/// The fingerprint is derived from the contents of the DACPAC, configuration JSON, renaming JSON, and -/// every file under the template directory. For each input, an XxHash64 hash is computed and written into -/// an internal manifest string, which is itself hashed using XxHash64 to produce the final -/// . +/// The fingerprint is derived from multiple sources to ensure regeneration when any relevant input changes: +/// +/// Library version (JD.Efcpt.Build.Tasks assembly) +/// Tool version (EF Core Power Tools CLI version) +/// Database schema (DACPAC or connection string schema fingerprint) +/// Configuration JSON file contents +/// Renaming JSON file contents +/// MSBuild config property overrides (EfcptConfig* properties) +/// All template files under the template directory +/// Generated files (optional, via EfcptDetectGeneratedFileChanges) +/// +/// For each input, an XxHash64 hash is computed and written into an internal manifest string, +/// which is itself hashed using XxHash64 to produce the final . /// /// /// The computed fingerprint is compared to the existing value stored in . @@ -70,6 +80,26 @@ public sealed class ComputeFingerprint : Task /// public string LogVerbosity { get; set; } = "minimal"; + /// + /// Version of the EF Core Power Tools CLI tool package being used. + /// + public string ToolVersion { get; set; } = ""; + + /// + /// Directory containing generated files to optionally include in the fingerprint. + /// + public string GeneratedDir { get; set; } = ""; + + /// + /// Indicates whether to detect changes to generated files (default: false to avoid overwriting manual edits). + /// + public string DetectGeneratedFileChanges { get; set; } = "false"; + + /// + /// Serialized JSON string containing MSBuild config property overrides. + /// + public string ConfigPropertyOverrides { get; set; } = ""; + /// /// Newly computed fingerprint value for the current inputs. /// @@ -99,6 +129,21 @@ private bool ExecuteCore(TaskExecutionContext ctx) var log = new BuildLog(ctx.Logger, LogVerbosity); var manifest = new StringBuilder(); + // Library version (JD.Efcpt.Build.Tasks assembly) + var libraryVersion = GetLibraryVersion(); + if (!string.IsNullOrWhiteSpace(libraryVersion)) + { + manifest.Append("library\0").Append(libraryVersion).Append('\n'); + log.Detail($"Library version: {libraryVersion}"); + } + + // Tool version (EF Core Power Tools CLI) + if (!string.IsNullOrWhiteSpace(ToolVersion)) + { + manifest.Append("tool\0").Append(ToolVersion).Append('\n'); + log.Detail($"Tool version: {ToolVersion}"); + } + // Source fingerprint (DACPAC OR schema fingerprint) if (UseConnectionStringMode.IsTrue()) { @@ -124,6 +169,13 @@ private bool ExecuteCore(TaskExecutionContext ctx) Append(manifest, ConfigPath, "config"); Append(manifest, RenamingPath, "renaming"); + // Config property overrides (MSBuild properties that override efcpt-config.json) + if (!string.IsNullOrWhiteSpace(ConfigPropertyOverrides)) + { + manifest.Append("config-overrides\0").Append(ConfigPropertyOverrides).Append('\n'); + log.Detail("Including MSBuild config property overrides in fingerprint"); + } + manifest = Directory .EnumerateFiles(TemplateDir, "*", SearchOption.AllDirectories) .Select(p => p.Replace('\u005C', '/')) @@ -136,6 +188,23 @@ private bool ExecuteCore(TaskExecutionContext ctx) .Append(data.rel).Append('\0') .Append(data.h).Append('\n')); + // Generated files (optional, off by default to avoid overwriting manual edits) + if (!string.IsNullOrWhiteSpace(GeneratedDir) && Directory.Exists(GeneratedDir) && DetectGeneratedFileChanges.IsTrue()) + { + log.Detail("Detecting generated file changes (EfcptDetectGeneratedFileChanges=true)"); + manifest = Directory + .EnumerateFiles(GeneratedDir, "*.g.cs", SearchOption.AllDirectories) + .Select(p => p.Replace('\u005C', '/')) + .OrderBy(p => p, StringComparer.Ordinal) + .Select(file => ( + rel: Path.GetRelativePath(GeneratedDir, file).Replace('\u005C', '/'), + h: FileHash.HashFile(file))) + .Aggregate(manifest, (builder, data) + => builder.Append("generated/") + .Append(data.rel).Append('\0') + .Append(data.h).Append('\n')); + } + Fingerprint = FileHash.HashString(manifest.ToString()); var prior = File.Exists(FingerprintFile) ? File.ReadAllText(FingerprintFile).Trim() : ""; @@ -155,6 +224,22 @@ private bool ExecuteCore(TaskExecutionContext ctx) return true; } + private static string GetLibraryVersion() + { + try + { + var assembly = typeof(ComputeFingerprint).Assembly; + var version = assembly.GetCustomAttribute()?.InformationalVersion + ?? assembly.GetName().Version?.ToString() + ?? ""; + return version; + } + catch + { + return ""; + } + } + private static void Append(StringBuilder manifest, string path, string label) { var full = Path.GetFullPath(path); diff --git a/src/JD.Efcpt.Build.Tasks/SerializeConfigProperties.cs b/src/JD.Efcpt.Build.Tasks/SerializeConfigProperties.cs new file mode 100644 index 0000000..c662abf --- /dev/null +++ b/src/JD.Efcpt.Build.Tasks/SerializeConfigProperties.cs @@ -0,0 +1,279 @@ +using System.Text; +using System.Text.Json; +using JD.Efcpt.Build.Tasks.Decorators; +using Microsoft.Build.Framework; +using Task = Microsoft.Build.Utilities.Task; + +namespace JD.Efcpt.Build.Tasks; + +/// +/// MSBuild task that serializes EfcptConfig* property overrides to a JSON string for fingerprinting. +/// +/// +/// This task collects all MSBuild property overrides (EfcptConfig*) and serializes them to a +/// deterministic JSON string. This allows the fingerprinting system to detect when configuration +/// properties change in the .csproj file, triggering regeneration. +/// +public sealed class SerializeConfigProperties : Task +{ + /// + /// Root namespace override. + /// + public string RootNamespace { get; set; } = ""; + + /// + /// DbContext name override. + /// + public string DbContextName { get; set; } = ""; + + /// + /// DbContext namespace override. + /// + public string DbContextNamespace { get; set; } = ""; + + /// + /// Model namespace override. + /// + public string ModelNamespace { get; set; } = ""; + + /// + /// Output path override. + /// + public string OutputPath { get; set; } = ""; + + /// + /// DbContext output path override. + /// + public string DbContextOutputPath { get; set; } = ""; + + /// + /// Split DbContext override. + /// + public string SplitDbContext { get; set; } = ""; + + /// + /// Use schema folders override. + /// + public string UseSchemaFolders { get; set; } = ""; + + /// + /// Use schema namespaces override. + /// + public string UseSchemaNamespaces { get; set; } = ""; + + /// + /// Enable OnConfiguring override. + /// + public string EnableOnConfiguring { get; set; } = ""; + + /// + /// Generation type override. + /// + public string GenerationType { get; set; } = ""; + + /// + /// Use database names override. + /// + public string UseDatabaseNames { get; set; } = ""; + + /// + /// Use data annotations override. + /// + public string UseDataAnnotations { get; set; } = ""; + + /// + /// Use nullable reference types override. + /// + public string UseNullableReferenceTypes { get; set; } = ""; + + /// + /// Use inflector override. + /// + public string UseInflector { get; set; } = ""; + + /// + /// Use legacy inflector override. + /// + public string UseLegacyInflector { get; set; } = ""; + + /// + /// Use many-to-many entity override. + /// + public string UseManyToManyEntity { get; set; } = ""; + + /// + /// Use T4 override. + /// + public string UseT4 { get; set; } = ""; + + /// + /// Use T4 split override. + /// + public string UseT4Split { get; set; } = ""; + + /// + /// Remove default SQL from bool override. + /// + public string RemoveDefaultSqlFromBool { get; set; } = ""; + + /// + /// Soft delete obsolete files override. + /// + public string SoftDeleteObsoleteFiles { get; set; } = ""; + + /// + /// Discover multiple result sets override. + /// + public string DiscoverMultipleResultSets { get; set; } = ""; + + /// + /// Use alternate result set discovery override. + /// + public string UseAlternateResultSetDiscovery { get; set; } = ""; + + /// + /// T4 template path override. + /// + public string T4TemplatePath { get; set; } = ""; + + /// + /// Use no navigations override. + /// + public string UseNoNavigations { get; set; } = ""; + + /// + /// Merge dacpacs override. + /// + public string MergeDacpacs { get; set; } = ""; + + /// + /// Refresh object lists override. + /// + public string RefreshObjectLists { get; set; } = ""; + + /// + /// Generate Mermaid diagram override. + /// + public string GenerateMermaidDiagram { get; set; } = ""; + + /// + /// Use decimal annotation for sprocs override. + /// + public string UseDecimalAnnotationForSprocs { get; set; } = ""; + + /// + /// Use prefix navigation naming override. + /// + public string UsePrefixNavigationNaming { get; set; } = ""; + + /// + /// Use database names for routines override. + /// + public string UseDatabaseNamesForRoutines { get; set; } = ""; + + /// + /// Use internal access for routines override. + /// + public string UseInternalAccessForRoutines { get; set; } = ""; + + /// + /// Use DateOnly/TimeOnly override. + /// + public string UseDateOnlyTimeOnly { get; set; } = ""; + + /// + /// Use HierarchyId override. + /// + public string UseHierarchyId { get; set; } = ""; + + /// + /// Use spatial override. + /// + public string UseSpatial { get; set; } = ""; + + /// + /// Use NodaTime override. + /// + public string UseNodaTime { get; set; } = ""; + + /// + /// Preserve casing with regex override. + /// + public string PreserveCasingWithRegex { get; set; } = ""; + + /// + /// Serialized JSON string containing all non-empty property values. + /// + [Output] + public string SerializedProperties { get; set; } = ""; + + /// + public override bool Execute() + { + var decorator = TaskExecutionDecorator.Create(ExecuteCore); + var ctx = new TaskExecutionContext(Log, nameof(SerializeConfigProperties)); + return decorator.Execute(in ctx); + } + + private bool ExecuteCore(TaskExecutionContext ctx) + { + var properties = new Dictionary(35, StringComparer.Ordinal); + + // Only include properties that have non-empty values + AddIfNotEmpty(properties, nameof(RootNamespace), RootNamespace); + AddIfNotEmpty(properties, nameof(DbContextName), DbContextName); + AddIfNotEmpty(properties, nameof(DbContextNamespace), DbContextNamespace); + AddIfNotEmpty(properties, nameof(ModelNamespace), ModelNamespace); + AddIfNotEmpty(properties, nameof(OutputPath), OutputPath); + AddIfNotEmpty(properties, nameof(DbContextOutputPath), DbContextOutputPath); + AddIfNotEmpty(properties, nameof(SplitDbContext), SplitDbContext); + AddIfNotEmpty(properties, nameof(UseSchemaFolders), UseSchemaFolders); + AddIfNotEmpty(properties, nameof(UseSchemaNamespaces), UseSchemaNamespaces); + AddIfNotEmpty(properties, nameof(EnableOnConfiguring), EnableOnConfiguring); + AddIfNotEmpty(properties, nameof(GenerationType), GenerationType); + AddIfNotEmpty(properties, nameof(UseDatabaseNames), UseDatabaseNames); + AddIfNotEmpty(properties, nameof(UseDataAnnotations), UseDataAnnotations); + AddIfNotEmpty(properties, nameof(UseNullableReferenceTypes), UseNullableReferenceTypes); + AddIfNotEmpty(properties, nameof(UseInflector), UseInflector); + AddIfNotEmpty(properties, nameof(UseLegacyInflector), UseLegacyInflector); + AddIfNotEmpty(properties, nameof(UseManyToManyEntity), UseManyToManyEntity); + AddIfNotEmpty(properties, nameof(UseT4), UseT4); + AddIfNotEmpty(properties, nameof(UseT4Split), UseT4Split); + AddIfNotEmpty(properties, nameof(RemoveDefaultSqlFromBool), RemoveDefaultSqlFromBool); + AddIfNotEmpty(properties, nameof(SoftDeleteObsoleteFiles), SoftDeleteObsoleteFiles); + AddIfNotEmpty(properties, nameof(DiscoverMultipleResultSets), DiscoverMultipleResultSets); + AddIfNotEmpty(properties, nameof(UseAlternateResultSetDiscovery), UseAlternateResultSetDiscovery); + AddIfNotEmpty(properties, nameof(T4TemplatePath), T4TemplatePath); + AddIfNotEmpty(properties, nameof(UseNoNavigations), UseNoNavigations); + AddIfNotEmpty(properties, nameof(MergeDacpacs), MergeDacpacs); + AddIfNotEmpty(properties, nameof(RefreshObjectLists), RefreshObjectLists); + AddIfNotEmpty(properties, nameof(GenerateMermaidDiagram), GenerateMermaidDiagram); + AddIfNotEmpty(properties, nameof(UseDecimalAnnotationForSprocs), UseDecimalAnnotationForSprocs); + AddIfNotEmpty(properties, nameof(UsePrefixNavigationNaming), UsePrefixNavigationNaming); + AddIfNotEmpty(properties, nameof(UseDatabaseNamesForRoutines), UseDatabaseNamesForRoutines); + AddIfNotEmpty(properties, nameof(UseInternalAccessForRoutines), UseInternalAccessForRoutines); + AddIfNotEmpty(properties, nameof(UseDateOnlyTimeOnly), UseDateOnlyTimeOnly); + AddIfNotEmpty(properties, nameof(UseHierarchyId), UseHierarchyId); + AddIfNotEmpty(properties, nameof(UseSpatial), UseSpatial); + AddIfNotEmpty(properties, nameof(UseNodaTime), UseNodaTime); + AddIfNotEmpty(properties, nameof(PreserveCasingWithRegex), PreserveCasingWithRegex); + + // Serialize to JSON with sorted keys for deterministic output + SerializedProperties = JsonSerializer.Serialize(properties.OrderBy(kvp => kvp.Key, StringComparer.Ordinal), JsonOptions); + + return true; + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = false + }; + + private static void AddIfNotEmpty(Dictionary dict, string key, string value) + { + if (!string.IsNullOrWhiteSpace(value)) + { + dict[key] = value; + } + } +} diff --git a/src/JD.Efcpt.Build/build/JD.Efcpt.Build.props b/src/JD.Efcpt.Build/build/JD.Efcpt.Build.props index cbf506e..7ebc707 100644 --- a/src/JD.Efcpt.Build/build/JD.Efcpt.Build.props +++ b/src/JD.Efcpt.Build/build/JD.Efcpt.Build.props @@ -39,6 +39,7 @@ $(EfcptOutput)fingerprint.txt $(EfcptOutput).efcpt.stamp + false minimal diff --git a/src/JD.Efcpt.Build/build/JD.Efcpt.Build.targets b/src/JD.Efcpt.Build/build/JD.Efcpt.Build.targets index 233dc96..6676e34 100644 --- a/src/JD.Efcpt.Build/build/JD.Efcpt.Build.targets +++ b/src/JD.Efcpt.Build/build/JD.Efcpt.Build.targets @@ -43,6 +43,9 @@ + + @@ -231,9 +234,58 @@ PreserveCasingWithRegex="$(EfcptConfigPreserveCasingWithRegex)" /> - + + + + + + + diff --git a/src/JD.Efcpt.Build/buildTransitive/JD.Efcpt.Build.props b/src/JD.Efcpt.Build/buildTransitive/JD.Efcpt.Build.props index 82187b2..ec079dd 100644 --- a/src/JD.Efcpt.Build/buildTransitive/JD.Efcpt.Build.props +++ b/src/JD.Efcpt.Build/buildTransitive/JD.Efcpt.Build.props @@ -39,6 +39,7 @@ $(EfcptOutput)fingerprint.txt $(EfcptOutput).efcpt.stamp + false minimal diff --git a/src/JD.Efcpt.Build/buildTransitive/JD.Efcpt.Build.targets b/src/JD.Efcpt.Build/buildTransitive/JD.Efcpt.Build.targets index 349bb64..27d1585 100644 --- a/src/JD.Efcpt.Build/buildTransitive/JD.Efcpt.Build.targets +++ b/src/JD.Efcpt.Build/buildTransitive/JD.Efcpt.Build.targets @@ -43,6 +43,9 @@ + + @@ -243,9 +246,58 @@ PreserveCasingWithRegex="$(EfcptConfigPreserveCasingWithRegex)" /> - + + + + + + + diff --git a/tests/JD.Efcpt.Build.Tests/ComputeFingerprintTests.cs b/tests/JD.Efcpt.Build.Tests/ComputeFingerprintTests.cs index e52b523..440012c 100644 --- a/tests/JD.Efcpt.Build.Tests/ComputeFingerprintTests.cs +++ b/tests/JD.Efcpt.Build.Tests/ComputeFingerprintTests.cs @@ -380,4 +380,330 @@ await Given("inputs with existing fingerprint", SetupWithExistingFingerprintFile .Finally(r => r.Setup.Folder.Dispose()) .AssertPassed(); } + + [Scenario("HasChanged is true when tool version changes")] + [Fact] + public async Task Tool_version_change_triggers_fingerprint_change() + { + await Given("inputs with existing fingerprint", () => + { + var setup = SetupWithExistingFingerprintFile(); + // First run with tool version + var task = new ComputeFingerprint + { + BuildEngine = setup.Engine, + DacpacPath = setup.DacpacPath, + ConfigPath = setup.ConfigPath, + RenamingPath = setup.RenamingPath, + TemplateDir = setup.TemplateDir, + FingerprintFile = setup.FingerprintFile, + ToolVersion = "10.0.0" + }; + task.Execute(); + return setup; + }) + .When("task executes with different tool version", s => + { + var task = new ComputeFingerprint + { + BuildEngine = s.Engine, + DacpacPath = s.DacpacPath, + ConfigPath = s.ConfigPath, + RenamingPath = s.RenamingPath, + TemplateDir = s.TemplateDir, + FingerprintFile = s.FingerprintFile, + ToolVersion = "10.1.0" + }; + var success = task.Execute(); + return new TaskResult(s, task, success); + }) + .Then("task succeeds", r => r.Success) + .And("HasChanged is true", r => r.Task.HasChanged == "true") + .Finally(r => r.Setup.Folder.Dispose()) + .AssertPassed(); + } + + [Scenario("HasChanged is true when config property overrides change")] + [Fact] + public async Task Config_property_overrides_change_triggers_fingerprint_change() + { + await Given("inputs with existing fingerprint", () => + { + var setup = SetupWithExistingFingerprintFile(); + // First run with config overrides + var task = new ComputeFingerprint + { + BuildEngine = setup.Engine, + DacpacPath = setup.DacpacPath, + ConfigPath = setup.ConfigPath, + RenamingPath = setup.RenamingPath, + TemplateDir = setup.TemplateDir, + FingerprintFile = setup.FingerprintFile, + ConfigPropertyOverrides = "{\"UseDataAnnotations\":\"true\"}" + }; + task.Execute(); + return setup; + }) + .When("task executes with different config overrides", s => + { + var task = new ComputeFingerprint + { + BuildEngine = s.Engine, + DacpacPath = s.DacpacPath, + ConfigPath = s.ConfigPath, + RenamingPath = s.RenamingPath, + TemplateDir = s.TemplateDir, + FingerprintFile = s.FingerprintFile, + ConfigPropertyOverrides = "{\"UseDataAnnotations\":\"false\"}" + }; + var success = task.Execute(); + return new TaskResult(s, task, success); + }) + .Then("task succeeds", r => r.Success) + .And("HasChanged is true", r => r.Task.HasChanged == "true") + .Finally(r => r.Setup.Folder.Dispose()) + .AssertPassed(); + } + + [Scenario("HasChanged is true when generated files change and detection is enabled")] + [Fact] + public async Task Generated_file_change_triggers_fingerprint_change() + { + await Given("inputs with existing fingerprint and generated files", () => + { + var setup = SetupWithExistingFingerprintFile(); + var generatedDir = setup.Folder.CreateDir("Generated"); + setup.Folder.WriteFile("Generated/Model.g.cs", "public class Model { }"); + + // First run with generated file detection + var task = new ComputeFingerprint + { + BuildEngine = setup.Engine, + DacpacPath = setup.DacpacPath, + ConfigPath = setup.ConfigPath, + RenamingPath = setup.RenamingPath, + TemplateDir = setup.TemplateDir, + FingerprintFile = setup.FingerprintFile, + GeneratedDir = generatedDir, + DetectGeneratedFileChanges = "true" + }; + task.Execute(); + return (setup, generatedDir); + }) + .When("generated file is modified and task executes", ctx => + { + var (s, generatedDir) = ctx; + File.WriteAllText(Path.Combine(generatedDir, "Model.g.cs"), "public class Model { public int Id { get; set; } }"); + + var task = new ComputeFingerprint + { + BuildEngine = s.Engine, + DacpacPath = s.DacpacPath, + ConfigPath = s.ConfigPath, + RenamingPath = s.RenamingPath, + TemplateDir = s.TemplateDir, + FingerprintFile = s.FingerprintFile, + GeneratedDir = generatedDir, + DetectGeneratedFileChanges = "true" + }; + var success = task.Execute(); + return new TaskResult(s, task, success); + }) + .Then("task succeeds", r => r.Success) + .And("HasChanged is true", r => r.Task.HasChanged == "true") + .Finally(r => r.Setup.Folder.Dispose()) + .AssertPassed(); + } + + [Scenario("HasChanged is false when generated files change but detection is disabled")] + [Fact] + public async Task Generated_file_change_ignored_when_detection_disabled() + { + await Given("inputs with existing fingerprint and generated files", () => + { + var setup = SetupWithExistingFingerprintFile(); + var generatedDir = setup.Folder.CreateDir("Generated"); + setup.Folder.WriteFile("Generated/Model.g.cs", "public class Model { }"); + + // First run without generated file detection + var task = new ComputeFingerprint + { + BuildEngine = setup.Engine, + DacpacPath = setup.DacpacPath, + ConfigPath = setup.ConfigPath, + RenamingPath = setup.RenamingPath, + TemplateDir = setup.TemplateDir, + FingerprintFile = setup.FingerprintFile, + GeneratedDir = generatedDir, + DetectGeneratedFileChanges = "false" + }; + task.Execute(); + return (setup, generatedDir); + }) + .When("generated file is modified and task executes", ctx => + { + var (s, generatedDir) = ctx; + File.WriteAllText(Path.Combine(generatedDir, "Model.g.cs"), "public class Model { public int Id { get; set; } }"); + + var task = new ComputeFingerprint + { + BuildEngine = s.Engine, + DacpacPath = s.DacpacPath, + ConfigPath = s.ConfigPath, + RenamingPath = s.RenamingPath, + TemplateDir = s.TemplateDir, + FingerprintFile = s.FingerprintFile, + GeneratedDir = generatedDir, + DetectGeneratedFileChanges = "false" + }; + var success = task.Execute(); + return new TaskResult(s, task, success); + }) + .Then("task succeeds", r => r.Success) + .And("HasChanged is false", r => r.Task.HasChanged == "false") + .Finally(r => r.Setup.Folder.Dispose()) + .AssertPassed(); + } + + [Scenario("Includes library version in fingerprint")] + [Fact] + public async Task Includes_library_version_in_fingerprint() + { + await Given("inputs for fingerprinting", SetupWithAllInputs) + .When("task executes with detailed logging", s => + { + var task = new ComputeFingerprint + { + BuildEngine = s.Engine, + DacpacPath = s.DacpacPath, + ConfigPath = s.ConfigPath, + RenamingPath = s.RenamingPath, + TemplateDir = s.TemplateDir, + FingerprintFile = s.FingerprintFile, + LogVerbosity = "detailed" + }; + var success = task.Execute(); + return new TaskResult(s, task, success); + }) + .Then("task succeeds", r => r.Success) + .And("fingerprint is computed", r => !string.IsNullOrEmpty(r.Task.Fingerprint)) + .And("logs library version", r => + r.Setup.Engine.Messages.Any(m => m.Message?.Contains("Library version:") == true)) + .Finally(r => r.Setup.Folder.Dispose()) + .AssertPassed(); + } + + [Scenario("Handles empty generated directory when detection is enabled")] + [Fact] + public async Task Empty_generated_directory_when_detection_enabled() + { + await Given("inputs with empty generated directory", () => + { + var setup = SetupWithExistingFingerprintFile(); + var generatedDir = setup.Folder.CreateDir("Generated"); + // Directory exists but is empty + return (setup, generatedDir); + }) + .When("task executes with detection enabled", ctx => + { + var (s, generatedDir) = ctx; + var task = new ComputeFingerprint + { + BuildEngine = s.Engine, + DacpacPath = s.DacpacPath, + ConfigPath = s.ConfigPath, + RenamingPath = s.RenamingPath, + TemplateDir = s.TemplateDir, + FingerprintFile = s.FingerprintFile, + GeneratedDir = generatedDir, + DetectGeneratedFileChanges = "true" + }; + var success = task.Execute(); + return new TaskResult(s, task, success); + }) + .Then("task succeeds", r => r.Success) + .And("fingerprint is computed", r => !string.IsNullOrEmpty(r.Task.Fingerprint)) + .Finally(r => r.Setup.Folder.Dispose()) + .AssertPassed(); + } + + [Scenario("Handles non-existent generated directory when detection is enabled")] + [Fact] + public async Task Nonexistent_generated_directory_when_detection_enabled() + { + await Given("inputs with non-existent generated directory", SetupWithExistingFingerprintFile) + .When("task executes with detection enabled", s => + { + var nonExistentDir = Path.Combine(s.Folder.Root, "DoesNotExist"); + var task = new ComputeFingerprint + { + BuildEngine = s.Engine, + DacpacPath = s.DacpacPath, + ConfigPath = s.ConfigPath, + RenamingPath = s.RenamingPath, + TemplateDir = s.TemplateDir, + FingerprintFile = s.FingerprintFile, + GeneratedDir = nonExistentDir, + DetectGeneratedFileChanges = "true" + }; + var success = task.Execute(); + return new TaskResult(s, task, success); + }) + .Then("task succeeds", r => r.Success) + .And("fingerprint is computed", r => !string.IsNullOrEmpty(r.Task.Fingerprint)) + .Finally(r => r.Setup.Folder.Dispose()) + .AssertPassed(); + } + + [Scenario("Handles empty tool version")] + [Fact] + public async Task Empty_tool_version_handled() + { + await Given("inputs with empty tool version", SetupWithExistingFingerprintFile) + .When("task executes", s => + { + var task = new ComputeFingerprint + { + BuildEngine = s.Engine, + DacpacPath = s.DacpacPath, + ConfigPath = s.ConfigPath, + RenamingPath = s.RenamingPath, + TemplateDir = s.TemplateDir, + FingerprintFile = s.FingerprintFile, + ToolVersion = "" + }; + var success = task.Execute(); + return new TaskResult(s, task, success); + }) + .Then("task succeeds", r => r.Success) + .And("fingerprint is computed", r => !string.IsNullOrEmpty(r.Task.Fingerprint)) + .Finally(r => r.Setup.Folder.Dispose()) + .AssertPassed(); + } + + [Scenario("Handles empty config property overrides")] + [Fact] + public async Task Empty_config_property_overrides_handled() + { + await Given("inputs with empty config overrides", SetupWithExistingFingerprintFile) + .When("task executes", s => + { + var task = new ComputeFingerprint + { + BuildEngine = s.Engine, + DacpacPath = s.DacpacPath, + ConfigPath = s.ConfigPath, + RenamingPath = s.RenamingPath, + TemplateDir = s.TemplateDir, + FingerprintFile = s.FingerprintFile, + ConfigPropertyOverrides = "" + }; + var success = task.Execute(); + return new TaskResult(s, task, success); + }) + .Then("task succeeds", r => r.Success) + .And("fingerprint is computed", r => !string.IsNullOrEmpty(r.Task.Fingerprint)) + .Finally(r => r.Setup.Folder.Dispose()) + .AssertPassed(); + } } diff --git a/tests/JD.Efcpt.Build.Tests/SerializeConfigPropertiesTests.cs b/tests/JD.Efcpt.Build.Tests/SerializeConfigPropertiesTests.cs new file mode 100644 index 0000000..0b85a10 --- /dev/null +++ b/tests/JD.Efcpt.Build.Tests/SerializeConfigPropertiesTests.cs @@ -0,0 +1,280 @@ +using JD.Efcpt.Build.Tasks; +using JD.Efcpt.Build.Tests.Infrastructure; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit; +using Xunit.Abstractions; + +namespace JD.Efcpt.Build.Tests; + +/// +/// Tests for the SerializeConfigProperties MSBuild task. +/// +[Feature("SerializeConfigProperties: Serialize MSBuild config properties to JSON for fingerprinting")] +[Collection(nameof(AssemblySetup))] +public sealed class SerializeConfigPropertiesTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + private sealed record SetupState(TestBuildEngine Engine); + + private sealed record TaskResult( + SetupState Setup, + SerializeConfigProperties Task, + bool Success); + + private static SetupState SetupTask() + { + var engine = new TestBuildEngine(); + return new SetupState(engine); + } + + private static TaskResult ExecuteTask(SetupState setup, Action? configure = null) + { + var task = new SerializeConfigProperties + { + BuildEngine = setup.Engine + }; + + configure?.Invoke(task); + + var success = task.Execute(); + return new TaskResult(setup, task, success); + } + + [Scenario("Returns empty JSON when no properties are set")] + [Fact] + public async Task Empty_properties_returns_empty_json() + { + await Given("task with no properties", SetupTask) + .When("task executes", s => ExecuteTask(s)) + .Then("task succeeds", r => r.Success) + .And("serialized properties is empty array", r => r.Task.SerializedProperties == "[]") + .AssertPassed(); + } + + [Scenario("Serializes single property correctly")] + [Fact] + public async Task Single_property_serializes_correctly() + { + await Given("task with RootNamespace set", SetupTask) + .When("task executes", s => ExecuteTask(s, t => t.RootNamespace = "MyNamespace")) + .Then("task succeeds", r => r.Success) + .And("serialized properties contains RootNamespace", r => + r.Task.SerializedProperties.Contains("\"RootNamespace\"") && + r.Task.SerializedProperties.Contains("\"MyNamespace\"")) + .AssertPassed(); + } + + [Scenario("Serializes multiple properties correctly")] + [Fact] + public async Task Multiple_properties_serialize_correctly() + { + await Given("task with multiple properties set", SetupTask) + .When("task executes", s => ExecuteTask(s, t => + { + t.RootNamespace = "MyNamespace"; + t.DbContextName = "MyContext"; + t.UseDataAnnotations = "true"; + })) + .Then("task succeeds", r => r.Success) + .And("serialized properties contains all values", r => + r.Task.SerializedProperties.Contains("\"RootNamespace\"") && + r.Task.SerializedProperties.Contains("\"MyNamespace\"") && + r.Task.SerializedProperties.Contains("\"DbContextName\"") && + r.Task.SerializedProperties.Contains("\"MyContext\"") && + r.Task.SerializedProperties.Contains("\"UseDataAnnotations\"") && + r.Task.SerializedProperties.Contains("\"true\"")) + .AssertPassed(); + } + + [Scenario("Ignores empty and whitespace-only properties")] + [Fact] + public async Task Empty_properties_are_ignored() + { + await Given("task with some empty properties", SetupTask) + .When("task executes", s => ExecuteTask(s, t => + { + t.RootNamespace = "MyNamespace"; + t.DbContextName = ""; + t.ModelNamespace = " "; + t.UseDataAnnotations = "true"; + })) + .Then("task succeeds", r => r.Success) + .And("serialized properties excludes empty values", r => + r.Task.SerializedProperties.Contains("\"RootNamespace\"") && + !r.Task.SerializedProperties.Contains("\"DbContextName\"") && + !r.Task.SerializedProperties.Contains("\"ModelNamespace\"") && + r.Task.SerializedProperties.Contains("\"UseDataAnnotations\"")) + .AssertPassed(); + } + + [Scenario("Output is deterministic and sorted")] + [Fact] + public async Task Output_is_deterministic_and_sorted() + { + await Given("task with properties in random order", SetupTask) + .When("task executes twice", s => + { + // First execution + var result1 = ExecuteTask(s, t => + { + t.UseDataAnnotations = "true"; + t.RootNamespace = "MyNamespace"; + t.DbContextName = "MyContext"; + }); + + // Second execution with same values + var result2 = ExecuteTask(s, t => + { + t.DbContextName = "MyContext"; + t.RootNamespace = "MyNamespace"; + t.UseDataAnnotations = "true"; + }); + + return (result1.Task.SerializedProperties, result2.Task.SerializedProperties); + }) + .Then("outputs are identical", t => t.Item1 == t.Item2) + .AssertPassed(); + } + + [Scenario("Serializes all name properties")] + [Fact] + public async Task Serializes_all_name_properties() + { + await Given("task with name properties", SetupTask) + .When("task executes", s => ExecuteTask(s, t => + { + t.RootNamespace = "Root"; + t.DbContextName = "Context"; + t.DbContextNamespace = "ContextNs"; + t.ModelNamespace = "ModelNs"; + })) + .Then("task succeeds", r => r.Success) + .And("all name properties are serialized", r => + r.Task.SerializedProperties.Contains("\"RootNamespace\"") && + r.Task.SerializedProperties.Contains("\"DbContextName\"") && + r.Task.SerializedProperties.Contains("\"DbContextNamespace\"") && + r.Task.SerializedProperties.Contains("\"ModelNamespace\"")) + .AssertPassed(); + } + + [Scenario("Serializes all file layout properties")] + [Fact] + public async Task Serializes_all_file_layout_properties() + { + await Given("task with file layout properties", SetupTask) + .When("task executes", s => ExecuteTask(s, t => + { + t.OutputPath = "Output"; + t.DbContextOutputPath = "ContextOut"; + t.SplitDbContext = "true"; + t.UseSchemaFolders = "true"; + t.UseSchemaNamespaces = "false"; + })) + .Then("task succeeds", r => r.Success) + .And("all file layout properties are serialized", r => + r.Task.SerializedProperties.Contains("\"OutputPath\"") && + r.Task.SerializedProperties.Contains("\"DbContextOutputPath\"") && + r.Task.SerializedProperties.Contains("\"SplitDbContext\"") && + r.Task.SerializedProperties.Contains("\"UseSchemaFolders\"") && + r.Task.SerializedProperties.Contains("\"UseSchemaNamespaces\"")) + .AssertPassed(); + } + + [Scenario("Serializes all code generation properties")] + [Fact] + public async Task Serializes_all_code_generation_properties() + { + await Given("task with code generation properties", SetupTask) + .When("task executes", s => ExecuteTask(s, t => + { + t.EnableOnConfiguring = "true"; + t.GenerationType = "DbContext"; + t.UseDatabaseNames = "false"; + t.UseDataAnnotations = "true"; + t.UseNullableReferenceTypes = "true"; + t.UseInflector = "false"; + t.UseLegacyInflector = "false"; + t.UseManyToManyEntity = "true"; + t.UseT4 = "false"; + t.UseT4Split = "false"; + })) + .Then("task succeeds", r => r.Success) + .And("all code generation properties are serialized", r => + r.Task.SerializedProperties.Contains("\"EnableOnConfiguring\"") && + r.Task.SerializedProperties.Contains("\"GenerationType\"") && + r.Task.SerializedProperties.Contains("\"UseDatabaseNames\"") && + r.Task.SerializedProperties.Contains("\"UseDataAnnotations\"") && + r.Task.SerializedProperties.Contains("\"UseNullableReferenceTypes\"") && + r.Task.SerializedProperties.Contains("\"UseInflector\"") && + r.Task.SerializedProperties.Contains("\"UseLegacyInflector\"") && + r.Task.SerializedProperties.Contains("\"UseManyToManyEntity\"") && + r.Task.SerializedProperties.Contains("\"UseT4\"") && + r.Task.SerializedProperties.Contains("\"UseT4Split\"")) + .AssertPassed(); + } + + [Scenario("Serializes all type mapping properties")] + [Fact] + public async Task Serializes_all_type_mapping_properties() + { + await Given("task with type mapping properties", SetupTask) + .When("task executes", s => ExecuteTask(s, t => + { + t.UseDateOnlyTimeOnly = "true"; + t.UseHierarchyId = "true"; + t.UseSpatial = "true"; + t.UseNodaTime = "true"; + })) + .Then("task succeeds", r => r.Success) + .And("all type mapping properties are serialized", r => + r.Task.SerializedProperties.Contains("\"UseDateOnlyTimeOnly\"") && + r.Task.SerializedProperties.Contains("\"UseHierarchyId\"") && + r.Task.SerializedProperties.Contains("\"UseSpatial\"") && + r.Task.SerializedProperties.Contains("\"UseNodaTime\"")) + .AssertPassed(); + } + + [Scenario("Serializes special character values correctly")] + [Fact] + public async Task Serializes_special_characters_correctly() + { + await Given("task with special character values", SetupTask) + .When("task executes", s => ExecuteTask(s, t => + { + t.RootNamespace = "My.Namespace\\With\"Special'Chars"; + t.T4TemplatePath = "C:\\Path\\To\\Template.t4"; + })) + .Then("task succeeds", r => r.Success) + .And("values are present in output", r => + r.Task.SerializedProperties.Contains("RootNamespace") && + r.Task.SerializedProperties.Contains("T4TemplatePath")) + .AssertPassed(); + } + + [Scenario("JSON output is valid and parseable")] + [Fact] + public async Task JSON_output_is_valid() + { + await Given("task with multiple properties", SetupTask) + .When("task executes", s => ExecuteTask(s, t => + { + t.RootNamespace = "MyNamespace"; + t.DbContextName = "MyContext"; + t.UseDataAnnotations = "true"; + })) + .Then("task succeeds", r => r.Success) + .And("output is valid JSON", r => + { + try + { + System.Text.Json.JsonDocument.Parse(r.Task.SerializedProperties); + return true; + } + catch + { + return false; + } + }) + .AssertPassed(); + } +}