diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1572aafdef1..1f702000dcb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -162,6 +162,8 @@ jobs:
runs-on: ubuntu-latest
needs: [configure, check-changes]
if: needs.check-changes.outputs.library_changes == 'true'
+ env:
+ DOCKER_CONFIG: $HOME/.docker
strategy:
fail-fast: false
@@ -185,6 +187,17 @@ jobs:
run: dotnet build ${{ matrix.path }} --framework net9.0 --verbosity q
timeout-minutes: 5
+ - name: Log in to Docker Hub
+ # Run step only if branch is local (not from a fork).
+ if: >
+ github.event_name != 'pull_request' ||
+ (github.event_name == 'pull_request' &&
+ github.event.pull_request.head.repo.full_name == github.repository)
+ uses: docker/login-action@v3
+ with:
+ username: ${{ vars.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
- name: Run tests
id: run-tests
timeout-minutes: 15
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index 8559ec02443..d99a5b5730a 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -44,6 +44,8 @@ jobs:
name: Run ${{ matrix.name }}
runs-on: ubuntu-latest
needs: configure
+ env:
+ DOCKER_CONFIG: $HOME/.docker
strategy:
fail-fast: false
@@ -67,6 +69,12 @@ jobs:
run: dotnet build ${{ matrix.path }} --framework net9.0 --verbosity q
timeout-minutes: 5
+ - name: Log in to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ vars.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
- name: Run tests
id: run-tests
timeout-minutes: 15
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index b85b3ea8b9d..3835de1c673 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -17,12 +17,19 @@
}
},
{
- "label": "Build src/All.sln",
+ "type": "dotnet",
+ "task": "build",
+ "group": "build",
+ "problemMatcher": [],
+ "label": "dotnet: build"
+ },
+ {
+ "label": "Build src/All.slnx",
"command": "dotnet",
"type": "shell",
"args": [
"build",
- "src/All.sln",
+ "src/All.slnx",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
@@ -49,12 +56,12 @@
"problemMatcher": "$msCompile"
},
{
- "label": "Test src/All.sln",
+ "label": "Test src/All.slnx",
"command": "dotnet",
"type": "shell",
"args": [
"test",
- "src/All.sln",
+ "src/All.slnx",
"--verbosity q",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
@@ -64,6 +71,6 @@
"reveal": "silent"
},
"problemMatcher": "$msCompile"
- },
+ }
]
}
diff --git a/dictionary.txt b/dictionary.txt
index 579af37cf6d..796ff883ff8 100644
--- a/dictionary.txt
+++ b/dictionary.txt
@@ -39,6 +39,7 @@ depersist
deprioritization
deprioritized
Dispatchable
+DOCKERHUB
drawio
enisdenjo
entityframework
diff --git a/src/All.slnx b/src/All.slnx
index 5a6b5e35bcb..b76dcd8cbc8 100644
--- a/src/All.slnx
+++ b/src/All.slnx
@@ -89,6 +89,7 @@
+
@@ -128,6 +129,9 @@
+
+
+
diff --git a/src/CookieCrumble/src/CookieCrumble.Xunit/CookieCrumble.Xunit.csproj b/src/CookieCrumble/src/CookieCrumble.Xunit/CookieCrumble.Xunit.csproj
index ecc54d94a23..b9e5062e13c 100644
--- a/src/CookieCrumble/src/CookieCrumble.Xunit/CookieCrumble.Xunit.csproj
+++ b/src/CookieCrumble/src/CookieCrumble.Xunit/CookieCrumble.Xunit.csproj
@@ -7,7 +7,9 @@
-
+
+
+
diff --git a/src/CookieCrumble/src/CookieCrumble.Xunit3/CookieCrumble.Xunit3.csproj b/src/CookieCrumble/src/CookieCrumble.Xunit3/CookieCrumble.Xunit3.csproj
index 4f920d2ee7f..caee8700f21 100644
--- a/src/CookieCrumble/src/CookieCrumble.Xunit3/CookieCrumble.Xunit3.csproj
+++ b/src/CookieCrumble/src/CookieCrumble.Xunit3/CookieCrumble.Xunit3.csproj
@@ -7,7 +7,8 @@
-
+
+
diff --git a/src/CookieCrumble/src/CookieCrumble/Snapshot.cs b/src/CookieCrumble/src/CookieCrumble/Snapshot.cs
index e4d8f1e71be..b6ea6a72d1d 100644
--- a/src/CookieCrumble/src/CookieCrumble/Snapshot.cs
+++ b/src/CookieCrumble/src/CookieCrumble/Snapshot.cs
@@ -242,6 +242,7 @@ public async ValueTask MatchAsync(CancellationToken cancellationToken = default)
if (!File.Exists(snapshotFile))
{
+ CheckStrictMode();
EnsureDirectoryExists(snapshotFile);
await using var stream = File.Create(snapshotFile);
await stream.WriteAsync(writer.WrittenMemory, cancellationToken);
@@ -274,6 +275,7 @@ public void Match()
if (!File.Exists(snapshotFile))
{
+ CheckStrictMode();
EnsureDirectoryExists(snapshotFile);
using var stream = File.Create(snapshotFile);
stream.Write(writer.WrittenSpan);
@@ -309,6 +311,7 @@ public async ValueTask MatchMarkdownAsync(CancellationToken cancellationToken =
if (!File.Exists(snapshotFile))
{
+ CheckStrictMode();
EnsureDirectoryExists(snapshotFile);
await using var stream = File.Create(snapshotFile);
await stream.WriteAsync(writer.WrittenMemory, cancellationToken);
@@ -346,6 +349,7 @@ public void MatchMarkdown()
if (!File.Exists(snapshotFile))
{
+ CheckStrictMode();
EnsureDirectoryExists(snapshotFile);
using var stream = File.Create(snapshotFile);
stream.Write(writer.WrittenSpan);
@@ -720,6 +724,20 @@ from methodInfo in classDeclaringType.GetMethods()
return actualMethodInfo;
}
+ private static void CheckStrictMode()
+ {
+ var value = Environment.GetEnvironmentVariable("COOKIE_CRUMBLE_STRICT_MODE");
+
+ if (string.Equals(value, "on", StringComparison.Ordinal)
+ || (bool.TryParse(value, out var b) && b))
+ {
+ _testFramework.ThrowTestException(
+ "Strict mode is enabled and no snapshot has been found " +
+ "for the current test. Create a new snapshot locally and " +
+ "rerun your tests.");
+ }
+ }
+
private readonly struct SnapshotSegment(string? name, object? value, ISnapshotValueFormatter formatter)
: ISnapshotSegment
{
diff --git a/src/CookieCrumble/test/CookieCrumble.Tests/SnapshotTests.cs b/src/CookieCrumble/test/CookieCrumble.Tests/SnapshotTests.cs
index 56cbeae2caa..cd3ef8ccb35 100644
--- a/src/CookieCrumble/test/CookieCrumble.Tests/SnapshotTests.cs
+++ b/src/CookieCrumble/test/CookieCrumble.Tests/SnapshotTests.cs
@@ -1,12 +1,19 @@
using System.Buffers;
+using System.Runtime.CompilerServices;
using System.Text;
using CookieCrumble.Formatters;
using CookieCrumble.Xunit;
+using Xunit.Sdk;
namespace CookieCrumble;
public class SnapshotTests
{
+ private const string _strictModeExceptionMessage =
+ "Strict mode is enabled and no snapshot has been found " +
+ "for the current test. Create a new snapshot locally and " +
+ "rerun your tests.";
+
static SnapshotTests()
{
Snapshot.RegisterTestFramework(new XunitFramework());
@@ -109,6 +116,83 @@ public void SnapshotBuilder_Segment_Custom_Global_Serializer()
snapshot.Match();
}
+ [Theory]
+ [InlineData("on")]
+ [InlineData("true")]
+ public async Task Match_StrictMode_On(string strictMode)
+ {
+ Environment.SetEnvironmentVariable("COOKIE_CRUMBLE_STRICT_MODE", strictMode);
+
+ var snapshot = new Snapshot();
+ snapshot.Add(new MyClass { Foo = "123", });
+
+ async Task Act1() => await snapshot.MatchAsync();
+ void Act2() => snapshot.Match();
+ async Task Act3() => await snapshot.MatchMarkdownAsync();
+ void Act4() => snapshot.MatchMarkdown();
+
+ try
+ {
+ Assert.Equal(
+ _strictModeExceptionMessage,
+ (await Assert.ThrowsAsync(Act1)).Message);
+
+ Assert.Equal(_strictModeExceptionMessage, Assert.Throws(Act2).Message);
+
+ Assert.Equal(
+ _strictModeExceptionMessage,
+ (await Assert.ThrowsAsync(Act3)).Message);
+
+ Assert.Equal(_strictModeExceptionMessage, Assert.Throws(Act4).Message);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("COOKIE_CRUMBLE_STRICT_MODE", null);
+ }
+ }
+
+ [Theory]
+ [InlineData(1, "off")]
+ [InlineData(2, "false")]
+ [InlineData(3, null)]
+ public async Task Match_StrictMode_Off(int number, string? strictMode)
+ {
+ Environment.SetEnvironmentVariable("COOKIE_CRUMBLE_STRICT_MODE", strictMode);
+
+ var snapshot = new Snapshot();
+ snapshot.Add(new MyClass { Foo = "123", });
+
+ async Task Act1() => await snapshot.SetPostFix($"MA_{number}").MatchAsync();
+ void Act2() => snapshot.SetPostFix($"M_{number}").Match();
+ async Task Act3() => await snapshot.SetPostFix($"MMA_{number}").MatchMarkdownAsync();
+ void Act4() => snapshot.SetPostFix($"MM_{number}").MatchMarkdown();
+
+ try
+ {
+ var result1 = await Record.ExceptionAsync(Act1);
+ var result2 = Record.Exception(Act2);
+ var result3 = await Record.ExceptionAsync(Act3);
+ var result4 = Record.Exception(Act4);
+
+ static string GetCallerFilePath([CallerFilePath] string filePath = "") => filePath;
+ var directory = Path.GetDirectoryName(GetCallerFilePath()) + "/__snapshots__";
+
+ File.Delete($"{directory}/SnapshotTests.Match_StrictMode_Off_MA_{number}.snap");
+ File.Delete($"{directory}/SnapshotTests.Match_StrictMode_Off_M_{number}.snap");
+ File.Delete($"{directory}/SnapshotTests.Match_StrictMode_Off_MMA_{number}.md");
+ File.Delete($"{directory}/SnapshotTests.Match_StrictMode_Off_MM_{number}.md");
+
+ Assert.Null(result1);
+ Assert.Null(result2);
+ Assert.Null(result3);
+ Assert.Null(result4);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("COOKIE_CRUMBLE_STRICT_MODE", null);
+ }
+ }
+
public class MyClass
{
public string Foo { get; set; } = "Bar";
diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
index a3d046b0431..987e221a7f1 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -26,9 +26,9 @@
-
-
-
+
+
+
@@ -39,7 +39,6 @@
-
@@ -47,23 +46,29 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
@@ -88,6 +93,7 @@
+
@@ -112,6 +118,7 @@
+
diff --git a/src/GreenDonut/src/GreenDonut.Abstractions/IPromiseCache.cs b/src/GreenDonut/src/GreenDonut.Abstractions/IPromiseCache.cs
index 9ac6419426b..d88fac0d4d9 100644
--- a/src/GreenDonut/src/GreenDonut.Abstractions/IPromiseCache.cs
+++ b/src/GreenDonut/src/GreenDonut.Abstractions/IPromiseCache.cs
@@ -1,3 +1,5 @@
+using System.Diagnostics.CodeAnalysis;
+
namespace GreenDonut;
///
@@ -92,7 +94,7 @@ public interface IPromiseCache
void Publish(T value);
///
- /// Publishes the values to the cache subscribers without adding it to the cache iself.
+ /// Publishes the values to the cache subscribers without adding it to the cache itself.
/// This allows the subscribers to decide if they want to cache the values.
///
///
diff --git a/src/GreenDonut/src/GreenDonut.Abstractions/IPromiseCacheInterceptor.cs b/src/GreenDonut/src/GreenDonut.Abstractions/IPromiseCacheInterceptor.cs
new file mode 100644
index 00000000000..f870d52084d
--- /dev/null
+++ b/src/GreenDonut/src/GreenDonut.Abstractions/IPromiseCacheInterceptor.cs
@@ -0,0 +1,44 @@
+namespace GreenDonut;
+
+///
+/// Allows to implement a second-level cache for the DataLoader promise cache.
+///
+public interface IPromiseCacheInterceptor
+{
+ ///
+ /// Gets a task from the cache if a task with the specified already
+ /// exists; otherwise, the factory is used to create a new
+ /// task and add it to the cache.
+ ///
+ /// A cache entry key.
+ /// A factory to create the new task.
+ /// The task type.
+ ///
+ /// Returns either the retrieved or new task from the cache.
+ ///
+ ///
+ /// Throws if is null.
+ ///
+ ///
+ /// Throws if is null.
+ ///
+ Promise GetOrAddPromise(PromiseCacheKey key, Func> createPromise);
+
+ ///
+ /// Tries to add a single task to the cache. It does nothing if the
+ /// task exists already.
+ ///
+ /// A cache entry key.
+ /// A task.
+ /// The task type.
+ ///
+ /// Throws if is null.
+ ///
+ ///
+ /// Throws if is null.
+ ///
+ ///
+ /// A value indicating whether the add was successful.
+ ///
+ bool TryAdd(PromiseCacheKey key, Promise promise);
+}
diff --git a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs
index 671d967e10b..e037e1ed18d 100644
--- a/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs
+++ b/src/GreenDonut/src/GreenDonut.Data.EntityFramework/Expressions/ExpressionHelpers.cs
@@ -40,9 +40,9 @@ internal static class ExpressionHelpers
///
/// If the number of keys does not match the number of values.
///
- public static Expression> BuildWhereExpression(
+ public static (Expression> WhereExpression, int Offset) BuildWhereExpression(
ReadOnlySpan keys,
- ReadOnlySpan