diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c85887d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,52 @@ +# EditorConfig: https://EditorConfig.org + +root = true + +# All Files +[*] +charset = utf-8 +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +# XML Configuration Files +[*.{xml,config,props,targets,nuspec,resx,ruleset,vsixmanifest,vsct,refactorlog,runsettings}] +indent_size = 2 + +# JSON Files +[*.{json,json5,webmanifest}] +indent_size = 2 + +# Project Files +[*.{csproj,sqlproj}] +indent_size = 2 + +# YAML Files +[*.{yml,yaml}] +indent_size = 2 + +# Markdown Files +[*.md] +trim_trailing_whitespace = false + +# Web Files +[*.{htm,html,js,jsm,ts,tsx,css,sass,scss,less,pcss,svg,vue}] +indent_size = 2 + +# Batch Files +[*.{cmd,bat}] +end_of_line = crlf + +# Bash Files +[*.sh] +end_of_line = lf + +[*.{cs,vb}] +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = true +dotnet_style_namespace_match_folder = true + +[*.cs] +csharp_using_directive_placement = outside_namespace +csharp_style_namespace_declarations = file_scoped:warning \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1ff0c42 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,63 @@ +############################################################################### +# Set default behavior to automatically normalize line endings. +############################################################################### +* text=auto + +############################################################################### +# Set default behavior for command prompt diff. +# +# This is need for earlier builds of msysgit that does not have it on by +# default for csharp files. +# Note: This is only used by command line +############################################################################### +#*.cs diff=csharp + +############################################################################### +# Set the merge driver for project and solution files +# +# Merging from the command prompt will add diff markers to the files if there +# are conflicts (Merging from VS is not affected by the settings below, in VS +# the diff markers are never inserted). Diff markers may cause the following +# file extensions to fail to load in VS. An alternative would be to treat +# these files as binary and thus will always conflict and require user +# intervention with every merge. To do so, just uncomment the entries below +############################################################################### +#*.sln merge=binary +#*.csproj merge=binary +#*.vbproj merge=binary +#*.vcxproj merge=binary +#*.vcproj merge=binary +#*.dbproj merge=binary +#*.fsproj merge=binary +#*.lsproj merge=binary +#*.wixproj merge=binary +#*.modelproj merge=binary +#*.sqlproj merge=binary +#*.wwaproj merge=binary + +############################################################################### +# behavior for image files +# +# image files are treated as binary by default. +############################################################################### +#*.jpg binary +#*.png binary +#*.gif binary + +############################################################################### +# diff behavior for common document formats +# +# Convert binary document formats to text before diffing them. This feature +# is only available from the command line. Turn it on by uncommenting the +# entries below. +############################################################################### +#*.doc diff=astextplain +#*.DOC diff=astextplain +#*.docx diff=astextplain +#*.DOCX diff=astextplain +#*.dot diff=astextplain +#*.DOT diff=astextplain +#*.pdf diff=astextplain +#*.PDF diff=astextplain +#*.rtf diff=astextplain +#*.RTF diff=astextplain diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..fa8c583 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,57 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: daily + time: "01:00" + timezone: "America/Chicago" + open-pull-requests-limit: 10 + + - package-ecosystem: nuget + directory: "/" + schedule: + interval: daily + time: "02:00" + timezone: "America/Chicago" + open-pull-requests-limit: 10 + groups: + Azure: + patterns: + - "Azure.*" + - "Microsoft.Azure.*" + - "Microsoft.Extensions.Azure" + AspNetCoreHealthChecks: + patterns: + - "AspNetCore.HealthChecks.*" + AspNetCore: + patterns: + - "Microsoft.AspNetCore.*" + - "Microsoft.Extensions.Features" + MicrosoftExtensions: + patterns: + - "Microsoft.Extensions.*" + EntityFrameworkCore: + patterns: + - "Microsoft.EntityFrameworkCore.*" + OpenTelemetry: + patterns: + - "OpenTelemetry.*" + Serilog: + patterns: + - "Serilog" + - "Serilog.*" + Hangfire: + patterns: + - "Hangfire" + - "Hangfire.*" + Testcontainers: + patterns: + - "Testcontainers.*" + xUnit: + patterns: + - "xunit" + - "xunit.assert" + - "xunit.core" + - "xunit.extensibility.*" + - "xunit.runner.*" diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml new file mode 100644 index 0000000..ca18873 --- /dev/null +++ b/.github/workflows/dotnet.yml @@ -0,0 +1,96 @@ +name: Build + +env: + DOTNET_NOLOGO: true + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_ENVIRONMENT: github + ASPNETCORE_ENVIRONMENT: github + BUILD_PATH: "${{github.workspace}}/artifacts" + COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} + +on: + push: + branches: + - main + - develop + tags: + - "v*" + pull_request: + branches: + - main + - develop + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Restore Dependencies + run: dotnet restore + + - name: Build Solution + run: dotnet build --no-restore --configuration Release + + - name: Run Test + run: dotnet test --no-build --configuration Release --collect:"XPlat Code Coverage" --settings coverlet.runsettings + + - name: Report Coverage + if: success() + uses: coverallsapp/github-action@v2 + with: + file: "${{github.workspace}}/test/*/TestResults/*/coverage.info" + format: lcov + + - name: Create Packages + if: success() && github.event_name != 'pull_request' + run: dotnet pack --configuration Release --no-build --output "${{env.BUILD_PATH}}" + + - name: Upload Packages + if: success() && github.event_name != 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: packages + path: "${{env.BUILD_PATH}}" + + deploy: + runs-on: ubuntu-latest + needs: build + if: success() && github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) + + steps: + - name: Download Artifact + uses: actions/download-artifact@v4 + with: + name: packages + + - name: Publish Packages GitHub + run: | + for package in $(find -name "*.nupkg"); do + echo "${0##*/}": Pushing $package... + dotnet nuget push $package --source https://nuget.pkg.github.com/loresoft/index.json --api-key ${{ secrets.GITHUB_TOKEN }} --skip-duplicate + done + + - name: Publish Packages feedz + run: | + for package in $(find -name "*.nupkg"); do + echo "${0##*/}": Pushing $package... + dotnet nuget push $package --source https://f.feedz.io/loresoft/open/nuget/index.json --api-key ${{ secrets.FEEDDZ_KEY }} --skip-duplicate + done + + - name: Publish Packages Nuget + if: startsWith(github.ref, 'refs/tags/v') + run: | + for package in $(find -name "*.nupkg"); do + echo "${0##*/}": Pushing $package... + dotnet nuget push $package --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_KEY }} --skip-duplicate + done diff --git a/Equatable.Generator.sln b/Equatable.Generator.sln new file mode 100644 index 0000000..a57cace --- /dev/null +++ b/Equatable.Generator.sln @@ -0,0 +1,69 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Equatable.Generator", "src\Equatable.Generator\Equatable.Generator.csproj", "{7CEA78BA-DFE9-4B0E-8837-11850D7C197B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Equatable.SourceGenerator", "src\Equatable.SourceGenerator\Equatable.SourceGenerator.csproj", "{919F5AB0-47F6-4B38-B934-E5ADE8C8BA11}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Build", "Build", "{9ABD9B14-9E53-463A-96B0-FA7F503BA826}" + ProjectSection(SolutionItems) = preProject + src\Directory.Build.props = src\Directory.Build.props + .github\workflows\dotnet.yml = .github\workflows\dotnet.yml + README.md = README.md + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Equatable.Generator.Tests", "test\Equatable.Generator.Tests\Equatable.Generator.Tests.csproj", "{FC77B226-0FC3-4AD4-8364-761D5FC419E5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Equatable.SourceGenerator.Tests", "test\Equatable.SourceGenerator.Tests\Equatable.SourceGenerator.Tests.csproj", "{75E86899-4DD9-4493-8EE4-CF8237F2539D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{EA286CB8-AC01-452B-9092-97DCE3817093}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Equatable.Entities", "test\Equatable.Entities\Equatable.Entities.csproj", "{E1C35715-F118-433E-B456-11FD4B7B3079}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Equatable.Comparers", "src\Equatable.Comparers\Equatable.Comparers.csproj", "{E8A054EF-222A-4CD8-9177-643050501FB1}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {7CEA78BA-DFE9-4B0E-8837-11850D7C197B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7CEA78BA-DFE9-4B0E-8837-11850D7C197B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7CEA78BA-DFE9-4B0E-8837-11850D7C197B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7CEA78BA-DFE9-4B0E-8837-11850D7C197B}.Release|Any CPU.Build.0 = Release|Any CPU + {919F5AB0-47F6-4B38-B934-E5ADE8C8BA11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {919F5AB0-47F6-4B38-B934-E5ADE8C8BA11}.Debug|Any CPU.Build.0 = Debug|Any CPU + {919F5AB0-47F6-4B38-B934-E5ADE8C8BA11}.Release|Any CPU.ActiveCfg = Release|Any CPU + {919F5AB0-47F6-4B38-B934-E5ADE8C8BA11}.Release|Any CPU.Build.0 = Release|Any CPU + {FC77B226-0FC3-4AD4-8364-761D5FC419E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FC77B226-0FC3-4AD4-8364-761D5FC419E5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FC77B226-0FC3-4AD4-8364-761D5FC419E5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FC77B226-0FC3-4AD4-8364-761D5FC419E5}.Release|Any CPU.Build.0 = Release|Any CPU + {75E86899-4DD9-4493-8EE4-CF8237F2539D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {75E86899-4DD9-4493-8EE4-CF8237F2539D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {75E86899-4DD9-4493-8EE4-CF8237F2539D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {75E86899-4DD9-4493-8EE4-CF8237F2539D}.Release|Any CPU.Build.0 = Release|Any CPU + {E1C35715-F118-433E-B456-11FD4B7B3079}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E1C35715-F118-433E-B456-11FD4B7B3079}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E1C35715-F118-433E-B456-11FD4B7B3079}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E1C35715-F118-433E-B456-11FD4B7B3079}.Release|Any CPU.Build.0 = Release|Any CPU + {E8A054EF-222A-4CD8-9177-643050501FB1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E8A054EF-222A-4CD8-9177-643050501FB1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E8A054EF-222A-4CD8-9177-643050501FB1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E8A054EF-222A-4CD8-9177-643050501FB1}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {FC77B226-0FC3-4AD4-8364-761D5FC419E5} = {EA286CB8-AC01-452B-9092-97DCE3817093} + {75E86899-4DD9-4493-8EE4-CF8237F2539D} = {EA286CB8-AC01-452B-9092-97DCE3817093} + {E1C35715-F118-433E-B456-11FD4B7B3079} = {EA286CB8-AC01-452B-9092-97DCE3817093} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {1A9BD8E7-57D6-44B0-BFC7-B7378C05D4F5} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md index 286593e..f03bbd0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ # Equatable.Generator + Source generator for Equals and GetHashCode diff --git a/coverlet.runsettings b/coverlet.runsettings new file mode 100644 index 0000000..b28750f --- /dev/null +++ b/coverlet.runsettings @@ -0,0 +1,12 @@ + + + + + + + lcov + + + + + diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..6482efc Binary files /dev/null and b/logo.png differ diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..b27b658 --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,59 @@ + + + + Source generator for Equals and GetHashCode + Copyright © $([System.DateTime]::Now.ToString(yyyy)) LoreSoft + LoreSoft + en-US + true + dotnet roslyn source-generator generator + https://github.com/loresoft/Equatable.Generator + MIT + logo.png + README.md + git + https://github.com/loresoft/Equatable.Generator + true + + + + embedded + true + false + + + + true + + + + en-US + latest + enable + enable + 1591 + + + + v + + + + + + + + + + true + \ + false + + + true + \ + false + + + + diff --git a/src/Equatable.Comparers/DictionaryEqualityComparer.cs b/src/Equatable.Comparers/DictionaryEqualityComparer.cs new file mode 100644 index 0000000..27c92f6 --- /dev/null +++ b/src/Equatable.Comparers/DictionaryEqualityComparer.cs @@ -0,0 +1,85 @@ +namespace Equatable.Comparers; + +/// +/// equality comparer instance +/// +/// The type of the keys in the dictionary. +/// The type of the values in the dictionary. +public class DictionaryEqualityComparer : IEqualityComparer> +{ + /// + /// Gets the default equality comparer for specified generic argument. + /// + public static DictionaryEqualityComparer Default { get; } = new(); + + /// + /// Initializes a new instance of the class. + /// + public DictionaryEqualityComparer() : this(EqualityComparer.Default, EqualityComparer.Default) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The that is used to determine equality of keys in a dictionary + /// The that is used to determine equality of values in a dictionary + /// or is null + public DictionaryEqualityComparer(IEqualityComparer keyComparer, IEqualityComparer valueComparer) + { + KeyComparer = keyComparer ?? throw new ArgumentNullException(nameof(keyComparer)); + ValueComparer = valueComparer ?? throw new ArgumentNullException(nameof(valueComparer)); + } + + /// + /// Gets the that is used to determine equality of keys in a dictionary + /// + public IEqualityComparer KeyComparer { get; } + + /// + /// Gets the that is used to determine equality of values in a dictionary + /// + public IEqualityComparer ValueComparer { get; } + + /// + public bool Equals(IDictionary? x, IDictionary? y) + { + if (ReferenceEquals(x, y)) + return true; + + if (x == null || y == null) + return false; + + if (x.Count != y.Count) + return false; + + foreach (var pair in x) + { + if (!y.TryGetValue(pair.Key, out var value)) + return false; + + if (!ValueComparer.Equals(pair.Value, value)) + return false; + } + + return true; + } + + /// + public int GetHashCode(IDictionary obj) + { + if (obj == null) + return 0; + + var hash = new HashCode(); + + // sort by key to ensure dictionary with different order are the same + foreach (var pair in obj.OrderBy(d => d.Key)) + { + hash.Add(pair.Key, KeyComparer); + hash.Add(pair.Value, ValueComparer); + } + + return hash.ToHashCode(); + } +} diff --git a/src/Equatable.Comparers/Equatable.Comparers.csproj b/src/Equatable.Comparers/Equatable.Comparers.csproj new file mode 100644 index 0000000..815d7e5 --- /dev/null +++ b/src/Equatable.Comparers/Equatable.Comparers.csproj @@ -0,0 +1,11 @@ + + + + netstandard2.0;net6.0;net8.0 + + + + + + + diff --git a/src/Equatable.Comparers/HashSetEqualityComparer.cs b/src/Equatable.Comparers/HashSetEqualityComparer.cs new file mode 100644 index 0000000..9c78883 --- /dev/null +++ b/src/Equatable.Comparers/HashSetEqualityComparer.cs @@ -0,0 +1,67 @@ +namespace Equatable.Comparers; + +/// +/// equality comparer instance +/// +/// The type of the values. +public class HashSetEqualityComparer : IEqualityComparer> +{ + /// + /// Gets the default equality comparer for specified generic argument. + /// + public static HashSetEqualityComparer Default { get; } = new(); + + /// + /// Initializes a new instance of the class. + /// + public HashSetEqualityComparer() : this(EqualityComparer.Default) + { + } + + /// + /// Initializes a new instance of the class. + /// + public HashSetEqualityComparer(IEqualityComparer comparer) + { + Comparer = comparer ?? throw new ArgumentNullException(nameof(comparer)); + } + + /// + /// Gets the that is used to determine equality of values + /// + public IEqualityComparer Comparer { get; } + + /// + public bool Equals(IEnumerable? x, IEnumerable? y) + { + if (ReferenceEquals(x, y)) + return true; + + if (x == null || y == null) + return false; + + if (x is ISet xSet) + return xSet.SetEquals(y); + + if (y is ISet ySet) + return ySet.SetEquals(x); + + xSet = new HashSet(x, Comparer); + return xSet.SetEquals(y); + } + + /// + public int GetHashCode(IEnumerable obj) + { + if (obj == null) + return 0; + + var hashCode = new HashCode(); + + // sort to ensure set with different order are the same + foreach (var item in obj.OrderBy(s => s)) + hashCode.Add(item, Comparer); + + return hashCode.ToHashCode(); + } +} diff --git a/src/Equatable.Comparers/ReferenceEqualityComparer.cs b/src/Equatable.Comparers/ReferenceEqualityComparer.cs new file mode 100644 index 0000000..a3db55a --- /dev/null +++ b/src/Equatable.Comparers/ReferenceEqualityComparer.cs @@ -0,0 +1,27 @@ +using System.Runtime.CompilerServices; + +namespace Equatable.Comparers; + +/// +/// Reference equality comparer instance +/// +/// The type of the value to compare. +public class ReferenceEqualityComparer : IEqualityComparer where T : class +{ + /// + /// Gets the default equality comparer + /// + public static ReferenceEqualityComparer Default { get; } = new(); + + /// + public bool Equals(T? x, T? y) + { + return ReferenceEquals(x, y); + } + + /// + public int GetHashCode(T obj) + { + return RuntimeHelpers.GetHashCode(obj); + } +} diff --git a/src/Equatable.Comparers/SequenceEqualityComparer.cs b/src/Equatable.Comparers/SequenceEqualityComparer.cs new file mode 100644 index 0000000..8c80f73 --- /dev/null +++ b/src/Equatable.Comparers/SequenceEqualityComparer.cs @@ -0,0 +1,59 @@ +namespace Equatable.Comparers; + +/// +/// equality comparer instance +/// +/// The type of the values. +public class SequenceEqualityComparer : IEqualityComparer> +{ + /// + /// Gets the default equality comparer for specified generic argument. + /// + public static SequenceEqualityComparer Default { get; } = new(); + + /// + /// Initializes a new instance of the class. + /// + public SequenceEqualityComparer() : this(EqualityComparer.Default) + { + } + + /// + /// Initializes a new instance of the class. + /// + public SequenceEqualityComparer(IEqualityComparer comparer) + { + Comparer = comparer ?? throw new ArgumentNullException(nameof(comparer)); + } + + /// + /// Gets the that is used to determine equality of values + /// + public IEqualityComparer Comparer { get; } + + /// + public bool Equals(IEnumerable? x, IEnumerable? y) + { + if (ReferenceEquals(x, y)) + return true; + + if (x == null || y == null) + return false; + + return x.SequenceEqual(y, Comparer); + } + + /// + public int GetHashCode(IEnumerable obj) + { + if (obj == null) + return 0; + + var hashCode = new HashCode(); + + foreach (var item in obj) + hashCode.Add(item, Comparer); + + return hashCode.ToHashCode(); + } +} diff --git a/src/Equatable.Generator/Attributes/DictionaryEqualityAttribute.cs b/src/Equatable.Generator/Attributes/DictionaryEqualityAttribute.cs new file mode 100644 index 0000000..8286772 --- /dev/null +++ b/src/Equatable.Generator/Attributes/DictionaryEqualityAttribute.cs @@ -0,0 +1,7 @@ +using System.Diagnostics; + +namespace Equatable.Attributes; + +[Conditional("EQUATABLE_GENERATOR")] +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] +public class DictionaryEqualityAttribute : Attribute; diff --git a/src/Equatable.Generator/Attributes/EqualityComparerAttribute.cs b/src/Equatable.Generator/Attributes/EqualityComparerAttribute.cs new file mode 100644 index 0000000..114492e --- /dev/null +++ b/src/Equatable.Generator/Attributes/EqualityComparerAttribute.cs @@ -0,0 +1,12 @@ +using System.Diagnostics; + +namespace Equatable.Attributes; + +[Conditional("EQUATABLE_GENERATOR")] +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] +public class EqualityComparerAttribute(Type equalityType, string instanceName = "Default") : Attribute +{ + public Type EqualityType { get; } = equalityType; + + public string InstanceName { get; } = instanceName; +} diff --git a/src/Equatable.Generator/Attributes/EquatableAttribute.cs b/src/Equatable.Generator/Attributes/EquatableAttribute.cs new file mode 100644 index 0000000..7d4dda1 --- /dev/null +++ b/src/Equatable.Generator/Attributes/EquatableAttribute.cs @@ -0,0 +1,18 @@ +using System.Diagnostics; + +namespace Equatable.Attributes; + +[Conditional("EQUATABLE_GENERATOR")] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +public class EquatableAttribute : Attribute +{ + /// + /// Only members marked with equality attributes will be generated for Equal and GetHashCode. + /// + public bool Explicit { get; set; } + + /// + /// Equal and GetHashCode generation do not consider members of base classes. + /// + public bool IgnoreInherited { get; set; } +} diff --git a/src/Equatable.Generator/Attributes/HashSetEqualityAttribute.cs b/src/Equatable.Generator/Attributes/HashSetEqualityAttribute.cs new file mode 100644 index 0000000..fb4931e --- /dev/null +++ b/src/Equatable.Generator/Attributes/HashSetEqualityAttribute.cs @@ -0,0 +1,7 @@ +using System.Diagnostics; + +namespace Equatable.Attributes; + +[Conditional("EQUATABLE_GENERATOR")] +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] +public class HashSetEqualityAttribute : Attribute; diff --git a/src/Equatable.Generator/Attributes/IgnoreEqualityAttribute.cs b/src/Equatable.Generator/Attributes/IgnoreEqualityAttribute.cs new file mode 100644 index 0000000..d525e17 --- /dev/null +++ b/src/Equatable.Generator/Attributes/IgnoreEqualityAttribute.cs @@ -0,0 +1,7 @@ +using System.Diagnostics; + +namespace Equatable.Attributes; + +[Conditional("EQUATABLE_GENERATOR")] +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] +public class IgnoreEqualityAttribute : Attribute; diff --git a/src/Equatable.Generator/Attributes/ReferenceEqualityAttribute.cs b/src/Equatable.Generator/Attributes/ReferenceEqualityAttribute.cs new file mode 100644 index 0000000..54550fa --- /dev/null +++ b/src/Equatable.Generator/Attributes/ReferenceEqualityAttribute.cs @@ -0,0 +1,7 @@ +using System.Diagnostics; + +namespace Equatable.Attributes; + +[Conditional("EQUATABLE_GENERATOR")] +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] +public class ReferenceEqualityAttribute : Attribute; diff --git a/src/Equatable.Generator/Attributes/SequenceEqualityAttribute.cs b/src/Equatable.Generator/Attributes/SequenceEqualityAttribute.cs new file mode 100644 index 0000000..f5a73a4 --- /dev/null +++ b/src/Equatable.Generator/Attributes/SequenceEqualityAttribute.cs @@ -0,0 +1,7 @@ +using System.Diagnostics; + +namespace Equatable.Attributes; + +[Conditional("EQUATABLE_GENERATOR")] +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] +public class SequenceEqualityAttribute : Attribute; diff --git a/src/Equatable.Generator/Attributes/StringEqualityAttribute.cs b/src/Equatable.Generator/Attributes/StringEqualityAttribute.cs new file mode 100644 index 0000000..6c5e797 --- /dev/null +++ b/src/Equatable.Generator/Attributes/StringEqualityAttribute.cs @@ -0,0 +1,10 @@ +using System.Diagnostics; + +namespace Equatable.Attributes; + +[Conditional("EQUATABLE_GENERATOR")] +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] +public class StringEqualityAttribute(StringComparison comparisonType) : Attribute +{ + public StringComparison ComparisonType { get; } = comparisonType; +} diff --git a/src/Equatable.Generator/Equatable.Generator.csproj b/src/Equatable.Generator/Equatable.Generator.csproj new file mode 100644 index 0000000..b5cb236 --- /dev/null +++ b/src/Equatable.Generator/Equatable.Generator.csproj @@ -0,0 +1,12 @@ + + + + netstandard2.0;net6.0;net8.0 + Equatable + + + + + + + diff --git a/src/Equatable.SourceGenerator/Equatable.SourceGenerator.csproj b/src/Equatable.SourceGenerator/Equatable.SourceGenerator.csproj new file mode 100644 index 0000000..9d4a2be --- /dev/null +++ b/src/Equatable.SourceGenerator/Equatable.SourceGenerator.csproj @@ -0,0 +1,28 @@ + + + + netstandard2.0 + true + false + true + true + true + false + true + false + cs + 4.3 + latest + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/src/Equatable.SourceGenerator/EquatableGenerator.cs b/src/Equatable.SourceGenerator/EquatableGenerator.cs new file mode 100644 index 0000000..40a1dd9 --- /dev/null +++ b/src/Equatable.SourceGenerator/EquatableGenerator.cs @@ -0,0 +1,237 @@ +using Equatable.SourceGenerator.Models; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Equatable.SourceGenerator; + +[Generator] +public class EquatableGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var provider = context.SyntaxProvider.ForAttributeWithMetadataName( + fullyQualifiedMetadataName: "Equatable.Attributes.EquatableAttribute", + predicate: SyntacticPredicate, + transform: SemanticTransform + ) + .Where(static context => context is not null); + + // Emit the diagnostics, if needed + var diagnostics = provider + .Select(static (item, _) => item?.Diagnostics) + .Where(static item => item?.Count > 0); + + context.RegisterSourceOutput(diagnostics, ReportDiagnostic); + + // output code + var entityClasses = provider + .Select(static (item, _) => item?.EntityClass) + .Where(static item => item is not null); + + context.RegisterSourceOutput(entityClasses, Execute); + } + + private static void ReportDiagnostic(SourceProductionContext context, EquatableArray? diagnostics) + { + if (diagnostics == null) + return; + + foreach (var diagnostic in diagnostics) + context.ReportDiagnostic(diagnostic); + } + + private static void Execute(SourceProductionContext context, EquatableClass? entityClass) + { + if (entityClass == null) + return; + + var qualifiedName = entityClass.EntityNamespace is null + ? entityClass.EntityName + : $"{entityClass.EntityNamespace}.{entityClass.EntityName}"; + + var source = EquatableWriter.Generate(entityClass); + + context.AddSource($"{qualifiedName}.Equatable.g.cs", source); + } + + + private static bool SyntacticPredicate(SyntaxNode syntaxNode, CancellationToken cancellationToken) + { + return (syntaxNode is ClassDeclarationSyntax classDeclaration && !classDeclaration.Modifiers.Any(SyntaxKind.StaticKeyword)) + || (syntaxNode is RecordDeclarationSyntax recordDeclaration && !recordDeclaration.Modifiers.Any(SyntaxKind.StaticKeyword)) + || (syntaxNode is StructDeclarationSyntax structDeclaration && !structDeclaration.Modifiers.Any(SyntaxKind.StaticKeyword)); + } + + private static EquatableContext? SemanticTransform(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) + { + if (context.TargetSymbol is not INamedTypeSymbol targetSymbol) + return null; + + var classNamespace = targetSymbol.ContainingNamespace.ToDisplayString(); + var className = targetSymbol.Name; + + var propertySymbols = GetProperties(targetSymbol); + + var propertyArray = propertySymbols + .Select(p => CreateProperty(p)) + .ToArray() ?? []; + + var entity = new EquatableClass(classNamespace, className, propertyArray); + return new EquatableContext(entity, null); + } + + + private static IEnumerable GetProperties(INamedTypeSymbol targetSymbol) + { + var properties = new Dictionary(); + + var currentSymbol = targetSymbol; + + // get nested properties + while (currentSymbol != null) + { + var propertySymbols = currentSymbol + .GetMembers() + .Where(m => m.Kind == SymbolKind.Property) + .OfType() + .Where(IsIncluded) + .Where(p => !properties.ContainsKey(p.Name)); + + foreach (var propertySymbol in propertySymbols) + properties.Add(propertySymbol.Name, propertySymbol); + + currentSymbol = currentSymbol.BaseType; + } + + return properties.Values; + } + + private static EquatableProperty CreateProperty(IPropertySymbol propertySymbol) + { + var propertyType = propertySymbol.Type.ToDisplayString(); + var propertyName = propertySymbol.Name; + + // look for custom equality + var attributes = propertySymbol.GetAttributes(); + if (attributes == null || attributes.Length == 0) + { + return new EquatableProperty( + propertyName, + propertyType, + ComparerTypes.Default); + } + + // search for known attribute + foreach (var attribute in attributes) + { + (var comparerType, var comparerName, var comparerInstance) = GetComparer(attribute); + + if (!comparerType.HasValue) + continue; + + return new EquatableProperty( + propertyName, + propertyType, + comparerType.Value, + comparerName, + comparerInstance); + } + + return new EquatableProperty( + propertyName, + propertyType, + ComparerTypes.Default); + } + + + private static (ComparerTypes? comparerType, string? comparerName, string? comparerInstance) GetComparer(AttributeData? attribute) + { + // known attributes + if (attribute == null || attribute.AttributeClass is not { ContainingNamespace: { Name: "Attributes", ContainingNamespace.Name: "Equatable" } }) + return (null, null, null); + + var className = attribute.AttributeClass?.Name; + + return className switch + { + "DictionaryEqualityAttribute" => (ComparerTypes.Dictionary, null, null), + "HashSetEqualityAttribute" => (ComparerTypes.HashSet, null, null), + "ReferenceEqualityAttribute" => (ComparerTypes.Reference, null, null), + "SequenceEqualityAttribute" => (ComparerTypes.Sequence, null, null), + "StringEqualityAttribute" => GetStringComparer(attribute), + "EqualityComparerAttribute" => GetEqualityComparer(attribute), + _ => (null, null, null), + }; + } + + private static (ComparerTypes? comparerType, string? comparerName, string? comparerInstance) GetStringComparer(AttributeData? attribute) + { + var argument = attribute?.ConstructorArguments.FirstOrDefault(); + if (argument == null || !argument.HasValue) + return (ComparerTypes.String, "CurrentCulture", null); + + var comparerName = argument?.Value switch + { + 0 => "CurrentCulture", + 1 => "CurrentCultureIgnoreCase", + 2 => "InvariantCulture", + 3 => "InvariantCultureIgnoreCase", + 4 => "Ordinal", + 5 => "OrdinalIgnoreCase", + _ => "CurrentCulture" + }; + + return (ComparerTypes.String, comparerName, null); + } + + private static (ComparerTypes? comparerType, string? comparerName, string? comparerInstance) GetEqualityComparer(AttributeData? attribute) + { + if (attribute == null) + return (ComparerTypes.Default, null, null); + + // attribute constructor + var comparerType = attribute.ConstructorArguments.FirstOrDefault(); + if (comparerType.Value is INamedTypeSymbol typeSymbol) + { + return (ComparerTypes.Custom, typeSymbol.ToDisplayString(), null); + } + + // generic attribute + var attributeClass = attribute.AttributeClass; + if (attributeClass is { IsGenericType: true } + && attributeClass.TypeArguments.Length == attributeClass.TypeParameters.Length + && attributeClass.TypeArguments.Length == 1) + { + var typeArgument = attributeClass.TypeArguments[0]; + var comparerName = typeArgument.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + return (ComparerTypes.Custom, comparerName, null); + } + + + return (ComparerTypes.Default, null, null); + } + + + private static bool IsIncluded(IPropertySymbol propertySymbol) + { + var attributes = propertySymbol.GetAttributes(); + if (attributes.Length > 0 && attributes.Any( + a => a.AttributeClass is + { + Name: "IgnoreEqualityAttribute", + ContainingNamespace: + { + Name: "Attributes", + ContainingNamespace.Name: "Equatable" + } + })) + { + return false; + } + + return !propertySymbol.IsIndexer && propertySymbol.DeclaredAccessibility == Accessibility.Public; + } +} diff --git a/src/Equatable.SourceGenerator/EquatableWriter.cs b/src/Equatable.SourceGenerator/EquatableWriter.cs new file mode 100644 index 0000000..b47bacd --- /dev/null +++ b/src/Equatable.SourceGenerator/EquatableWriter.cs @@ -0,0 +1,435 @@ +using Equatable.SourceGenerator.Models; + +using Microsoft.CodeAnalysis; + +namespace Equatable.SourceGenerator; + +public static class EquatableWriter +{ + public static string Generate(EquatableClass entityClass) + { + if (entityClass == null) + throw new ArgumentNullException(nameof(entityClass)); + + var codeBuilder = new IndentedStringBuilder(); + codeBuilder + .AppendLine("// ") + .AppendLine("#nullable enable") + .AppendLine(); + + codeBuilder + .Append("namespace ") + .AppendLine(entityClass.EntityNamespace) + .AppendLine("{") + .IncrementIndent(); + + codeBuilder + .Append("partial class ") + .Append(entityClass.EntityName) + .Append(" : global::System.IEquatable<") + .Append(entityClass.EntityName) + .AppendLine("?>") + .AppendLine("{") + .IncrementIndent(); + + GenerateEquatable(codeBuilder, entityClass); + GenerateEquals(codeBuilder, entityClass); + GenerateHashCode(codeBuilder, entityClass); + + codeBuilder + .DecrementIndent() + .AppendLine("}") // class + .DecrementIndent() + .AppendLine("}"); // namespace + + return codeBuilder.ToString(); + } + + private static void GenerateEquatable(IndentedStringBuilder codeBuilder, EquatableClass entityClass) + { + codeBuilder + .AppendLine("/// ") + .Append("public bool Equals(") + .Append(entityClass.EntityName) + .AppendLine("? other)") + .AppendLine("{") + .IncrementIndent(); + + codeBuilder + .Append("return other is not null"); + + foreach (var entityProperty in entityClass.Properties) + { + switch (entityProperty.ComparerType) + { + case ComparerTypes.Dictionary: + codeBuilder + .AppendLine() + .Append(" && ") + .Append("DictionaryEquals(") + .Append(entityProperty.PropertyName) + .Append(", other.") + .Append(entityProperty.PropertyName) + .Append(")"); + break; + case ComparerTypes.HashSet: + codeBuilder + .AppendLine() + .Append(" && ") + .Append("HashSetEquals(") + .Append(entityProperty.PropertyName) + .Append(", other.") + .Append(entityProperty.PropertyName) + .Append(")"); + break; + case ComparerTypes.Reference: + break; + case ComparerTypes.Sequence: + codeBuilder + .AppendLine() + .Append(" && ") + .Append("SequenceEquals(") + .Append(entityProperty.PropertyName) + .Append(", other.") + .Append(entityProperty.PropertyName) + .Append(")"); + break; + case ComparerTypes.String: + codeBuilder + .AppendLine() + .Append(" && ") + .Append("global::System.StringComparer.") + .Append(entityProperty.ComparerName) + .Append(".Equals(") + .Append(entityProperty.PropertyName) + .Append(", other.") + .Append(entityProperty.PropertyName) + .Append(")"); + break; + case ComparerTypes.Custom: + codeBuilder + .AppendLine() + .Append(" && ") + .Append(entityProperty.ComparerName) + .Append(".") + .Append(entityProperty.ComparerInstance ?? "Default") + .Append(".Equals(") + .Append(entityProperty.PropertyName) + .Append(", other.") + .Append(entityProperty.PropertyName) + .Append(")"); + break; + default: + codeBuilder + .AppendLine() + .Append(" && ") + .Append("global::System.Collections.Generic.EqualityComparer<") + .Append(entityProperty.PropertyType) + .Append(">.Default.Equals(") + .Append(entityProperty.PropertyName) + .Append(", other.") + .Append(entityProperty.PropertyName) + .Append(")"); + break; + } + } + + codeBuilder + .AppendLine(";") + .AppendLine(); + + GenerateEquatableFunctions(codeBuilder, entityClass); + + codeBuilder + .DecrementIndent() + .AppendLine("}") + .AppendLine(); + } + + private static void GenerateEquatableFunctions(IndentedStringBuilder codeBuilder, EquatableClass entityClass) + { + if (entityClass == null) + return; + + if (entityClass.Properties.Any(p => p.ComparerType == ComparerTypes.Dictionary)) + { + codeBuilder + .AppendLine("static bool DictionaryEquals(global::System.Collections.Generic.IDictionary? left, global::System.Collections.Generic.IDictionary? right)") + .AppendLine("{") + .IncrementIndent() + .AppendLine("if (global::System.Object.ReferenceEquals(left, right))") + .AppendLine(" return true;") + .AppendLine() + .AppendLine("if (left == null || right == null)") + .AppendLine(" return false;") + .AppendLine() + .AppendLine("if (left.Count != right.Count)") + .AppendLine(" return false;") + .AppendLine(); + + codeBuilder + .AppendLine("foreach (var pair in left)") + .AppendLine("{") + .IncrementIndent() + .AppendLine("if (!right.TryGetValue(pair.Key, out var value))") + .AppendLine(" return false;") + .AppendLine() + .AppendLine("if (!global::System.Collections.Generic.EqualityComparer.Default.Equals(pair.Value, value))") + .AppendLine(" return false;") + .AppendLine() + .DecrementIndent() + .AppendLine("}"); // foreach + + codeBuilder + .AppendLine() + .AppendLine("return true;") + .DecrementIndent() + .AppendLine("}") + .AppendLine(); + } + + if (entityClass.Properties.Any(p => p.ComparerType == ComparerTypes.HashSet)) + { + codeBuilder + .AppendLine("static bool HashSetEquals(global::System.Collections.Generic.IEnumerable? left, global::System.Collections.Generic.IEnumerable? right)") + .AppendLine("{") + .IncrementIndent() + .AppendLine("if (global::System.Object.ReferenceEquals(left, right))") + .AppendLine(" return true;") + .AppendLine() + .AppendLine("if (left == null || right == null)") + .AppendLine(" return false;") + .AppendLine() + .AppendLine("if (left is ISet leftSet)") + .AppendLine(" return leftSet.SetEquals(right);") + .AppendLine() + .AppendLine("if (right is ISet rightSet)") + .AppendLine(" return rightSet.SetEquals(left);") + .AppendLine() + .AppendLine("var hashSet = new HashSet(left, global::System.Collections.Generic.EqualityComparer.Default);") + .AppendLine("return hashSet.SetEquals(right);") + .DecrementIndent() + .AppendLine("}") + .AppendLine(); + } + + if (entityClass.Properties.Any(p => p.ComparerType == ComparerTypes.Sequence)) + { + codeBuilder + .AppendLine("static bool SequenceEquals(global::System.Collections.Generic.IEnumerable? left, global::System.Collections.Generic.IEnumerable? right)") + .AppendLine("{") + .IncrementIndent() + .AppendLine("if (global::System.Object.ReferenceEquals(left, right))") + .AppendLine(" return true;") + .AppendLine() + .AppendLine("if (left == null || right == null)") + .AppendLine(" return false;") + .AppendLine() + .AppendLine("return global::System.Linq.Enumerable.SequenceEqual(left, right, global::System.Collections.Generic.EqualityComparer.Default);") + .DecrementIndent() + .AppendLine("}") + .AppendLine(); + } + } + + private static void GenerateEquals(IndentedStringBuilder codeBuilder, EquatableClass entityClass) + { + codeBuilder + .AppendLine("/// ") + .AppendLine("public override bool Equals(object? obj)") + .AppendLine("{") + .IncrementIndent() + .Append("return Equals(obj as ") + .Append(entityClass.EntityName) + .AppendLine(");") + .DecrementIndent() + .AppendLine("}") + .AppendLine(); + + codeBuilder + .AppendLine("/// ") + .Append("public static bool operator ==(") + .Append(entityClass.EntityName) + .Append("? left, ") + .Append(entityClass.EntityName) + .AppendLine("? right)") + .AppendLine("{") + .IncrementIndent() + .Append("return Equals(left, right);") + .DecrementIndent() + .AppendLine("}") + .AppendLine(); + + codeBuilder + .AppendLine("/// ") + .Append("public static bool operator !=(") + .Append(entityClass.EntityName) + .Append("? left, ") + .Append(entityClass.EntityName) + .AppendLine("? right)") + .AppendLine("{") + .IncrementIndent() + .Append("return !Equals(left, right);") + .DecrementIndent() + .AppendLine("}") + .AppendLine(); + } + + private static void GenerateHashCode(IndentedStringBuilder codeBuilder, EquatableClass entityClass) + { + codeBuilder + .AppendLine("/// ") + .Append("public override int GetHashCode()") + .AppendLine("{") + .IncrementIndent(); + + codeBuilder + .AppendLine("int hashCode = -510739267;"); + + foreach (var entityProperty in entityClass.Properties) + { + switch (entityProperty.ComparerType) + { + case ComparerTypes.Dictionary: + codeBuilder + .Append("hashCode = (hashCode * -1521134295) + ") + .Append("DictionaryHashCode(") + .Append(entityProperty.PropertyName) + .AppendLine(");"); + break; + case ComparerTypes.HashSet: + codeBuilder + .Append("hashCode = (hashCode * -1521134295) + ") + .Append("HashSetHashCode(") + .Append(entityProperty.PropertyName) + .AppendLine(");"); + break; + case ComparerTypes.Reference: + break; + case ComparerTypes.Sequence: + codeBuilder + .Append("hashCode = (hashCode * -1521134295) + ") + .Append("SequenceHashCode(") + .Append(entityProperty.PropertyName) + .AppendLine(");"); + break; + case ComparerTypes.String: + codeBuilder + .Append("hashCode = (hashCode * -1521134295) + ") + .Append("global::System.StringComparer.") + .Append(entityProperty.ComparerName) + .Append(".GetHashCode(") + .Append(entityProperty.PropertyName) + .AppendLine("!);"); + break; + case ComparerTypes.Custom: + codeBuilder + .Append("hashCode = (hashCode * -1521134295) + ") + .Append(entityProperty.ComparerName) + .Append(".") + .Append(entityProperty.ComparerInstance ?? "Default") + .Append(".GetHashCode(") + .Append(entityProperty.PropertyName) + .AppendLine("!);"); + break; + default: + codeBuilder + .Append("hashCode = (hashCode * -1521134295) + ") + .Append("global::System.Collections.Generic.EqualityComparer<") + .Append(entityProperty.PropertyType) + .Append(">.Default.GetHashCode(") + .Append(entityProperty.PropertyName) + .AppendLine("!);"); + break; + } + } + + codeBuilder + .AppendLine("return hashCode;") + .AppendLine(); + + GenerateHashCodeFunctions(codeBuilder, entityClass); + + codeBuilder + .DecrementIndent() + .AppendLine("}") + .AppendLine(); + } + + private static void GenerateHashCodeFunctions(IndentedStringBuilder codeBuilder, EquatableClass entityClass) + { + if (entityClass == null) + return; + + if (entityClass.Properties.Any(p => p.ComparerType == ComparerTypes.Dictionary)) + { + codeBuilder + .AppendLine("static int DictionaryHashCode(global::System.Collections.Generic.IDictionary? items)") + .AppendLine("{") + .IncrementIndent() + .AppendLine("if (items == null)") + .AppendLine(" return 0;") + .AppendLine(); + + codeBuilder + .AppendLine("int hashCode = -510739267;") + .AppendLine() + .AppendLine("// sort by key to ensure dictionary with different order are the same") + .AppendLine("foreach (var item in items.OrderBy(d => d.Key))") + .AppendLine("{") + .IncrementIndent() + .AppendLine("hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Key!);") + .AppendLine("hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item.Value!);") + .DecrementIndent() + .AppendLine("}"); // foreach + + codeBuilder + .AppendLine() + .AppendLine("return hashCode;") + .DecrementIndent() + .AppendLine("}") + .AppendLine(); + } + + if (entityClass.Properties.Any(p => p.ComparerType == ComparerTypes.HashSet)) + { + codeBuilder + .AppendLine("static int HashSetHashCode(global::System.Collections.Generic.IEnumerable? items)") + .AppendLine("{") + .IncrementIndent() + .AppendLine("if (items == null)") + .AppendLine(" return 0;") + .AppendLine() + .AppendLine("int hashCode = -114976970;") + .AppendLine() + .AppendLine("// sort to ensure set with different order are the same") + .AppendLine("foreach (var item in items.OrderBy(s => s))") + .AppendLine(" hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!);") + .AppendLine() + .AppendLine("return hashCode;") + .DecrementIndent() + .AppendLine("}") + .AppendLine(); + } + + if (entityClass.Properties.Any(p => p.ComparerType == ComparerTypes.Sequence)) + { + codeBuilder + .AppendLine("static int SequenceHashCode(global::System.Collections.Generic.IEnumerable? items)") + .AppendLine("{") + .IncrementIndent() + .AppendLine("if (items == null)") + .AppendLine(" return 0;") + .AppendLine() + .AppendLine("int hashCode = -114976970;") + .AppendLine() + .AppendLine("foreach (var item in items)") + .AppendLine(" hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!);") + .AppendLine() + .AppendLine("return hashCode;") + .DecrementIndent() + .AppendLine("}") + .AppendLine(); + } + } +} diff --git a/src/Equatable.SourceGenerator/IndentedStringBuilder.cs b/src/Equatable.SourceGenerator/IndentedStringBuilder.cs new file mode 100644 index 0000000..efd5bf7 --- /dev/null +++ b/src/Equatable.SourceGenerator/IndentedStringBuilder.cs @@ -0,0 +1,360 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace Equatable.SourceGenerator; + +/// +/// A thin wrapper over that adds indentation to each line built. +/// +[ExcludeFromCodeCoverage] +public class IndentedStringBuilder +{ + private const byte IndentSize = 4; + private byte _indent; + private bool _indentPending = true; + + private readonly StringBuilder _stringBuilder = new(); + + /// + /// The current length of the built string. + /// + public virtual int Length + => _stringBuilder.Length; + + /// + /// Appends the current indent and then the given string to the string being built. + /// + /// The string to append. + /// This builder so that additional calls can be chained. + public virtual IndentedStringBuilder Append(string? value) + { + DoIndent(); + + _stringBuilder.Append(value); + + return this; + } + + /// + /// Appends the current indent and then the given string to the string being built. + /// + /// The value to append. + /// This builder so that additional calls can be chained. + public virtual IndentedStringBuilder Append(T? value) + { + if (value == null) + return this; + + DoIndent(); + + _stringBuilder.Append(value.ToString()); + + return this; + } + + /// + /// Appends the current indent and then the given char to the string being built. + /// + /// The char to append. + /// This builder so that additional calls can be chained. + public virtual IndentedStringBuilder Append(char value) + { + DoIndent(); + + _stringBuilder.Append(value); + + return this; + } + + /// + /// Appends the current indent and then the given strings to the string being built. + /// + /// The strings to append. + /// This builder so that additional calls can be chained. + public virtual IndentedStringBuilder Append(IEnumerable value) + { + DoIndent(); + + foreach (var str in value) + _stringBuilder.Append(str); + + return this; + } + + /// + /// Appends the current indent and then the given chars to the string being built. + /// + /// The chars to append. + /// This builder so that additional calls can be chained. + public virtual IndentedStringBuilder Append(IEnumerable value) + { + DoIndent(); + + foreach (var chr in value) + _stringBuilder.Append(chr); + + return this; + } + + /// + /// Appends the current indent and then the given strings to the string being built. + /// + /// The type of the members of values. + /// The string to use as a separator. separator is included in the concatenated and appended strings only if values has more than one element. + /// A collection that contains the objects to concatenate and append to the current instance of the string builder. + /// This builder so that additional calls can be chained. + public virtual IndentedStringBuilder AppendJoin(IEnumerable values, string separator) + { + if (values is null) + throw new ArgumentNullException(nameof(values)); + + separator ??= string.Empty; + + DoIndent(); + + var wroteValue = false; + + foreach (var value in values) + { + if (wroteValue) + _stringBuilder.Append(separator); + + _stringBuilder.Append(value); + wroteValue = true; + } + + return this; + } + + /// Appends the current indent and then the given strings to the string being built. + /// The type of the members of values. + /// A collection that contains the objects to concatenate and append to the current instance of the string builder. + /// The string to use as a separator. separator is included in the concatenated and appended strings only if values has more than one element. + /// + /// This builder so that additional calls can be chained. + public virtual IndentedStringBuilder AppendJoinIf(IEnumerable values, string separator, Func, bool>? condition = null) + { + var c = condition ?? (s => s.Any()); + + if (c(values)) + AppendJoin(values, separator); + + return this; + } + + /// Appends the current indent and then the given strings to the string being built. + /// The type of the members of values. + /// A collection that contains the objects to concatenate and append to the current instance of the string builder. + /// The string to use as a separator. separator is included in the concatenated and appended strings only if values has more than one element. + /// + /// This builder so that additional calls can be chained. + public virtual IndentedStringBuilder AppendJoinIf(IEnumerable values, string separator, bool condition) + { + if (condition) + AppendJoin(values, separator); + + return this; + } + + /// + /// Appends a new line to the string being built. + /// + /// This builder so that additional calls can be chained. + public virtual IndentedStringBuilder AppendLine() + { + AppendLine(string.Empty); + + return this; + } + + /// + /// + /// Appends the current indent, the given string, and a new line to the string being built. + /// + /// + /// If the given string itself contains a new line, the part of the string after that new line will not be indented. + /// + /// + /// The string to append. + /// This builder so that additional calls can be chained. + public virtual IndentedStringBuilder AppendLine(string value) + { + if (value.Length != 0) + DoIndent(); + + _stringBuilder.AppendLine(value); + + _indentPending = true; + + return this; + } + + /// + /// Separates the given string into lines, and then appends each line, prefixed + /// by the current indent and followed by a new line, to the string being built. + /// + /// The string to append. + /// If , then the terminating new line is not added after the last line. + /// This builder so that additional calls can be chained. + public virtual IndentedStringBuilder AppendLines(string value, bool skipFinalNewline = false) + { + using (var reader = new StringReader(value)) + { + var first = true; + string line; + while ((line = reader.ReadLine()) != null) + { + if (first) + first = false; + else + AppendLine(); + + if (line.Length != 0) + Append(line); + } + } + + if (!skipFinalNewline) + AppendLine(); + + return this; + } + + /// + /// Appends a copy of the specified string if is met. + /// + /// The string to append. + /// The condition delegate to evaluate. If condition is null, String.IsNullOrWhiteSpace method will be used. + public IndentedStringBuilder AppendIf(string text, Func? condition = null) + { + var c = condition ?? (s => !string.IsNullOrEmpty(s)); + + if (c(text)) + Append(text); + + return this; + } + + /// + /// Appends a copy of the specified string if is met. + /// + /// The string to append. + /// The condition delegate to evaluate. If condition is null, String.IsNullOrWhiteSpace method will be used. + public IndentedStringBuilder AppendIf(string? text, bool condition) + { + if (condition) + Append(text); + + return this; + } + + /// + /// Appends a copy of the specified string followed by the default line terminator if is met. + /// + /// The string to append. + /// The condition delegate to evaluate. If condition is null, String.IsNullOrWhiteSpace method will be used. + public IndentedStringBuilder AppendLineIf(string text, Func? condition = null) + { + var c = condition ?? (s => !string.IsNullOrEmpty(s)); + + if (c(text)) + AppendLine(text); + + return this; + } + + /// + /// Resets this builder ready to build a new string. + /// + /// This builder so that additional calls can be chained. + public virtual IndentedStringBuilder Clear() + { + _stringBuilder.Clear(); + _indent = 0; + + return this; + } + + /// + /// Increments the indent. + /// + /// This builder so that additional calls can be chained. + public virtual IndentedStringBuilder IncrementIndent() + { + _indent++; + + return this; + } + + /// + /// Decrements the indent. + /// + /// This builder so that additional calls can be chained. + public virtual IndentedStringBuilder DecrementIndent() + { + if (_indent > 0) + _indent--; + + return this; + } + + /// + /// Creates a scoped indenter that will increment the indent, then decrement it when disposed. + /// + /// An indenter. + public virtual IDisposable Indent() + => new Indenter(this); + + /// + /// Temporarily disables all indentation. Restores the original indentation when the returned object is disposed. + /// + /// An object that restores the original indentation when disposed. + public virtual IDisposable SuspendIndent() + => new IndentSuspender(this); + + /// + /// Returns the built string. + /// + /// The built string. + public override string ToString() + => _stringBuilder.ToString(); + + private void DoIndent() + { + if (_indentPending && _indent > 0) + _stringBuilder.Append(' ', _indent * IndentSize); + + _indentPending = false; + } + + private sealed class Indenter : IDisposable + { + private readonly IndentedStringBuilder _stringBuilder; + + public Indenter(IndentedStringBuilder stringBuilder) + { + _stringBuilder = stringBuilder; + + _stringBuilder.IncrementIndent(); + } + + public void Dispose() + => _stringBuilder.DecrementIndent(); + } + + private sealed class IndentSuspender : IDisposable + { + private readonly IndentedStringBuilder _stringBuilder; + private readonly byte _indent; + + public IndentSuspender(IndentedStringBuilder stringBuilder) + { + _stringBuilder = stringBuilder; + _indent = _stringBuilder._indent; + _stringBuilder._indent = 0; + } + + public void Dispose() + => _stringBuilder._indent = _indent; + } +} diff --git a/src/Equatable.SourceGenerator/Models/ComparerTypes.cs b/src/Equatable.SourceGenerator/Models/ComparerTypes.cs new file mode 100644 index 0000000..28a118d --- /dev/null +++ b/src/Equatable.SourceGenerator/Models/ComparerTypes.cs @@ -0,0 +1,12 @@ +namespace Equatable.SourceGenerator.Models; + +public enum ComparerTypes +{ + Default, + Dictionary, + HashSet, + Reference, + Sequence, + String, + Custom +} diff --git a/src/Equatable.SourceGenerator/Models/EquatableArray.cs b/src/Equatable.SourceGenerator/Models/EquatableArray.cs new file mode 100644 index 0000000..2e84f77 --- /dev/null +++ b/src/Equatable.SourceGenerator/Models/EquatableArray.cs @@ -0,0 +1,45 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; + +namespace Equatable.SourceGenerator.Models; + +public readonly struct EquatableArray(T[] array) : IEquatable>, IEnumerable + where T : IEquatable +{ + public T[] Array { get; } = array ?? []; + + public int Count => Array.Length; + + public ReadOnlySpan AsSpan() => Array.AsSpan(); + + public T[] AsArray() => Array; + + + public static bool operator ==(EquatableArray left, EquatableArray right) => left.Equals(right); + + public static bool operator !=(EquatableArray left, EquatableArray right) => !left.Equals(right); + + public bool Equals(EquatableArray array) => Array.AsSpan().SequenceEqual(array.AsSpan()); + + public override bool Equals(object? obj) => obj is EquatableArray array && Equals(this, array); + + public override int GetHashCode() + { + if (Array is not T[] array) + return 0; + + HashCode hashCode = default; + foreach (T item in array) + hashCode.Add(item); + + return hashCode.ToHashCode(); + } + + + IEnumerator IEnumerable.GetEnumerator() => (Array as IEnumerable).GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => Array.GetEnumerator(); + + + public static implicit operator EquatableArray(T[] array) => new(array); +} diff --git a/src/Equatable.SourceGenerator/Models/EquatableClass.cs b/src/Equatable.SourceGenerator/Models/EquatableClass.cs new file mode 100644 index 0000000..3105654 --- /dev/null +++ b/src/Equatable.SourceGenerator/Models/EquatableClass.cs @@ -0,0 +1,6 @@ +namespace Equatable.SourceGenerator.Models; + +public record EquatableClass( + string EntityNamespace, + string EntityName, + EquatableArray Properties); diff --git a/src/Equatable.SourceGenerator/Models/EquatableContext.cs b/src/Equatable.SourceGenerator/Models/EquatableContext.cs new file mode 100644 index 0000000..2d26593 --- /dev/null +++ b/src/Equatable.SourceGenerator/Models/EquatableContext.cs @@ -0,0 +1,7 @@ +using Microsoft.CodeAnalysis; + +namespace Equatable.SourceGenerator.Models; + +public record EquatableContext( + EquatableClass? EntityClass, + EquatableArray? Diagnostics); diff --git a/src/Equatable.SourceGenerator/Models/EquatableProperty.cs b/src/Equatable.SourceGenerator/Models/EquatableProperty.cs new file mode 100644 index 0000000..652a353 --- /dev/null +++ b/src/Equatable.SourceGenerator/Models/EquatableProperty.cs @@ -0,0 +1,8 @@ +namespace Equatable.SourceGenerator.Models; + +public record EquatableProperty( + string PropertyName, + string PropertyType, + ComparerTypes ComparerType = ComparerTypes.Default, + string? ComparerName = null, + string? ComparerInstance = null); diff --git a/src/Equatable.SourceGenerator/Properties/launchSettings.json b/src/Equatable.SourceGenerator/Properties/launchSettings.json new file mode 100644 index 0000000..a0eae00 --- /dev/null +++ b/src/Equatable.SourceGenerator/Properties/launchSettings.json @@ -0,0 +1,9 @@ +{ + "profiles": { + "Equatable.Entities": { + "commandName": "DebugRoslynComponent", + "targetProject": "..\\..\\test\\Equatable.Entities\\Equatable.Entities.csproj" + } + + } +} diff --git a/test/Equatable.Entities/Audit.cs b/test/Equatable.Entities/Audit.cs new file mode 100644 index 0000000..947ef29 --- /dev/null +++ b/test/Equatable.Entities/Audit.cs @@ -0,0 +1,15 @@ +using System; + +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class Audit : ModelBase +{ + public DateTime Date { get; set; } + public int? UserId { get; set; } + public int? TaskId { get; set; } + public string? Content { get; set; } + public string? UserName { get; set; } +} diff --git a/test/Equatable.Entities/DataType.cs b/test/Equatable.Entities/DataType.cs new file mode 100644 index 0000000..2bac84c --- /dev/null +++ b/test/Equatable.Entities/DataType.cs @@ -0,0 +1,65 @@ +using System; + +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class DataType +{ + public long Id { get; set; } + + public string Name { get; set; } = null!; + + public bool Boolean { get; set; } + + public short Short { get; set; } + + public long Long { get; set; } + + public float Float { get; set; } + + public double Double { get; set; } + + public decimal Decimal { get; set; } + + public DateTime DateTime { get; set; } + + public DateTimeOffset DateTimeOffset { get; set; } + + public Guid Guid { get; set; } + + public TimeSpan TimeSpan { get; set; } + +#if NET6_0_OR_GREATER + public DateOnly DateOnly { get; set; } + + public TimeOnly TimeOnly { get; set; } +#endif + + public bool? BooleanNull { get; set; } + + public short? ShortNull { get; set; } + + public long? LongNull { get; set; } + + public float? FloatNull { get; set; } + + public double? DoubleNull { get; set; } + + public decimal? DecimalNull { get; set; } + + public DateTime? DateTimeNull { get; set; } + + public DateTimeOffset? DateTimeOffsetNull { get; set; } + + public Guid? GuidNull { get; set; } + + public TimeSpan? TimeSpanNull { get; set; } + +#if NET6_0_OR_GREATER + public DateOnly? DateOnlyNull { get; set; } + + public TimeOnly? TimeOnlyNull { get; set; } +#endif +} diff --git a/test/Equatable.Entities/Equatable.Entities.csproj b/test/Equatable.Entities/Equatable.Entities.csproj new file mode 100644 index 0000000..ea7d562 --- /dev/null +++ b/test/Equatable.Entities/Equatable.Entities.csproj @@ -0,0 +1,27 @@ + + + + netstandard2.0;net6.0;net8.0 + false + enable + latest + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + Analyzer + false + + + + + diff --git a/test/Equatable.Entities/Member.cs b/test/Equatable.Entities/Member.cs new file mode 100644 index 0000000..05a5335 --- /dev/null +++ b/test/Equatable.Entities/Member.cs @@ -0,0 +1,19 @@ +using System; + +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class Member +{ + public Guid Id { get; set; } + + public string EmailAddress { get; set; } = null!; + + public string DisplayName { get; set; } = null!; + + public string? FirstName { get; set; } + + public string? LastName { get; set; } +} diff --git a/test/Equatable.Entities/ModelBase.cs b/test/Equatable.Entities/ModelBase.cs new file mode 100644 index 0000000..a73f93e --- /dev/null +++ b/test/Equatable.Entities/ModelBase.cs @@ -0,0 +1,21 @@ +using System; + +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public abstract partial class ModelBase +{ + public int Id { get; set; } + + public DateTimeOffset Created { get; set; } + + public string? CreatedBy { get; set; } + + public DateTimeOffset Updated { get; set; } + + public string? UpdatedBy { get; set; } + + public long RowVersion { get; set; } +} diff --git a/test/Equatable.Entities/Priority.cs b/test/Equatable.Entities/Priority.cs new file mode 100644 index 0000000..19c8c81 --- /dev/null +++ b/test/Equatable.Entities/Priority.cs @@ -0,0 +1,12 @@ +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class Priority : ModelBase +{ + public string Name { get; set; } = null!; + public string? Description { get; set; } + public int DisplayOrder { get; set; } + public bool IsActive { get; set; } +} diff --git a/test/Equatable.Entities/Role.cs b/test/Equatable.Entities/Role.cs new file mode 100644 index 0000000..435c6c6 --- /dev/null +++ b/test/Equatable.Entities/Role.cs @@ -0,0 +1,12 @@ +using System; + +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class Role : ModelBase +{ + public string Name { get; set; } = null!; + public string? Description { get; set; } +} diff --git a/test/Equatable.Entities/Status.cs b/test/Equatable.Entities/Status.cs new file mode 100644 index 0000000..8c7c1bb --- /dev/null +++ b/test/Equatable.Entities/Status.cs @@ -0,0 +1,13 @@ +using Equatable.Attributes; + +namespace Equatable.Entities; + + +[Equatable] +public partial class Status : ModelBase +{ + public string Name { get; set; } = null!; + public string? Description { get; set; } + public int DisplayOrder { get; set; } + public bool IsActive { get; set; } +} diff --git a/test/Equatable.Entities/StatusConstructor.cs b/test/Equatable.Entities/StatusConstructor.cs new file mode 100644 index 0000000..565cf7a --- /dev/null +++ b/test/Equatable.Entities/StatusConstructor.cs @@ -0,0 +1,45 @@ +using System; + +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class StatusConstructor +{ + public StatusConstructor( + int id, + string name, + string description, + bool isActive, + int displayOrder, + DateTimeOffset created, + string createdBy, + DateTimeOffset updated, + string updatedBy, + long rowVersion) + { + Id = id; + Name = name; + Description = description; + DisplayOrder = displayOrder; + IsActive = isActive; + Created = created; + CreatedBy = createdBy; + Updated = updated; + UpdatedBy = updatedBy; + RowVersion = rowVersion; + } + + public int Id { get; } + public string Name { get; } + public string Description { get; } + public int DisplayOrder { get; } + public bool IsActive { get; } + public DateTimeOffset Created { get; } + public string CreatedBy { get; } + public DateTimeOffset Updated { get; } + public string UpdatedBy { get; } + + public long RowVersion { get; } +} diff --git a/test/Equatable.Entities/StatusReadOnly.cs b/test/Equatable.Entities/StatusReadOnly.cs new file mode 100644 index 0000000..85d21c9 --- /dev/null +++ b/test/Equatable.Entities/StatusReadOnly.cs @@ -0,0 +1,22 @@ +using System; + +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class StatusReadOnly +{ + public int Id { get; init; } + + public string Name { get; init; } = null!; + public string? Description { get; init; } + public int DisplayOrder { get; init; } + public bool IsActive { get; init; } + + public DateTimeOffset Created { get; init; } + public string? CreatedBy { get; init; } + public DateTimeOffset Updated { get; init; } + public string? UpdatedBy { get; init; } + public long RowVersion { get; init; } +} diff --git a/test/Equatable.Entities/StatusRecord.cs b/test/Equatable.Entities/StatusRecord.cs new file mode 100644 index 0000000..146243a --- /dev/null +++ b/test/Equatable.Entities/StatusRecord.cs @@ -0,0 +1,18 @@ +using System; + +using Equatable.Attributes; + +namespace Equatable.Entities; + +public partial record StatusRecord( + int Id, + string Name, + string? Description, + int DisplayOrder, + bool IsActive, + DateTimeOffset Created, + string? CreatedBy, + DateTimeOffset Updated, + string? UpdatedBy, + long RowVersion +); diff --git a/test/Equatable.Entities/StatusRecordList.cs b/test/Equatable.Entities/StatusRecordList.cs new file mode 100644 index 0000000..11bf1f8 --- /dev/null +++ b/test/Equatable.Entities/StatusRecordList.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; + +using Equatable.Attributes; + +namespace Equatable.Entities; + +public partial record StatusRecordList( + int Id, + string Name, + string? Description, + int DisplayOrder, + bool IsActive, + DateTimeOffset Created, + string? CreatedBy, + DateTimeOffset Updated, + string? UpdatedBy, + long RowVersion, + List Versions +); diff --git a/test/Equatable.Entities/Task.cs b/test/Equatable.Entities/Task.cs new file mode 100644 index 0000000..66146eb --- /dev/null +++ b/test/Equatable.Entities/Task.cs @@ -0,0 +1,33 @@ +using System; + +using Equatable.Attributes; +using Equatable.Generator.Entities; + +namespace Equatable.Entities; + +[Equatable] +public partial class Task : ModelBase +{ + public int? StatusId { get; set; } + + public int? PriorityId { get; set; } + + public string Title { get; set; } = null!; + + public string? Description { get; set; } + + public DateTimeOffset? StartDate { get; set; } + + public DateTimeOffset? DueDate { get; set; } + + public DateTimeOffset? CompleteDate { get; set; } + + public int? AssignedId { get; set; } + + + public Priority? Priority { get; set; } + + public Status? Status { get; set; } + + public User? AssignedUser { get; set; } +} diff --git a/test/Equatable.Entities/User.cs b/test/Equatable.Entities/User.cs new file mode 100644 index 0000000..17f2eae --- /dev/null +++ b/test/Equatable.Entities/User.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; + +using Equatable.Attributes; +using Equatable.Entities; + +namespace Equatable.Generator.Entities; + +[Equatable] +public partial class User : ModelBase +{ + [StringEquality(StringComparison.OrdinalIgnoreCase)] + public string EmailAddress { get; set; } = null!; + + public bool IsEmailAddressConfirmed { get; set; } + + public string? DisplayName { get; set; } + + public string? FirstName { get; set; } + + public string? LastName { get; set; } + + public string? PasswordHash { get; set; } + + public string? ResetHash { get; set; } + + public string? InviteHash { get; set; } + + public int AccessFailedCount { get; set; } + + public bool LockoutEnabled { get; set; } + + public DateTimeOffset? LockoutEnd { get; set; } + + public DateTimeOffset? LastLogin { get; set; } + + public bool IsDeleted { get; set; } + + [SequenceEquality] + public List? AssignedTasks { get; set; } + + [SequenceEquality] + public List? Roles { get; set; } +} diff --git a/test/Equatable.Entities/UserImport.cs b/test/Equatable.Entities/UserImport.cs new file mode 100644 index 0000000..1a8c2bd --- /dev/null +++ b/test/Equatable.Entities/UserImport.cs @@ -0,0 +1,17 @@ +using System; + +using Equatable.Attributes; + +namespace Equatable.Entities; + +[Equatable] +public partial class UserImport +{ + [StringEquality(StringComparison.OrdinalIgnoreCase)] + public string EmailAddress { get; set; } = null!; + public string? DisplayName { get; set; } + public string? FirstName { get; set; } + public string? LastName { get; set; } + public DateTimeOffset? LockoutEnd { get; set; } + public DateTimeOffset? LastLogin { get; set; } +} diff --git a/test/Equatable.Entities/UserLogin.cs b/test/Equatable.Entities/UserLogin.cs new file mode 100644 index 0000000..add21e6 --- /dev/null +++ b/test/Equatable.Entities/UserLogin.cs @@ -0,0 +1,19 @@ +using System; + +using Equatable.Attributes; +using Equatable.Generator.Entities; + +namespace Equatable.Entities; + +[Equatable] +public partial class UserLogin : ModelBase +{ + public string? EmailAddress { get; set; } + public Guid? UserId { get; set; } + public string? UserAgent { get; set; } + public string? IpAddress { get; set; } + public bool IsSuccessful { get; set; } + public string? FailureMessage { get; set; } + + public User? User { get; set; } +} diff --git a/test/Equatable.Entities/UserRole.cs b/test/Equatable.Entities/UserRole.cs new file mode 100644 index 0000000..5eddf6a --- /dev/null +++ b/test/Equatable.Entities/UserRole.cs @@ -0,0 +1,16 @@ +using System; + +using Equatable.Attributes; +using Equatable.Generator.Entities; + +namespace Equatable.Entities; + +[Equatable] +public partial class UserRole +{ + public Guid UserId { get; set; } + public Guid RoleId { get; set; } + + public User User { get; set; } = null!; + public Role Role { get; set; } = null!; +} diff --git a/test/Equatable.Generator.Tests/Comparers/DictionaryEqualityComparerTests.cs b/test/Equatable.Generator.Tests/Comparers/DictionaryEqualityComparerTests.cs new file mode 100644 index 0000000..87fb42e --- /dev/null +++ b/test/Equatable.Generator.Tests/Comparers/DictionaryEqualityComparerTests.cs @@ -0,0 +1,173 @@ +using Equatable.Comparers; + +namespace Equatable.Generator.Tests.Comparers; + +public class DictionaryEqualityComparerTests +{ + [Fact] + public void DefaultEquals() + { + var a = new Dictionary + { + ["a"] = 10, + ["b"] = 5 + }; + + var b = new Dictionary + { + ["a"] = 10, + ["b"] = 5 + }; + + var comparer = DictionaryEqualityComparer.Default; + comparer.Equals(a, b).Should().BeTrue(); + } + + [Fact] + public void NotEqualsKeys() + { + var a = new Dictionary + { + ["a"] = 10, + ["b"] = 5 + }; + + var b = new Dictionary + { + ["c"] = -10, + ["b"] = 5 + }; + + var comparer = DictionaryEqualityComparer.Default; + comparer.Equals(a, b).Should().BeFalse(); + } + + [Fact] + public void NotEqualsValues() + { + var a = new Dictionary + { + ["a"] = 10, + ["b"] = 5 + }; + + var b = new Dictionary + { + ["a"] = -10, + ["b"] = 5 + }; + + var comparer = DictionaryEqualityComparer.Default; + comparer.Equals(a, b).Should().BeFalse(); + } + + [Fact] + public void NotEqualsValuesNull() + { + var a = new Dictionary + { + ["a"] = 10, + ["b"] = 5 + }; + + var b = new Dictionary + { + ["a"] = 10, + ["b"] = null + }; + + var comparer = DictionaryEqualityComparer.Default; + comparer.Equals(a, b).Should().BeFalse(); + } + + [Fact] + public void EqualsValuesNull() + { + var a = new Dictionary + { + ["a"] = 10, + ["b"] = null + }; + + var b = new Dictionary + { + ["a"] = 10, + ["b"] = null + }; + + var comparer = DictionaryEqualityComparer.Default; + comparer.Equals(a, b).Should().BeTrue(); + } + + [Fact] + public void NotEqualsCount() + { + var a = new Dictionary + { + ["a"] = 10, + ["b"] = 5 + }; + + var b = new Dictionary(); + + var comparer = DictionaryEqualityComparer.Default; + comparer.Equals(a, b).Should().BeFalse(); + } + + [Fact] + public void NotEqualsNull() + { + var a = new Dictionary + { + ["a"] = 10, + ["b"] = 5 + }; + + var comparer = DictionaryEqualityComparer.Default; + comparer.Equals(a, null).Should().BeFalse(); + } + + [Fact] + public void GetHashCodeSame() + { + var a = new Dictionary + { + ["a"] = 10, + ["b"] = 5 + }; + + var b = new Dictionary + { + ["a"] = 10, + ["b"] = 5 + }; + + var comparer = DictionaryEqualityComparer.Default; + var aHash = comparer.GetHashCode(a); + var bHash = comparer.GetHashCode(b); + + aHash.Should().Be(bHash); + } + + [Fact] + public void GetHashCodeSameDifferentOrder() + { + var a = new Dictionary + { + ["a"] = 10, + ["b"] = 5 + }; + + var b = new Dictionary + { + ["b"] = 5, + ["a"] = 10 + }; + + var comparer = DictionaryEqualityComparer.Default; + var aHash = comparer.GetHashCode(a); + var bHash = comparer.GetHashCode(b); + + aHash.Should().Be(bHash); + } +} diff --git a/test/Equatable.Generator.Tests/Comparers/HashSetEqualityComparerTests.cs b/test/Equatable.Generator.Tests/Comparers/HashSetEqualityComparerTests.cs new file mode 100644 index 0000000..1a5a6a5 --- /dev/null +++ b/test/Equatable.Generator.Tests/Comparers/HashSetEqualityComparerTests.cs @@ -0,0 +1,96 @@ +using Equatable.Comparers; + +namespace Equatable.Generator.Tests.Comparers; + +public class HashSetEqualityComparerTests +{ + [Fact] + public void DefaultEquals() + { + var a = new HashSet([10, 5]); + + var b = new HashSet([10, 5]); + + var comparer = HashSetEqualityComparer.Default; + comparer.Equals(a, b).Should().BeTrue(); + } + + [Fact] + public void EqualsOutOfOrder() + { + var a = new HashSet([10, 5]); + + var b = new HashSet([5, 10]); + + var comparer = HashSetEqualityComparer.Default; + comparer.Equals(a, b).Should().BeTrue(); + } + + [Fact] + public void NotEqualsValues() + { + var a = new HashSet([10, 5]); + + var b = new HashSet([-10, 5]); + + var comparer = HashSetEqualityComparer.Default; + comparer.Equals(a, b).Should().BeFalse(); + } + + [Fact] + public void NotEqualsValuesNull() + { + var a = new HashSet([10, 5]); + + var b = new HashSet([10, null]); + + var comparer = HashSetEqualityComparer.Default; + comparer.Equals(a, b).Should().BeFalse(); + } + + [Fact] + public void EqualsValuesNull() + { + var a = new HashSet([10, null]); + + var b = new HashSet([10, null]); + + + var comparer = HashSetEqualityComparer.Default; + comparer.Equals(a, b).Should().BeTrue(); + } + + [Fact] + public void NotEqualsCount() + { + var a = new HashSet([10, 5]); + + var b = new HashSet(); + + var comparer = HashSetEqualityComparer.Default; + comparer.Equals(a, b).Should().BeFalse(); + } + + [Fact] + public void NotEqualsNull() + { + var a = new HashSet([10, 5]); + + var comparer = HashSetEqualityComparer.Default; + comparer.Equals(a, null).Should().BeFalse(); + } + + [Fact] + public void GetHashCodeSame() + { + var a = new HashSet([10, 5]); + + var b = new HashSet([5, 10]); + + var comparer = HashSetEqualityComparer.Default; + var aHash = comparer.GetHashCode(a); + var bHash = comparer.GetHashCode(b); + + aHash.Should().Be(bHash); + } +} diff --git a/test/Equatable.Generator.Tests/Comparers/SequenceEqualityComparerTests.cs b/test/Equatable.Generator.Tests/Comparers/SequenceEqualityComparerTests.cs new file mode 100644 index 0000000..da95763 --- /dev/null +++ b/test/Equatable.Generator.Tests/Comparers/SequenceEqualityComparerTests.cs @@ -0,0 +1,85 @@ +using Equatable.Comparers; + +namespace Equatable.Generator.Tests.Comparers; + +public class SequenceEqualityComparerTests +{ + [Fact] + public void DefaultEquals() + { + var a = new List([10, 5]); + + var b = new List([10, 5]); + + var comparer = SequenceEqualityComparer.Default; + comparer.Equals(a, b).Should().BeTrue(); + } + + [Fact] + public void NotEqualsValues() + { + var a = new List([10, 5]); + + var b = new List([-10, 5]); + + var comparer = SequenceEqualityComparer.Default; + comparer.Equals(a, b).Should().BeFalse(); + } + + [Fact] + public void NotEqualsValuesNull() + { + var a = new List([10, 5]); + + var b = new List([10, null]); + + var comparer = SequenceEqualityComparer.Default; + comparer.Equals(a, b).Should().BeFalse(); + } + + [Fact] + public void EqualsValuesNull() + { + var a = new List([10, null]); + + var b = new List([10, null]); + + + var comparer = SequenceEqualityComparer.Default; + comparer.Equals(a, b).Should().BeTrue(); + } + + [Fact] + public void NotEqualsCount() + { + var a = new List([10, 5]); + + var b = new List(); + + var comparer = SequenceEqualityComparer.Default; + comparer.Equals(a, b).Should().BeFalse(); + } + + [Fact] + public void NotEqualsNull() + { + var a = new List([10, 5]); + + var comparer = SequenceEqualityComparer.Default; + comparer.Equals(a, null).Should().BeFalse(); + } + + [Fact] + public void GetHashCodeSame() + { + var a = new List([10, 5]); + + var b = new List([10, 5]); + + var comparer = SequenceEqualityComparer.Default; + var aHash = comparer.GetHashCode(a); + var bHash = comparer.GetHashCode(b); + + aHash.Should().Be(bHash); + } +} diff --git a/test/Equatable.Generator.Tests/Equatable.Generator.Tests.csproj b/test/Equatable.Generator.Tests/Equatable.Generator.Tests.csproj new file mode 100644 index 0000000..b017302 --- /dev/null +++ b/test/Equatable.Generator.Tests/Equatable.Generator.Tests.csproj @@ -0,0 +1,39 @@ + + + + net8.0 + enable + enable + latest + true + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + diff --git a/test/Equatable.SourceGenerator.Tests/Equatable.SourceGenerator.Tests.csproj b/test/Equatable.SourceGenerator.Tests/Equatable.SourceGenerator.Tests.csproj new file mode 100644 index 0000000..b7f5521 --- /dev/null +++ b/test/Equatable.SourceGenerator.Tests/Equatable.SourceGenerator.Tests.csproj @@ -0,0 +1,41 @@ + + + + net8.0 + enable + enable + latest + true + false + true + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + diff --git a/test/Equatable.SourceGenerator.Tests/EquatableWriterTest.cs b/test/Equatable.SourceGenerator.Tests/EquatableWriterTest.cs new file mode 100644 index 0000000..518cdee --- /dev/null +++ b/test/Equatable.SourceGenerator.Tests/EquatableWriterTest.cs @@ -0,0 +1,31 @@ +using Equatable.SourceGenerator.Models; + +namespace Equatable.SourceGenerator.Tests; + +public class EquatableWriterTest +{ + [Fact] + public async Task GenerateBasicUser() + { + var entityClass = new EquatableClass( + "Equatable.Generator.Entities", + "User", + new EquatableArray([ + new EquatableProperty("Id", "System.Guid"), + new EquatableProperty("FirstName", "string"), + new EquatableProperty("LastName", "string"), + new EquatableProperty("EmailAddress", "string", ComparerTypes.String, "OrdinalIgnoreCase"), + new EquatableProperty("Created", "System.DateTimeOffset"), + new EquatableProperty("Roles", "ICollection", ComparerTypes.Sequence), + ]) + ); + + var output = EquatableWriter.Generate(entityClass); + + await Verifier + .Verify(output) + .UseDirectory("Snapshots") + .ScrubLinesContaining("GeneratedCodeAttribute"); + } + +} diff --git a/test/Equatable.SourceGenerator.Tests/Snapshots/EquatableWriterTest.GenerateBasicUser.verified.txt b/test/Equatable.SourceGenerator.Tests/Snapshots/EquatableWriterTest.GenerateBasicUser.verified.txt new file mode 100644 index 0000000..19373a0 --- /dev/null +++ b/test/Equatable.SourceGenerator.Tests/Snapshots/EquatableWriterTest.GenerateBasicUser.verified.txt @@ -0,0 +1,75 @@ +// +#nullable enable + +namespace Equatable.Generator.Entities +{ + partial class User : global::System.IEquatable + { + /// + public bool Equals(User? other) + { + return other is not null + && global::System.Collections.Generic.EqualityComparer.Default.Equals(Id, other.Id) + && global::System.Collections.Generic.EqualityComparer.Default.Equals(FirstName, other.FirstName) + && global::System.Collections.Generic.EqualityComparer.Default.Equals(LastName, other.LastName) + && global::System.StringComparer.OrdinalIgnoreCase.Equals(EmailAddress, other.EmailAddress) + && global::System.Collections.Generic.EqualityComparer.Default.Equals(Created, other.Created) + && SequenceEquals(Roles, other.Roles); + + static bool SequenceEquals(global::System.Collections.Generic.IEnumerable? left, global::System.Collections.Generic.IEnumerable? right) + { + if (global::System.Object.ReferenceEquals(left, right)) + return true; + + if (left == null || right == null) + return false; + + return global::System.Linq.Enumerable.SequenceEqual(left, right, global::System.Collections.Generic.EqualityComparer.Default); + } + + } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as User); + } + + /// + public static bool operator ==(User? left, User? right) + { + return Equals(left, right);} + + /// + public static bool operator !=(User? left, User? right) + { + return !Equals(left, right);} + + /// + public override int GetHashCode(){ + int hashCode = -510739267; + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(Id!); + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(FirstName!); + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(LastName!); + hashCode = (hashCode * -1521134295) + global::System.StringComparer.OrdinalIgnoreCase.GetHashCode(EmailAddress!); + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(Created!); + hashCode = (hashCode * -1521134295) + SequenceHashCode(Roles); + return hashCode; + + static int SequenceHashCode(global::System.Collections.Generic.IEnumerable? items) + { + if (items == null) + return 0; + + int hashCode = -114976970; + + foreach (var item in items) + hashCode = (hashCode * -1521134295) + global::System.Collections.Generic.EqualityComparer.Default.GetHashCode(item!); + + return hashCode; + } + + } + + } +}