diff --git a/.github/workflows/build-test-publish-nuget.yml b/.github/workflows/build-test-publish-nuget.yml index e69e747..cf27505 100644 --- a/.github/workflows/build-test-publish-nuget.yml +++ b/.github/workflows/build-test-publish-nuget.yml @@ -16,12 +16,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - - name: Setup .NET Core - uses: actions/setup-dotnet@v1 + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 with: - dotnet-version: 5.0.103 + dotnet-version: 8.0.* - name: Install dependencies run: dotnet restore @@ -30,16 +30,30 @@ jobs: run: dotnet build --configuration Release --no-restore - name: Test - run: dotnet test --no-restore --verbosity normal + run: dotnet test --no-restore --verbosity normal --framework net8 - name: Publish on version change - id: publish_nuget - uses: rohith/publish-nuget@v2 - with: - PROJECT_FILE_PATH: ./src/Comparation/Comparation.csproj - VERSION_REGEX: ^\s*(.*)<\/Version>\s*$ - TAG_COMMIT: true - TAG_FORMAT: v* - NUGET_KEY: ${{secrets.NUGETKEY}} - NUGET_SOURCE: https://api.nuget.org - INCLUDE_SYMBOLS: true \ No newline at end of file + shell: bash + env: + NUGETKEY: ${{ secrets.NUGETKEY }} + run: | + function publish_project() { + ProjectName="$1" + Version=$(grep --only-matching --perl-regex '[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+)?' "$ProjectName/$ProjectName.csproj" | grep --only-matching --perl-regex '[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+)?') + echo "Version=$Version" + if test -z "$Version"; then + echo "Could not get version from $ProjectName.csproj" + exit 1 + fi + git fetch --tags + if test -n "$(git tag --list "$ProjectName-$Version" | tr -d '[:space:]')"; then + echo "Version $Version already exists for $ProjectName" + else + rm -rf nuget + dotnet pack "$ProjectName" --configuration Release --no-build --include-symbols --nologo --output nuget &&\ + dotnet nuget push nuget/*.nupkg --source 'https://api.nuget.org/v3/index.json' --api-key "$NUGETKEY" --skip-duplicate &&\ + git tag "$ProjectName-$Version" && git push origin "$ProjectName-$Version" || { echo "Error occurred"; exit 1; } + fi + } + + publish_project Comparation \ No newline at end of file diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 9cc0ccc..ad82a26 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -16,12 +16,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - - name: Setup .NET Core - uses: actions/setup-dotnet@v1 + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 with: - dotnet-version: 5.0.103 + dotnet-version: 8.0.* - name: Install dependencies run: dotnet restore @@ -30,4 +30,4 @@ jobs: run: dotnet build --configuration Release --no-restore - name: Test - run: dotnet test --no-restore --verbosity normal \ No newline at end of file + run: dotnet test --no-restore --verbosity normal --framework net8 \ No newline at end of file diff --git a/src/Comparation.Tests/Comparation.Tests.csproj b/src/Comparation.Tests/Comparation.Tests.csproj index f0b479a..f2a476f 100644 --- a/src/Comparation.Tests/Comparation.Tests.csproj +++ b/src/Comparation.Tests/Comparation.Tests.csproj @@ -1,7 +1,7 @@  - net48;net5.0 + net48;net8 ..\..\bin\Comparation.Tests true 9 @@ -13,7 +13,7 @@ annotations - + enable diff --git a/src/Comparation.Tests/Core/EqualitySuite.cs b/src/Comparation.Tests/Core/EqualitySuite.cs new file mode 100644 index 0000000..2f50c86 --- /dev/null +++ b/src/Comparation.Tests/Core/EqualitySuite.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using Comparation.Tests.Equality.Aspects; + +namespace Comparation.Tests.Core +{ + public sealed class EqualitySuite : IEnumerable> + { + private readonly string name; + private readonly IEqualityComparer sut; + private readonly Named[][] equalGroups; + + public EqualitySuite(string name, IEqualityComparer sut, Named[][] equalGroups) + { + this.name = name; + this.sut = sut; + this.equalGroups = equalGroups; + } + + private IEnumerable> Yield() + { + var allItems = equalGroups.SelectMany(group => group).ToList(); + var uniqueNames = allItems.Select(item => item.Name).Distinct().ToList(); + if (uniqueNames.Count != allItems.Count) + { + throw new Exception("Duplicate items"); + } + + return Cases(); + + IEnumerable> Cases() + { + for (var i = 0; i < equalGroups.Length; i++) + { + for (var j = 0; j < equalGroups.Length; j++) + { + for (var k = 0; k < equalGroups[i].Length; k++) + { + for (var m = 0; m < equalGroups[j].Length; m++) + { + var a = equalGroups[i][k]; + var b = equalGroups[j][m]; + var expectation = i == j; + var sign = expectation + ? "==" + : "!="; + yield return ( + $"{name}: {a.Name} {sign} {b.Name}", + new EqualityShouldWorkAsExpected( + sut, + a.Value, + b.Value, + expectation: expectation + ) + ); + + if (expectation) + { + yield return ( + $"{name}: HashCode({a.Name}) {sign} HashCode({b.Name})", + new EqualityShouldGiveSameHashCodeForEqualObjects( + sut, + a.Value, + b.Value + ) + ); + } + } + } + } + } + } + } + + public IEnumerator> GetEnumerator() => Yield().GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} \ No newline at end of file diff --git a/src/Comparation.Tests/Equality/Aspects/EqualityShouldGiveSameHashCodeForEqualObjects.cs b/src/Comparation.Tests/Equality/Aspects/EqualityShouldGiveSameHashCodeForEqualObjects.cs index 3cfee5b..6014dc4 100644 --- a/src/Comparation.Tests/Equality/Aspects/EqualityShouldGiveSameHashCodeForEqualObjects.cs +++ b/src/Comparation.Tests/Equality/Aspects/EqualityShouldGiveSameHashCodeForEqualObjects.cs @@ -7,10 +7,10 @@ namespace Comparation.Tests.Equality.Aspects public sealed class EqualityShouldGiveSameHashCodeForEqualObjects : Test { private readonly IEqualityComparer equality; - private readonly T a; - private readonly T b; + private readonly T? a; + private readonly T? b; - public EqualityShouldGiveSameHashCodeForEqualObjects(IEqualityComparer equality, T a, T b) + public EqualityShouldGiveSameHashCodeForEqualObjects(IEqualityComparer equality, T? a, T? b) { this.equality = equality; this.a = a; diff --git a/src/Comparation.Tests/Equality/Cases.cs b/src/Comparation.Tests/Equality/Cases.cs index 5f7587a..ffd78a3 100644 --- a/src/Comparation.Tests/Equality/Cases.cs +++ b/src/Comparation.Tests/Equality/Cases.cs @@ -1,4 +1,5 @@ -using System.Collections; +using System; +using System.Collections; using System.Collections.Generic; using System.Linq; using Comparation.Tests.Core; @@ -11,6 +12,41 @@ public sealed class Cases : IEnumerable { private const string Category = "Equality"; + private static EqualitySuite> StringSetSuite() + { + // ReSharper disable InconsistentNaming + Named?> nullSet = (nameof(nullSet), null); + Named?> emptySet = (nameof(emptySet), Array.Empty()); + Named?> singleNull = (nameof(singleNull), new string?[] { null }); + Named?> singleA = (nameof(singleA), new[] { "a" }); + Named?> singleBigA = (nameof(singleBigA), new[] { "A" }); + Named?> twoAs = (nameof(twoAs), new[] { "a", "A" }); + Named?> ab = (nameof(ab), new[] { "a", "b" }); + Named?> ba = (nameof(ba), new[] { "b", "a" }); + Named?> abc = (nameof(abc), new[] { "a", "b", "c" }); + Named?> ab2c = (nameof(ab2c), new[] { "a", "b", "c", "b" }); + Named?> abnull = (nameof(abnull), new[] { "a", "b", null }); + Named?> nullba = (nameof(nullba), new[] { null, "b", "a" }); + // ReSharper restore InconsistentNaming + + return new EqualitySuite>( + nameof(SetEquality), + new SetEquality( + Comparation.Equality.Of().By(@string => @string.ToLowerInvariant()) + ), + new[] + { + new[] { nullSet }, + new[] { emptySet }, + new[] { singleNull }, + new[] { singleA, singleBigA, twoAs }, + new[] { ab, ba }, + new[] { abc, ab2c }, + new[] { abnull, nullba } + } + ); + } + public IEnumerator GetEnumerator() => Sequence.Concat( new WorkAsExpected(), new Examples(), @@ -19,7 +55,8 @@ public IEnumerator GetEnumerator() => Sequence.Concat( new GiveSameHashCodeForEqualObjects(), new TreatTreatsNullNotEqualToObject(), new Transitive(), - new Equal() + new Equal(), + StringSetSuite() ) .Select(@case => new NamePrefixing(Category, @case)) .Select(@case => new TestCaseData(@case.Value).SetName(@case.Name).SetCategory(Category)) diff --git a/src/Comparation.Tests/TestsEntryPoint.cs b/src/Comparation.Tests/TestsEntryPoint.cs index 3e68c3d..5d6e5f3 100644 --- a/src/Comparation.Tests/TestsEntryPoint.cs +++ b/src/Comparation.Tests/TestsEntryPoint.cs @@ -1,8 +1,11 @@ using Comparation.Tests.Core; using NUnit.Framework; +[assembly: LevelOfParallelism(8)] + namespace Comparation.Tests { + [Parallelizable(ParallelScope.All)] public sealed class TestsEntryPoint { [Test] @@ -12,5 +15,11 @@ public void Run(Test test) { test.Run(); } + + [Test] + public void FakeTest() + { + Assert.Pass(); + } } } \ No newline at end of file diff --git a/src/Comparation/Box.cs b/src/Comparation/Box.cs index fd5b84f..ce4af49 100644 --- a/src/Comparation/Box.cs +++ b/src/Comparation/Box.cs @@ -24,16 +24,20 @@ public Equality(IEqualityComparer equality) this.equality = equality; } - public bool Equals(Box x, Box y) => (x.value, y.value) switch - { - ({ } a, { } b) => equality.Equals(a, b), - (null, null) => true, - _ => false - }; +#if NETSTANDARD2_0 || NETSTANDARD2_1 + public bool Equals(Box x, Box y) => equality.Equals(x.value!, y.value!); +#else + public bool Equals(Box x, Box y) => equality.Equals(x.value, y.value); +#endif public int GetHashCode(Box obj) => obj.value is { } value ? equality.GetHashCode(value) : 0; } } + + internal static class Box + { + public static Box Wrap(T item) => new(item); + } } \ No newline at end of file diff --git a/src/Comparation/CollectionEquality.cs b/src/Comparation/CollectionEquality.cs index 2a32ba0..63a92a8 100644 --- a/src/Comparation/CollectionEquality.cs +++ b/src/Comparation/CollectionEquality.cs @@ -1,5 +1,9 @@ using System; using System.Collections.Generic; +#if NET6_0_OR_GREATER +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +#endif namespace Comparation { @@ -77,10 +81,14 @@ public int GetHashCode(IReadOnlyCollection collection) foreach (var item in x) { var box = new Box(item); +#if NET6_0_OR_GREATER + CollectionsMarshal.GetValueRefOrAddDefault(counts, box, out _)++; +#else var newCount = counts.TryGetValue(box, out var count) ? count + 1 : 1; counts[box] = newCount; +#endif } return counts; @@ -91,21 +99,33 @@ private static bool Equals(IEnumerable y, Dictionary, int> counts) foreach (var item in y) { var box = new Box(item); +#if NET6_0_OR_GREATER + ref var count = ref CollectionsMarshal.GetValueRefOrNullRef(counts, box); + if (Unsafe.IsNullRef(ref count)) + { + return false; + } + + count--; + if (count < 0) + { + return false; + } +#else if (counts.TryGetValue(box, out var count)) { - if (count == 1) + if (count <= 0) { - counts.Remove(box); - } - else - { - counts[box] = count - 1; + return false; } + + counts[box] = count - 1; } else { return false; } +#endif } return true; diff --git a/src/Comparation/Comparation.csproj b/src/Comparation/Comparation.csproj index 23cb5a9..3d6495b 100644 --- a/src/Comparation/Comparation.csproj +++ b/src/Comparation/Comparation.csproj @@ -3,12 +3,12 @@ ..\..\bin\Comparation true + netstandard2.0;netstandard2.1;net6 9 enable - netstandard2.0;netstandard2.1;net5.0 true full false @@ -16,7 +16,6 @@ - netstandard2.0;netstandard2.1 pdbonly true diff --git a/src/Comparation/Equality.cs b/src/Comparation/Equality.cs index 14b8d6f..bf27f73 100644 --- a/src/Comparation/Equality.cs +++ b/src/Comparation/Equality.cs @@ -56,11 +56,11 @@ public sealed class Equality public IEqualityComparer Default => EqualityComparer.Default; - public IEqualityComparer By(Func projection) => + public IEqualityComparer By(Func projection) => By(projection, EqualityComparer.Default); public IEqualityComparer By( - Func projection, + Func projection, IEqualityComparer equality) => new ProjectingEquality(projection, equality); diff --git a/src/Comparation/HashCode.cs b/src/Comparation/HashCode.cs new file mode 100644 index 0000000..0a88da2 --- /dev/null +++ b/src/Comparation/HashCode.cs @@ -0,0 +1,13 @@ +#if NETSTANDARD2_0 +namespace Comparation +{ + internal struct HashCode + { + private int value; + + public void Add(int hashCode) => value = unchecked(value * 397) ^ hashCode; + + public int ToHashCode() => value; + } +} +#endif \ No newline at end of file diff --git a/src/Comparation/NullableEquality.cs b/src/Comparation/NullableEquality.cs new file mode 100644 index 0000000..a2056e0 --- /dev/null +++ b/src/Comparation/NullableEquality.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; + +namespace Comparation +{ + public sealed class NullableEquality : IEqualityComparer where TSubject : struct + { + private readonly IEqualityComparer equality; + + public NullableEquality(IEqualityComparer equality) + { + this.equality = equality; + } + + public bool Equals(TSubject? x, TSubject? y) + { + if (x == null && y == null) + { + return true; + } + + if (x == null || y == null) + { + return false; + } + + return equality.Equals(x.Value, y.Value); + } + + public int GetHashCode(TSubject? obj) + { + return obj == null + ? 0 + : equality.GetHashCode(obj.Value); + } + } +} \ No newline at end of file diff --git a/src/Comparation/ProjectingEquality.cs b/src/Comparation/ProjectingEquality.cs index 45a696c..8ecf64f 100644 --- a/src/Comparation/ProjectingEquality.cs +++ b/src/Comparation/ProjectingEquality.cs @@ -3,13 +3,12 @@ namespace Comparation { - // todo make interface generic parameter nullable public sealed class ProjectingEquality : IEqualityComparer { - private readonly Func projection; + private readonly Func projection; private readonly IEqualityComparer equality; - public ProjectingEquality(Func projection, IEqualityComparer equality) + public ProjectingEquality(Func projection, IEqualityComparer equality) { this.projection = projection; this.equality = equality; @@ -32,10 +31,17 @@ public bool Equals(TSubject? x, TSubject? y) return false; } +#if NETSTANDARD2_0 || NETSTANDARD2_1 + return equality.Equals( + projection(x)!, + projection(y)! + ); +#else return equality.Equals( projection(x), projection(y) ); +#endif } public int GetHashCode(TSubject obj) => projection(obj) is { } value diff --git a/src/Comparation/SetEquality.cs b/src/Comparation/SetEquality.cs new file mode 100644 index 0000000..4492f9c --- /dev/null +++ b/src/Comparation/SetEquality.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Comparation +{ + public sealed class SetEquality : IEqualityComparer> + { + private readonly Box.Equality itemEquality; + + public SetEquality(IEqualityComparer itemEquality) + { + this.itemEquality = new Box.Equality(itemEquality); + } + + public bool Equals(IReadOnlyCollection? x, IReadOnlyCollection? y) + { + if (ReferenceEquals(x, y)) + { + return true; + } + + if (ReferenceEquals(x, null)) + { + return false; + } + + if (ReferenceEquals(y, null)) + { + return false; + } + + var xEmpty = x.Count == 0; + var yEmpty = y.Count == 0; + if (xEmpty != yEmpty) + { + return false; + } + + var setX = new HashSet>( + x.Select(Box.Wrap), + itemEquality + ); + + var itemsY = y.Select(Box.Wrap); + return setX.SetEquals(itemsY); + } + + public int GetHashCode(IReadOnlyCollection collection) + { + var count = collection.Count; +#if NETCOREAPP2_1_OR_GREATER + var hashCodes = count <= 1024 / sizeof(int) + ? stackalloc int[count] + : new int[count]; +#else + var hashCodes = new int[count]; +#endif + var i = 0; + foreach (var item in collection) + { + hashCodes[i++] = itemEquality.GetHashCode(Box.Wrap(item)); + } + +#if NETCOREAPP2_1_OR_GREATER + hashCodes.Sort(); +#else + Array.Sort(hashCodes); +#endif + + var result = new HashCode(); + var lastHashCode = 0; + foreach (var hashCode in hashCodes) + { + if (lastHashCode == hashCode) + { + continue; + } + + result.Add(hashCode); + lastHashCode = hashCode; + } + + return result.ToHashCode(); + } + } +} \ No newline at end of file