Skip to content

Commit e279f88

Browse files
authored
perf: implemented a more intelligent DACPAC fingerprinting algorithm based on @ErikEJ's DacDeploySkip implementation (#7) (#9)
* perf: implemented a more intelligent DACPAC fingerprinting algorithm based on @ErikEJ's DacDeploySkip implementation * fix: corrected double-context inclusion issue from buildTransitive's .targets
1 parent 6ddac9a commit e279f88

8 files changed

Lines changed: 720 additions & 12 deletions

File tree

src/JD.Efcpt.Build.Tasks/ComputeFingerprint.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,12 @@ public override bool Execute()
100100
{
101101
if (!string.IsNullOrWhiteSpace(DacpacPath) && File.Exists(DacpacPath))
102102
{
103-
Append(manifest, DacpacPath, "dacpac");
104-
log.Detail($"Using DACPAC: {DacpacPath}");
103+
// Use schema-based fingerprinting instead of raw file hash
104+
// This produces consistent hashes for identical schemas even when
105+
// build-time metadata (paths, timestamps) differs
106+
var dacpacHash = DacpacFingerprint.Compute(DacpacPath);
107+
manifest.Append("dacpac").Append('\0').Append(dacpacHash).Append('\n');
108+
log.Detail($"Using DACPAC (schema fingerprint): {DacpacPath}");
105109
}
106110
}
107111

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
using System.IO.Compression;
2+
using System.IO.Hashing;
3+
using System.Text;
4+
using System.Text.RegularExpressions;
5+
6+
namespace JD.Efcpt.Build.Tasks;
7+
8+
/// <summary>
9+
/// Computes a schema-based fingerprint for DACPAC files.
10+
/// </summary>
11+
/// <remarks>
12+
/// <para>
13+
/// A DACPAC is a ZIP archive containing schema metadata. Simply hashing the entire file
14+
/// produces different results for identical schemas because build-time metadata (file paths,
15+
/// timestamps) is embedded in the archive.
16+
/// </para>
17+
/// <para>
18+
/// This class extracts and normalizes the schema-relevant content:
19+
/// <list type="bullet">
20+
/// <item><description><c>model.xml</c> - The schema definition, with path metadata normalized</description></item>
21+
/// <item><description><c>predeploy.sql</c> - Optional pre-deployment script</description></item>
22+
/// <item><description><c>postdeploy.sql</c> - Optional post-deployment script</description></item>
23+
/// </list>
24+
/// </para>
25+
/// <para>
26+
/// The implementation is based on the approach from ErikEJ/DacDeploySkip.
27+
/// </para>
28+
/// </remarks>
29+
internal static partial class DacpacFingerprint
30+
{
31+
private const string ModelXmlEntry = "model.xml";
32+
private const string PreDeployEntry = "predeploy.sql";
33+
private const string PostDeployEntry = "postdeploy.sql";
34+
35+
/// <summary>
36+
/// Computes a fingerprint for the schema content within a DACPAC file.
37+
/// </summary>
38+
/// <param name="dacpacPath">Path to the DACPAC file.</param>
39+
/// <returns>A 16-character hexadecimal fingerprint string.</returns>
40+
/// <exception cref="FileNotFoundException">The DACPAC file does not exist.</exception>
41+
/// <exception cref="InvalidOperationException">The DACPAC does not contain a model.xml file.</exception>
42+
public static string Compute(string dacpacPath)
43+
{
44+
if (!File.Exists(dacpacPath))
45+
throw new FileNotFoundException("DACPAC file not found.", dacpacPath);
46+
47+
using var archive = ZipFile.OpenRead(dacpacPath);
48+
49+
var hash = new XxHash64();
50+
51+
// Process model.xml (required)
52+
var modelEntry = archive.GetEntry(ModelXmlEntry)
53+
?? throw new InvalidOperationException($"DACPAC does not contain {ModelXmlEntry}");
54+
55+
var normalizedModel = ReadAndNormalizeModelXml(modelEntry);
56+
hash.Append(normalizedModel);
57+
58+
// Process optional pre-deployment script
59+
var preDeployEntry = archive.GetEntry(PreDeployEntry);
60+
if (preDeployEntry != null)
61+
{
62+
var preDeployContent = ReadEntryBytes(preDeployEntry);
63+
hash.Append(preDeployContent);
64+
}
65+
66+
// Process optional post-deployment script
67+
var postDeployEntry = archive.GetEntry(PostDeployEntry);
68+
if (postDeployEntry != null)
69+
{
70+
var postDeployContent = ReadEntryBytes(postDeployEntry);
71+
hash.Append(postDeployContent);
72+
}
73+
74+
return hash.GetCurrentHashAsUInt64().ToString("x16");
75+
}
76+
77+
/// <summary>
78+
/// Reads model.xml and normalizes metadata to remove build-specific paths.
79+
/// </summary>
80+
private static byte[] ReadAndNormalizeModelXml(ZipArchiveEntry entry)
81+
{
82+
using var stream = entry.Open();
83+
using var reader = new StreamReader(stream, Encoding.UTF8);
84+
var content = reader.ReadToEnd();
85+
86+
// Normalize metadata values that contain full paths
87+
// These change between builds on different machines but don't affect the schema
88+
content = NormalizeMetadataPath(content, "FileName");
89+
content = NormalizeMetadataPath(content, "AssemblySymbolsName");
90+
91+
return Encoding.UTF8.GetBytes(content);
92+
}
93+
94+
/// <summary>
95+
/// Replaces full paths in Metadata elements with just the filename.
96+
/// </summary>
97+
/// <remarks>
98+
/// Matches patterns like:
99+
/// <code>&lt;Metadata Name="FileName" Value="C:\path\to\file.dacpac" /&gt;</code>
100+
/// and replaces with:
101+
/// <code>&lt;Metadata Name="FileName" Value="file.dacpac" /&gt;</code>
102+
/// </remarks>
103+
private static string NormalizeMetadataPath(string xml, string metadataName)
104+
// Pattern matches: <Metadata Name="FileName" Value="any/path/here" />
105+
// or: <Metadata Name="FileName" Value="any\path\here" />
106+
=> MetadataRegex(metadataName).Replace(xml, match =>
107+
{
108+
var prefix = match.Groups[1].Value;
109+
var fullPath = match.Groups[2].Value;
110+
var suffix = match.Groups[3].Value;
111+
112+
// Extract just the filename from the path
113+
var fileName = GetFileName(fullPath);
114+
return $"{prefix}{fileName}{suffix}";
115+
});
116+
117+
/// <summary>
118+
/// Extracts the filename from a path, handling both forward and back slashes.
119+
/// </summary>
120+
private static string GetFileName(string path)
121+
{
122+
if (string.IsNullOrEmpty(path))
123+
return path;
124+
125+
var lastSlash = path.LastIndexOfAny(['/', '\\']);
126+
return lastSlash >= 0 ? path[(lastSlash + 1)..] : path;
127+
}
128+
129+
/// <summary>
130+
/// Reads all bytes from a ZIP archive entry.
131+
/// </summary>
132+
private static byte[] ReadEntryBytes(ZipArchiveEntry entry)
133+
{
134+
using var stream = entry.Open();
135+
using var ms = new MemoryStream();
136+
stream.CopyTo(ms);
137+
return ms.ToArray();
138+
}
139+
140+
141+
private static Regex MetadataRegex(string metadataName) => metadataName switch
142+
{
143+
"FileName" => FileNameMetadataRegex(),
144+
"AssemblySymbolsName" => AssemblySymbolsMetadataRegex(),
145+
_ => new Regex($"""(<Metadata\s+Name="{metadataName}"\s+Value=")([^"]+)(")""", RegexOptions.Compiled)
146+
};
147+
148+
/// <summary>
149+
/// Regex for matching Metadata elements with specific Name attributes.
150+
/// </summary>
151+
[GeneratedRegex("""(<Metadata\s+Name="FileName"\s+Value=")([^"]+)(")""", RegexOptions.Compiled)]
152+
private static partial Regex FileNameMetadataRegex();
153+
154+
[GeneratedRegex("""(<Metadata\s+Name="AssemblySymbolsName"\s+Value=")([^"]+)(")""", RegexOptions.Compiled)]
155+
private static partial Regex AssemblySymbolsMetadataRegex();
156+
157+
}

