diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml new file mode 100644 index 0000000..c1b0b10 --- /dev/null +++ b/.github/workflows/build_and_test.yml @@ -0,0 +1,185 @@ +name: Build, Test, Release + +on: + workflow_dispatch: + push: + tags: + - '*' + branches: + - 'main' + - 'gh-actions*' + pull_request: + branches: + - '*' + +env: + TIMEZONE: 'America/Chicago' + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + DOTNET_NOLOGO: true + ArtifactDirectory: ${{ github.workspace}}/artifacts + +defaults: + run: + shell: pwsh + +jobs: + build_and_test: + runs-on: ubuntu-latest + permissions: + checks: write + pull-requests: write + steps: + - uses: actions/checkout@v2 + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 8 + dotnet-quality: ga + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration:Release + + - name: Test Core + if: ${{ always() && github.event_name == 'pull_request' || github.ref == 'refs/heads/main' }} + run: | + dotnet test 'tests\Distributed.Collections.Tests\' ` + --no-build ` + --configuration:Release ` + --logger:trx ` + --results-directory ${{ env.ArtifactDirectory }} + + - name: Test Redis + if: ${{ always() && github.event_name == 'pull_request' || github.ref == 'refs/heads/main' }} + run: | + dotnet test 'tests\Distributed.Collections.Redis.Tests\' ` + --no-build ` + --configuration:Release ` + --logger:trx ` + --results-directory ${{ env.ArtifactDirectory }} + + - name: Upload Test Results + if: ${{ always() && github.event_name == 'pull_request' || github.ref == 'refs/heads/main' }} + uses: actions/upload-artifact@v4 + with: + name: test-results + path: ${{ env.ArtifactDirectory }}/**/*.trx + + # Cosmos DB Emulator is broken on Ubuntu + # https://github.com/Azure/azure-cosmos-db-emulator-docker/issues/56 + + build_and_test_cosmos: + if: false + runs-on: windows-2022 + permissions: + checks: write + pull-requests: write + # Reenable when Ubuntu is fixed + # services: + # cosmos: + # image: mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator + # ports: + # - 8081:8081 + # - 10250-10255:10250-10255 + # env: + # AZURE_COSMOS_EMULATOR_PARTITION_COUNT: 3 + # AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE: true + steps: + - uses: actions/checkout@v2 + + # Remove when Ubuntu is fixed + - name: Start Cosmos DB Emulator + if: ${{ github.event_name == 'pull_request' || github.ref == 'refs/heads/main' }} + uses: southpolesteve/cosmos-emulator-github-action@v1 + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 8 + dotnet-quality: ga + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration:Release + + # Reenable when Ubuntu is fixed + # - name: Wait for Cosmos Emulator + # uses: cygnetdigital/wait_for_response@v2.0.0 + # with: + # url: 'https://localhost:8081/_explorer/index.html' + # responseCode: '200,500' + # timeout: 5000 + # interval: 500 + + - name: Test Cosmos + if: ${{ github.event_name == 'pull_request' || github.ref == 'refs/heads/main' }} + run: | + dotnet test 'tests\Distributed.Collections.Azure.Cosmos.Tests\' ` + --no-build ` + --configuration:Release ` + --logger:trx ` + --results-directory ${{ env.ArtifactDirectory }} + + - name: Upload Test Results + if: ${{ always() && github.event_name == 'pull_request' || github.ref == 'refs/heads/main' }} + uses: actions/upload-artifact@v4 + with: + name: cosmos-test-results + path: ${{ env.ArtifactDirectory }}/**/*.trx + + test_results_comment: + if: ${{ always() && github.event_name == 'pull_request' || github.ref == 'refs/heads/main' }} + runs-on: ubuntu-latest + permissions: + checks: write + pull-requests: write + needs: + - build_and_test + #- build_and_test_cosmos + + steps: + - name: Download Test Results + uses: actions/download-artifact@v4 + with: + path: ${{ env.ArtifactDirectory }} + + - name: Test Results Comment + uses: im-open/process-dotnet-test-results@v2.4 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + timezone: ${{ env.TIMEZONE }} + create-status-check: false + create-pr-comment: true + comment-identifier: 'TestResults' + + tag: + if: ${{ github.event_name != 'pull_request' }} + uses: ./.github/workflows/tag.yml + + test-tag-output: + runs-on: ubuntu-latest + needs: + - tag + steps: + - name: Echo tag output + run: | + echo version: "${{ needs.tag.outputs.version }}" + echo prerelease-depth: "${{ fromJSON(needs.tag.outputs.prerelease-depth) }}" + echo is-prerelease: "${{ fromJSON(needs.tag.outputs.is-prerelease) }}" + + release: + needs: + - tag + - build_and_test + #- build_and_test_cosmos + if: ${{ github.ref_type == 'tag' || github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && fromJSON(needs.tag.outputs.prerelease-depth) < 2 }} + uses: ./.github/workflows/release.yml + with: + version: ${{ needs.tag.outputs.version }} + is-prerelease: ${{ fromJSON(needs.tag.outputs.is-prerelease) }} + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d96148b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,69 @@ +name: Pack & Release + +on: + workflow_call: + inputs: + version: + required: true + type: string + is-prerelease: + required: true + type: boolean + secrets: + NUGET_ORG_API_KEY: + required: true +env: + MinVerVersionOverride: ${{ inputs.version }} + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + DOTNET_NOLOGO: true + ArtifactDirectory: ${{ github.workspace }}/artifacts + +defaults: + run: + shell: pwsh + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - run: | + dotnet nuget add source ` + --username USERNAME ` + --password ${{ secrets.GITHUB_TOKEN }} ` + --store-password-in-clear-text ` + --name github "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" + + - run: | + dotnet pack ` + -c Release ` + -o ${{ env.ArtifactDirectory }} ` + /p:RepositoryUrl="${{ github.server_url }}/${{ github.repository }}" ` + /p:Authors=${{ github.repository_owner }} + + - name: Echo artifact directory + run: | + echo ${{ env.ArtifactDirectory }} + ls ${{ env.ArtifactDirectory }} + + - name: Create GitHub release + id: release + uses: ncipollo/release-action@v1 + with: + tag: v${{ inputs.version }} + prerelease: ${{ inputs.is-prerelease }} + artifacts: "${{ env.ArtifactDirectory }}/*.nupkg" + + - name: Publish to pkg.github.com + run: | + dotnet nuget push "${{ env.ArtifactDirectory }}/*.nupkg" -s "github" + + - name: Publish to nuget.org + run: | + dotnet nuget push "${{ env.ArtifactDirectory }}/*.nupkg" -k ${{ secrets.NUGET_ORG_API_KEY }} -s https://api.nuget.org/v3/index.json \ No newline at end of file diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml new file mode 100644 index 0000000..b0d8831 --- /dev/null +++ b/.github/workflows/tag.yml @@ -0,0 +1,102 @@ +name: Tag + +on: + workflow_call: + outputs: + version: + description: "Generated version" + value: ${{ jobs.tag.outputs.version }} + major: + description: "Generated major version" + value: ${{ jobs.tag.outputs.major }} + minor: + description: "Generated minor version" + value: ${{ jobs.tag.outputs.minor }} + patch: + description: "Generated patch version" + value: ${{ jobs.tag.outputs.patch }} + prerelease: + description: "Generated prerelease version" + value: ${{ jobs.tag.outputs.prerelease }} + prerelease-depth: + description: "Number of periods in the prerelease version" + value: ${{ jobs.tag.outputs.prerelease-depth }} + is-prerelease: + description: "Boolean string indicating whether the version is a prerelease" + value: ${{ jobs.tag.outputs.is-prerelease }} + +env: + #MinVerIgnoreHeight: true + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + DOTNET_NOLOGO: true + +jobs: + tag: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + major: ${{ steps.version.outputs.major }} + minor: ${{ steps.version.outputs.minor }} + patch: ${{ steps.version.outputs.patch }} + prerelease: ${{ steps.version.outputs.prerelease }} + prerelease-depth: ${{ steps.set-prerelease-depth.outputs.prerelease_depth }} + is-prerelease: ${{ steps.set-is-prerelease.outputs.is_prerelease }} + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: action-minver-refreshed + id: version + uses: Stelzi79/action-minver@3.0.1 + with: + minver-version: 4.3.0 + auto-increment: patch + tag-prefix: v + default-pre-release-phase: preview + + - name: Echo version #version major minor patch prerelease + run: | + echo version: ${{ steps.version.outputs.version }} + echo major: ${{ steps.version.outputs.major }}, minor: ${{ steps.version.outputs.minor }}, patch: ${{ steps.version.outputs.patch }} + echo prerelease: ${{ steps.version.outputs.prerelease }} + + - name: Set prerelease depth + id: set-prerelease-depth + run: | + echo "prerelease_depth=$(echo '${{ steps.version.outputs.prerelease }}' | tr -cd '.' | wc -c | tr -d ' ')" >> "$GITHUB_OUTPUT" + + - name: Echo prerelease depth + run: echo "${{ steps.set-prerelease-depth.outputs.prerelease_depth }}" + + - name: Set is-prerelease + id: set-is-prerelease + run: | + echo "is_prerelease=$(echo '${{ contains(steps.version.outputs.version, '-') && 'true' || 'false' }}')" >> "$GITHUB_OUTPUT" + + - name: Echo is-prerelease + run: echo "${{ steps.set-is-prerelease.outputs.is_prerelease }}" + + - name: Tag version + if: ${{ github.ref == 'refs/heads/main' }} + run: | + git tag v${{ steps.version.outputs.version }} + git push --tags + + # Update unstable tag on main branch + - name: Tag unstable + if: ${{ github.ref_type == 'tag' || github.ref == 'refs/heads/main' }} + uses: EndBug/latest-tag@v1.5.1 + with: + ref: unstable + + # Update latest tag on main branch when tagged RTM + - name: Tag latest + if: ${{ github.ref_type == 'tag' || github.ref == 'refs/heads/main' && steps.version.outputs.prerelease == '' }} + uses: EndBug/latest-tag@v1.5.1 + + # - name: Tag version + # if: ${{ github.ref == 'refs/heads/main' }} + # uses: EndBug/latest-tag@v1.5.1 + # with: + # ref: v${{ steps.version.outputs.version }} \ No newline at end of file diff --git a/Distributed.Collections.sln b/Distributed.Collections.sln new file mode 100644 index 0000000..a05d8dd --- /dev/null +++ b/Distributed.Collections.sln @@ -0,0 +1,63 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.9.34616.47 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Distributed.Collections.Tests", "tests\Distributed.Collections.Tests\Distributed.Collections.Tests.csproj", "{DD19D032-3E66-4990-890D-3873537E816B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{797491C6-8CEB-4319-8806-ED4514648ED1}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{914818D9-D689-46FB-BE8B-1C1C03D83ECA}" + ProjectSection(SolutionItems) = preProject + .github\workflows\build_and_test.yml = .github\workflows\build_and_test.yml + .github\workflows\release.yml = .github\workflows\release.yml + .github\workflows\tag.yml = .github\workflows\tag.yml + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Distributed.Collections", "src\Distributed.Collections\Distributed.Collections.csproj", "{226DB6D6-32D9-4A65-8A70-A20FB1197C6B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Distributed.Collections.Redis", "src\Distributed.Collections.Redis\Distributed.Collections.Redis.csproj", "{F7894007-7DC2-4A4B-AE42-F5CE15B7A57B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Distributed.Collections.Redis.Tests", "tests\Distributed.Collections.Redis.Tests\Distributed.Collections.Redis.Tests.csproj", "{08BDFB09-E7E7-4FCA-9BD9-DA63D24033B2}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{2BBB78B9-083B-4237-99F5-A590E49DB789}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{512531A2-6D2D-4A62-BBC7-2BEA3C531408}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {DD19D032-3E66-4990-890D-3873537E816B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DD19D032-3E66-4990-890D-3873537E816B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DD19D032-3E66-4990-890D-3873537E816B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DD19D032-3E66-4990-890D-3873537E816B}.Release|Any CPU.Build.0 = Release|Any CPU + {226DB6D6-32D9-4A65-8A70-A20FB1197C6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {226DB6D6-32D9-4A65-8A70-A20FB1197C6B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {226DB6D6-32D9-4A65-8A70-A20FB1197C6B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {226DB6D6-32D9-4A65-8A70-A20FB1197C6B}.Release|Any CPU.Build.0 = Release|Any CPU + {F7894007-7DC2-4A4B-AE42-F5CE15B7A57B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F7894007-7DC2-4A4B-AE42-F5CE15B7A57B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F7894007-7DC2-4A4B-AE42-F5CE15B7A57B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F7894007-7DC2-4A4B-AE42-F5CE15B7A57B}.Release|Any CPU.Build.0 = Release|Any CPU + {08BDFB09-E7E7-4FCA-9BD9-DA63D24033B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {08BDFB09-E7E7-4FCA-9BD9-DA63D24033B2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {08BDFB09-E7E7-4FCA-9BD9-DA63D24033B2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {08BDFB09-E7E7-4FCA-9BD9-DA63D24033B2}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {DD19D032-3E66-4990-890D-3873537E816B} = {512531A2-6D2D-4A62-BBC7-2BEA3C531408} + {914818D9-D689-46FB-BE8B-1C1C03D83ECA} = {797491C6-8CEB-4319-8806-ED4514648ED1} + {226DB6D6-32D9-4A65-8A70-A20FB1197C6B} = {2BBB78B9-083B-4237-99F5-A590E49DB789} + {F7894007-7DC2-4A4B-AE42-F5CE15B7A57B} = {2BBB78B9-083B-4237-99F5-A590E49DB789} + {08BDFB09-E7E7-4FCA-9BD9-DA63D24033B2} = {512531A2-6D2D-4A62-BBC7-2BEA3C531408} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {A45838C5-8918-4AEE-BDC0-A6F3865CB0ED} + EndGlobalSection +EndGlobal diff --git a/src/Distributed.Collections.Redis/Distributed.Collections.Redis.csproj b/src/Distributed.Collections.Redis/Distributed.Collections.Redis.csproj new file mode 100644 index 0000000..f126ff2 --- /dev/null +++ b/src/Distributed.Collections.Redis/Distributed.Collections.Redis.csproj @@ -0,0 +1,28 @@ + + + + net6.0 + enable + disable + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/src/Distributed.Collections.Redis/RedisDistributedHashSet.cs b/src/Distributed.Collections.Redis/RedisDistributedHashSet.cs new file mode 100644 index 0000000..1f2fdab --- /dev/null +++ b/src/Distributed.Collections.Redis/RedisDistributedHashSet.cs @@ -0,0 +1,57 @@ +using StackExchange.Redis; + +namespace Distributed.Collections.Redis; + +public class RedisDistributedHashSet : IDistributedHashSet +{ + private readonly IDatabase _database; + private readonly IRedisSerializer _serializer; + private readonly string _setKey; + + public RedisDistributedHashSet(IDatabase database, string setKey) : this(database, DefaultRedisSerializer.Instance, setKey) { } + + public RedisDistributedHashSet(IDatabase database, IRedisSerializer serializer, string setKey) + { + _database = database; + _serializer = serializer; + _setKey = setKey; + } + + public async Task AddAsync(T item) + { + return await _database.SetAddAsync(_setKey, _serializer.Serialize(item)); + } + + public async Task RemoveAsync(T item) + { + return await _database.SetRemoveAsync(_setKey, _serializer.Serialize(item)); + } + + public async Task ContainsAsync(T item) + { + return await _database.SetContainsAsync(_setKey, _serializer.Serialize(item)); + } + + public async Task CountAsync() + { + return (int)await _database.SetLengthAsync(_setKey); + } + + public async Task LongCountAsync() + { + return await _database.SetLengthAsync(_setKey); + } + + public async Task ClearAsync() + { + await _database.KeyDeleteAsync(_setKey); + } + + public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = new()) + { + await foreach (var item in _database.SetScanAsync(_setKey).WithCancellation(cancellationToken)) + { + yield return _serializer.Deserialize(item); + } + } +} \ No newline at end of file diff --git a/src/Distributed.Collections.Redis/RedisDistributedHashSetFactory.cs b/src/Distributed.Collections.Redis/RedisDistributedHashSetFactory.cs new file mode 100644 index 0000000..5989c6f --- /dev/null +++ b/src/Distributed.Collections.Redis/RedisDistributedHashSetFactory.cs @@ -0,0 +1,12 @@ +using StackExchange.Redis; + +namespace Distributed.Collections.Redis; + +public class RedisDistributedHashSetFactory : IHashSetFactory +{ + private readonly IDatabase _database; + + public RedisDistributedHashSetFactory(IDatabase database) => _database = database; + + public IDistributedHashSet Create(string name) => new RedisDistributedHashSet(_database, name); +} \ No newline at end of file diff --git a/src/Distributed.Collections.Redis/RedisSerializer.cs b/src/Distributed.Collections.Redis/RedisSerializer.cs new file mode 100644 index 0000000..91b666d --- /dev/null +++ b/src/Distributed.Collections.Redis/RedisSerializer.cs @@ -0,0 +1,27 @@ +using System.Text.Json; +using StackExchange.Redis; + +namespace Distributed.Collections.Redis; + +public interface IRedisSerializer +{ + RedisValue Serialize(T value); + T Deserialize(RedisValue redisValue); +} + +internal class DefaultRedisSerializer : IRedisSerializer +{ + private static readonly Lazy _instance = new(() => new DefaultRedisSerializer()); + + public static DefaultRedisSerializer Instance => _instance.Value; + + private DefaultRedisSerializer() { } + + public RedisValue Serialize(T value) => new(JsonSerializer.Serialize(value)); + + public T Deserialize(RedisValue redisValue) => redisValue switch + { + { HasValue: true, IsNull: false } => JsonSerializer.Deserialize(redisValue.ToString()), + _ => default + }; +} \ No newline at end of file diff --git a/src/Distributed.Collections/Distributed.Collections.csproj b/src/Distributed.Collections/Distributed.Collections.csproj new file mode 100644 index 0000000..a8705d1 --- /dev/null +++ b/src/Distributed.Collections/Distributed.Collections.csproj @@ -0,0 +1,24 @@ + + + + net6.0 + enable + disable + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/src/Distributed.Collections/DistributedHashSet.cs b/src/Distributed.Collections/DistributedHashSet.cs new file mode 100644 index 0000000..095591b --- /dev/null +++ b/src/Distributed.Collections/DistributedHashSet.cs @@ -0,0 +1,69 @@ +using System.Collections.Concurrent; + +namespace Distributed.Collections; + +public interface IHashSetFactory +{ + IDistributedHashSet Create(string name); +} + +public interface IDistributedHashSet { } + +public interface IDistributedHashSet : IDistributedHashSet, IAsyncEnumerable +{ + Task AddAsync(T item); + Task RemoveAsync(T item); + Task ContainsAsync(T item); + Task CountAsync(); + Task LongCountAsync(); + Task ClearAsync(); +} + +public class InMemoryDistributedHashSet : IDistributedHashSet +{ + private record Item(T Value); + private class ItemComparer : IEqualityComparer + { + private readonly IEqualityComparer _comparer; + + public ItemComparer(IEqualityComparer comparer) => _comparer = comparer; + + public bool Equals(Item x, Item y) => _comparer.Equals(x.Value, y.Value); + + public int GetHashCode(Item obj) => _comparer.GetHashCode(obj.Value); + } + + private readonly ConcurrentDictionary _dictionary = new(new ItemComparer(EqualityComparer.Default)); + + #region IDistributedHashSet Members + + public Task AddAsync(T item) => + Task.FromResult(_dictionary.TryAdd(new Item(item), byte.MinValue)); + + public Task RemoveAsync(T item) => + Task.FromResult(_dictionary.TryRemove(new Item(item), out _)); + + public Task ContainsAsync(T item) => + Task.FromResult(_dictionary.ContainsKey(new Item(item))); + + public Task CountAsync() => + Task.FromResult(_dictionary.Count); + + public Task LongCountAsync() => + Task.FromResult((long)_dictionary.Count); + + public Task ClearAsync() + { + _dictionary.Clear(); + return Task.CompletedTask; + } + + #endregion + + #region IAsyncEnumerable Members + + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) => + _dictionary.Keys.Select(x => x.Value).ToAsyncEnumerable().GetAsyncEnumerator(cancellationToken); + + #endregion +} \ No newline at end of file diff --git a/tests/Distributed.Collections.Redis.Tests/Distributed.Collections.Redis.Tests.csproj b/tests/Distributed.Collections.Redis.Tests/Distributed.Collections.Redis.Tests.csproj new file mode 100644 index 0000000..55edc50 --- /dev/null +++ b/tests/Distributed.Collections.Redis.Tests/Distributed.Collections.Redis.Tests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + disable + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Distributed.Collections.Redis.Tests/Fixtures.cs b/tests/Distributed.Collections.Redis.Tests/Fixtures.cs new file mode 100644 index 0000000..cf61f29 --- /dev/null +++ b/tests/Distributed.Collections.Redis.Tests/Fixtures.cs @@ -0,0 +1,22 @@ +using Distributed.Collections.Tests; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using Ephemerally.Redis.Xunit; + +namespace Distributed.Collections.Redis.Tests; + +public class RedisCollectionFixture : PooledEphemeralRedisMultiplexerFixture; + +public class RedisTestContainerFixture : TestContainerFixture, IRedisInstanceFixture +{ + public ushort PublicPort => Container.GetMappedPublicPort(6379); + + public string ConnectionString => $"localhost:{PublicPort},allowAdmin=true"; + + protected override IContainer CreateContainer() => + new ContainerBuilder() + .WithImage("redis:7-alpine") + .WithPortBinding(6379, true) + .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(6379)) + .Build(); +} \ No newline at end of file diff --git a/tests/Distributed.Collections.Redis.Tests/RedisDistributedHashSetTests.cs b/tests/Distributed.Collections.Redis.Tests/RedisDistributedHashSetTests.cs new file mode 100644 index 0000000..d6468b0 --- /dev/null +++ b/tests/Distributed.Collections.Redis.Tests/RedisDistributedHashSetTests.cs @@ -0,0 +1,43 @@ +using Distributed.Collections.Tests; +using Ephemerally; +using Ephemerally.Redis; +using Ephemerally.Redis.Xunit; + +namespace Distributed.Collections.Redis.Tests; + +[Collection(RedisTestCollection.Name)] +public class RedisStringDistributedHashSetTests(RedisCollectionFixture multiplexerFixture) : + StringDistributedHashSetTests> +{ + protected override Task> CreateHashSet() => multiplexerFixture.HashSet(); +} + +[Collection(RedisTestCollection.Name)] +public class RedisIntDistributedHashSetTests(RedisCollectionFixture multiplexerFixture) : + IntDistributedHashSetTests> +{ + protected override Task> CreateHashSet() => multiplexerFixture.HashSet(); +} + +internal class EphemeralRedisDistributedHashSet : RedisDistributedHashSet, IAsyncDisposable +{ + private readonly IEphemeralRedisDatabase _database; + + public EphemeralRedisDistributedHashSet(IEphemeralRedisDatabase database, string setKey) : base(database, setKey) + { + _database = database; + } + + public EphemeralRedisDistributedHashSet(IEphemeralRedisDatabase database, IRedisSerializer serializer, string setKey) : base(database, serializer, setKey) { } + + public async ValueTask DisposeAsync() => await _database.DisposeAsync(); +} + +file static class Extensions +{ + public static Task> HashSet(this RedisMultiplexerFixture fixture) => + new EphemeralRedisDistributedHashSet( + fixture.Multiplexer.GetEphemeralDatabase(), + Guid.NewGuid().ToString() + ).ToTask>(); +} \ No newline at end of file diff --git a/tests/Distributed.Collections.Redis.Tests/RedisTestCollection.cs b/tests/Distributed.Collections.Redis.Tests/RedisTestCollection.cs new file mode 100644 index 0000000..67668ef --- /dev/null +++ b/tests/Distributed.Collections.Redis.Tests/RedisTestCollection.cs @@ -0,0 +1,7 @@ +namespace Distributed.Collections.Redis.Tests; + +[CollectionDefinition(Name)] +public class RedisTestCollection : ICollectionFixture +{ + public const string Name = "redis"; +} \ No newline at end of file diff --git a/tests/Distributed.Collections.Tests/Distributed.Collections.Tests.csproj b/tests/Distributed.Collections.Tests/Distributed.Collections.Tests.csproj new file mode 100644 index 0000000..a27c230 --- /dev/null +++ b/tests/Distributed.Collections.Tests/Distributed.Collections.Tests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Distributed.Collections.Tests/DistributedHashSetTests.cs b/tests/Distributed.Collections.Tests/DistributedHashSetTests.cs new file mode 100644 index 0000000..c252bd7 --- /dev/null +++ b/tests/Distributed.Collections.Tests/DistributedHashSetTests.cs @@ -0,0 +1,229 @@ +using Shouldly; + +namespace Distributed.Collections.Tests; + +public abstract class StringDistributedHashSetTests + : DistributedHashSetTests + where THashSet : IDistributedHashSet +{ + protected sealed override string[] TestValues => ["one", "five", "negative ten", "infinity", "negative infinity"]; +} + +public abstract class IntDistributedHashSetTests + : DistributedHashSetTests + where THashSet : IDistributedHashSet +{ + protected sealed override int[] TestValues => [1, 5, -10, int.MinValue, int.MaxValue]; +} + +public abstract class DistributedHashSetTests : + IAsyncLifetime + where THashSet : IDistributedHashSet +{ + private bool _disposed; + + private readonly Lazy> _hashSet; + + protected DistributedHashSetTests() => _hashSet = new(CreateHashSet); + + protected THashSet HashSet => _hashSet.Value.Result; + + + #region AddAsync Tests + + [Fact] + public async Task AddAsync_should_add_item_when_it_does_not_exist() + { + // Arrange + + // Act + var result = await HashSet.AddAsync(TestValues[0]); + + // Assert + result.ShouldBeTrue(); + } + + [Fact] + public async Task AddAsync_should_not_add_item_when_it_already_exists() + { + // Arrange + await HashSet.AddAsync(TestValues[0]); + + // Act + var result = await HashSet.AddAsync(TestValues[0]); + + // Assert + result.ShouldBeFalse(); + } + + #endregion + + #region RemoveAsync Tests + + [Fact] + public async Task RemoveAsync_should_remove_item_when_it_exists() + { + // Arrange + await HashSet.AddAsync(TestValues[0]); + + // Act + var result = await HashSet.RemoveAsync(TestValues[0]); + + // Assert + result.ShouldBeTrue(); + } + + [Fact] + public async Task RemoveAsync_should_not_remove_item_when_it_does_not_exist() + { + // Arrange + + // Act + var result = await HashSet.RemoveAsync(TestValues[0]); + + // Assert + result.ShouldBeFalse(); + } + + #endregion + + #region ContainsAsync Tests + + [Fact] + public async Task ContainsAsync_should_return_true_when_item_exists() + { + // Arrange + await HashSet.AddAsync(TestValues[0]); + + // Act + var result = await HashSet.ContainsAsync(TestValues[0]); + + // Assert + result.ShouldBeTrue(); + } + + [Fact] + public async Task ContainsAsync_should_return_false_when_item_does_not_exist() + { + // Arrange + + // Act + var result = await HashSet.ContainsAsync(TestValues[0]); + + // Assert + result.ShouldBeFalse(); + } + + #endregion + + #region CountAsync Tests + + [Fact] + public async Task CountAsync_should_return_0_when_hash_set_is_empty() + { + // Arrange + + // Act + var result = await HashSet.CountAsync(); + + // Assert + result.ShouldBe(0); + } + + [Fact] + public async Task CountAsync_should_return_1_when_hash_set_has_one_item() + { + // Arrange + await HashSet.AddAsync(TestValues[0]); + + // Act + var result = await HashSet.CountAsync(); + + // Assert + result.ShouldBe(1); + } + + #endregion + + #region LongCountAsync Tests + + [Fact] + public async Task LongCountAsync_should_return_0_when_hash_set_is_empty() + { + // Arrange + + // Act + var result = await HashSet.LongCountAsync(); + + // Assert + result.ShouldBe(0); + } + + [Fact] + public async Task LongCountAsync_should_return_1_when_hash_set_has_one_item() + { + // Arrange + await HashSet.AddAsync(TestValues[0]); + + // Act + var result = await HashSet.LongCountAsync(); + + // Assert + result.ShouldBe(1); + } + + #endregion + + #region ClearAsync Tests + + [Fact] + public async Task ClearAsync_should_remove_all_items() + { + // Arrange + await HashSet.AddAsync(TestValues[0]); + + // Act + await HashSet.ClearAsync(); + + // Assert + var count = await HashSet.CountAsync(); + count.ShouldBe(0); + } + + #endregion + + #region IAsyncEnumerable Tests + + [Fact] + public async Task IAsyncEnumerable_should_enumerate_all_items() + { + // Arrange + foreach (var item in TestValues) + { + await HashSet.AddAsync(item); + } + + // Act + var items = await HashSet.ToArrayAsync(); + + // Assert + items.ShouldBe(TestValues, true); + } + + #endregion + + protected abstract Task CreateHashSet(); + + protected abstract T[] TestValues { get; } + + public async Task InitializeAsync() => await _hashSet.Value; + + public async Task DisposeAsync() + { + if (_disposed || !_hashSet.IsValueCreated) return; + + _disposed = true; + + await HashSet.TryDisposeAsync(); + } +} \ No newline at end of file diff --git a/tests/Distributed.Collections.Tests/Extensions.cs b/tests/Distributed.Collections.Tests/Extensions.cs new file mode 100644 index 0000000..a3f2c95 --- /dev/null +++ b/tests/Distributed.Collections.Tests/Extensions.cs @@ -0,0 +1,24 @@ +namespace Distributed.Collections.Tests; + +public static class Extensions +{ + public static Task ToTask(this T value) => Task.FromResult(value); + + public static async ValueTask TryDisposeAsync(this T self) + { + if (self is not IAsyncDisposable disposable) + return false; + + await disposable.DisposeAsync().ConfigureAwait(false); + return true; + } + + public static bool TryDispose(this T self) + { + if (self is not IDisposable disposable) + return false; + + disposable.Dispose(); + return true; + } +} \ No newline at end of file diff --git a/tests/Distributed.Collections.Tests/InMemoryDistributedHashSetTests.cs b/tests/Distributed.Collections.Tests/InMemoryDistributedHashSetTests.cs new file mode 100644 index 0000000..beb20ff --- /dev/null +++ b/tests/Distributed.Collections.Tests/InMemoryDistributedHashSetTests.cs @@ -0,0 +1,15 @@ +namespace Distributed.Collections.Tests; + +public class InMemoryStringDistributedHashSetTests : + StringDistributedHashSetTests> +{ + protected override Task> CreateHashSet() => + new InMemoryDistributedHashSet().ToTask(); +} + +public class InMemoryIntDistributedHashSetTests : + IntDistributedHashSetTests> +{ + protected override Task> CreateHashSet() => + new InMemoryDistributedHashSet().ToTask(); +} \ No newline at end of file diff --git a/tests/Distributed.Collections.Tests/TestContainerFixture.cs b/tests/Distributed.Collections.Tests/TestContainerFixture.cs new file mode 100644 index 0000000..a28dd88 --- /dev/null +++ b/tests/Distributed.Collections.Tests/TestContainerFixture.cs @@ -0,0 +1,21 @@ +using DotNet.Testcontainers.Containers; + +namespace Distributed.Collections.Tests; + +public abstract class TestContainerFixture : IAsyncLifetime +{ + private readonly Lazy _container; + + protected IContainer Container => _container.Value; + + protected TestContainerFixture() + { + _container = new(CreateContainer); + } + + public Task InitializeAsync() => _container.Value.StartAsync(); + + public Task DisposeAsync() => _container.TryDisposeAsync().AsTask(); + + protected abstract IContainer CreateContainer(); +} \ No newline at end of file