src/JD.Efcpt.Build/buildTransitive/JD.Efcpt.Build.targets

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,8 +258,6 @@
258258
DependsOnTargets="EfcptResolveInputs;EfcptUseDirectDacpac;EfcptEnsureDacpac;EfcptStageInputs;EfcptComputeFingerprint;EfcptGenerateModels"
259259
Condition="'$(EfcptEnabled)' == 'true'">
260260
<ItemGroup>
261-
<!-- Include root-level .g.cs files (DbContext) and all .g.cs files in Models subdirectory -->
262-
<Compile Include="$(EfcptGeneratedDir)*.g.cs" Visible="false" />
263261
<Compile Include="$(EfcptGeneratedDir)**\*.g.cs" Visible="false" />
264262
</ItemGroup>
265263
</Target>

tests/JD.Efcpt.Build.Tests/ComputeFingerprintTests.cs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ private sealed record TaskResult(
3131
private static SetupState SetupWithAllInputs()
3232
{
3333
var folder = new TestFolder();
34-
var dacpac = folder.WriteFile("db.dacpac", "DACPAC content v1");
34+
var dacpac = MockDacpacHelper.Create(folder, "db.dacpac", "Users");
3535
var config = folder.WriteFile("efcpt-config.json", "{}");
3636
var renaming = folder.WriteFile("efcpt.renaming.json", "[]");
3737
var templateDir = folder.CreateDir("Templates");
@@ -46,7 +46,7 @@ private static SetupState SetupWithAllInputs()
4646
private static SetupState SetupWithNoFingerprintFile()
4747
{
4848
var folder = new TestFolder();
49-
var dacpac = folder.WriteFile("db.dacpac", "DACPAC content");
49+
var dacpac = MockDacpacHelper.Create(folder, "db.dacpac", "Users");
5050
var config = folder.WriteFile("efcpt-config.json", "{}");
5151
var renaming = folder.WriteFile("efcpt.renaming.json", "[]");
5252
var templateDir = folder.CreateDir("Templates");
@@ -139,7 +139,9 @@ public async Task Dacpac_change_triggers_fingerprint_change()
139139
await Given("inputs with existing fingerprint", SetupWithExistingFingerprintFile)
140140
.When("DACPAC is modified and task executes", s =>
141141
{
142-
File.WriteAllText(s.DacpacPath, "DACPAC content v2 - modified!");
142+
// Delete and recreate with different schema content
143+
File.Delete(s.DacpacPath);
144+
MockDacpacHelper.Create(s.Folder, "db.dacpac", "Orders");
143145
return ExecuteTask(s);
144146
})
145147
.Then("task succeeds", r => r.Success)
@@ -301,7 +303,7 @@ public async Task Creates_fingerprint_directory()
301303
await Given("inputs with nested fingerprint path", () =>
302304
{
303305
var folder = new TestFolder();
304-
var dacpac = folder.WriteFile("db.dacpac", "content");
306+
var dacpac = MockDacpacHelper.Create(folder, "db.dacpac", "Users");
305307
var config = folder.WriteFile("efcpt-config.json", "{}");
306308
var renaming = folder.WriteFile("efcpt.renaming.json", "[]");
307309
var templateDir = folder.CreateDir("Templates");
@@ -324,7 +326,7 @@ public async Task Includes_nested_template_files()
324326
await Given("templates with nested structure", () =>
325327
{
326328
var folder = new TestFolder();
327-
var dacpac = folder.WriteFile("db.dacpac", "content");
329+
var dacpac = MockDacpacHelper.Create(folder, "db.dacpac", "Users");
328330
var config = folder.WriteFile("efcpt-config.json", "{}");
329331
var renaming = folder.WriteFile("efcpt.renaming.json", "[]");
330332
var templateDir = folder.CreateDir("Templates");

0 commit comments

Comments
 (0)