diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..cf329de
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,78 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+EntityFrameworkCore.Triggered is a library that adds trigger support to EF Core. Triggers respond to entity changes before and after they are committed to the database, similar to SQL database triggers but implemented in C#.
+
+## Build Commands
+
+```bash
+# Build (v3 is the default configuration on this branch)
+dotnet build EntityFrameworkCore.Triggered.sln
+
+# Run all tests
+dotnet test EntityFrameworkCore.Triggered.sln
+
+# Run a single test project
+dotnet test test/EntityFrameworkCore.Triggered.Tests
+
+# Run a specific test by filter
+dotnet test test/EntityFrameworkCore.Triggered.Tests --filter "FullyQualifiedName~TriggerSessionTests"
+
+# Build only core libraries (no samples)
+dotnet build EntityFrameworkCore.Triggered.Core.slnf
+
+# Build samples only
+dotnet build EntityFrameworkCore.Triggered.Samples.slnf
+```
+
+## Multi-Version Build System
+
+The repo supports 3 major versions via `$(EFCoreTriggeredVersion)` in `Directory.Build.props`:
+- **V1**: EF Core 3.1, `netstandard2.0`, config `ReleaseV1`/`DebugV1`
+- **V2**: EF Core 5.0, `netstandard2.1`, config `ReleaseV2`/`DebugV2`
+- **V3** (current branch): EF Core 6.0+, `net6.0`, config `Release`/`Debug`
+
+Source files use `#if` directives with `EFCORETRIGGERED_V1`, `EFCORETRIGGERED_V2`, `EFCORETRIGGERED_V3` for version-specific code.
+
+## Build Settings
+
+- `TreatWarningsAsErrors` is enabled globally
+- Nullable reference types are enabled globally (`enable`)
+- Language version is C# 9.0
+- Strong-name signing is used (`EntityFrameworkCore.Triggered.snk`)
+
+## Architecture
+
+### NuGet Packages (src/)
+
+- **Abstractions** — Trigger interfaces only (`IBeforeSaveTrigger`, `IAfterSaveTrigger`, `IAfterSaveFailedTrigger`, `ITriggerContext`, lifecycle triggers)
+- **EntityFrameworkCore.Triggered** — Core implementation: trigger session management, SaveChanges interception, cascading support
+- **Extensions** — Assembly scanning for auto-discovery of triggers via `AddAssemblyTriggers()`
+- **Transactions** / **Transactions.Abstractions** — Transaction-scoped triggers (`IBeforeCommitTrigger`, `IAfterCommitTrigger`, etc.)
+
+### Core Flow
+
+1. **Registration**: Triggers are registered via `options.UseTriggers(t => t.AddTrigger())` on `DbContextOptionsBuilder`
+2. **Interception**: `TriggerSessionSaveChangesInterceptor` hooks into EF Core's `SaveChanges`/`SaveChangesAsync`
+3. **Execution order**: BeforeSaveStarting → BeforeSave (with cascading) → CaptureChanges → BeforeSaveCompleted → [actual SaveChanges] → AfterSaveStarting → AfterSave → AfterSaveCompleted
+4. **Cascading**: BeforeSave triggers can modify entities, causing additional trigger invocations. Controlled by `ICascadeStrategy` with a configurable max cycle count (default 100).
+
+### Key Internal Types
+
+- `TriggerSession` (`TriggerSession.cs`) — Orchestrates trigger execution for a single SaveChanges call
+- `TriggerService` (`TriggerService.cs`) — Factory for trigger sessions, manages current session state
+- `TriggerSessionSaveChangesInterceptor` (`Internal/`) — EF Core `ISaveChangesInterceptor` implementation
+- `TriggersOptionExtension` (`Infrastructure/Internal/`) — EF Core options extension that registers all trigger services
+- `TriggerContextTracker` — Wraps EF Core's ChangeTracker to produce `TriggerContext` instances
+
+### Test Projects (test/)
+
+- **Tests** — Unit tests for the core library (xUnit + ScenarioTests.XUnit)
+- **Extensions.Tests** — Tests for assembly scanning
+- **IntegrationTests** — End-to-end tests
+- **Transactions.Tests** — Transaction trigger tests
+
+Tests use EF Core InMemory and SQLite providers.
diff --git a/src/EntityFrameworkCore.Triggered/Infrastructure/Internal/TriggersOptionExtension.cs b/src/EntityFrameworkCore.Triggered/Infrastructure/Internal/TriggersOptionExtension.cs
index b5e0d4d..c18d3ce 100644
--- a/src/EntityFrameworkCore.Triggered/Infrastructure/Internal/TriggersOptionExtension.cs
+++ b/src/EntityFrameworkCore.Triggered/Infrastructure/Internal/TriggersOptionExtension.cs
@@ -17,6 +17,7 @@ public class TriggersOptionExtension : IDbContextOptionsExtension
sealed class ExtensionInfo : DbContextOptionsExtensionInfo
{
private string? _logFragment;
+ private int? _serviceProviderHashCode;
public ExtensionInfo(IDbContextOptionsExtension extension) : base(extension)
{
}
@@ -44,14 +45,19 @@ public override void PopulateDebugInfo(IDictionary debugInfo)
throw new ArgumentNullException(nameof(debugInfo));
}
- debugInfo["Triggers:TriggersCount"] = (Extension._triggers?.Count() ?? 0).ToString();
- debugInfo["Triggers:TriggerTypesCount"] = (Extension._triggerTypes?.Count() ?? 0).ToString();
+ debugInfo["Triggers:TriggersCount"] = (Extension._triggers?.Count ?? 0).ToString();
+ debugInfo["Triggers:TriggerTypesCount"] = (Extension._triggerTypes?.Count ?? 0).ToString();
debugInfo["Triggers:MaxCascadeCycles"] = Extension._maxCascadeCycles.ToString();
debugInfo["Triggers:CascadeBehavior"] = Extension._cascadeBehavior.ToString();
}
public override int GetServiceProviderHashCode()
{
+ if (_serviceProviderHashCode.HasValue)
+ {
+ return _serviceProviderHashCode.Value;
+ }
+
var hashCode = new HashCode();
if (Extension._triggers != null)
@@ -78,28 +84,56 @@ public override int GetServiceProviderHashCode()
hashCode.Add(Extension._serviceProviderTransform);
}
- return hashCode.ToHashCode();
+ _serviceProviderHashCode = hashCode.ToHashCode();
+ return _serviceProviderHashCode.Value;
}
public override bool ShouldUseSameServiceProvider(DbContextOptionsExtensionInfo other)
- => other is ExtensionInfo otherInfo
- && Enumerable.SequenceEqual(Extension._triggers ?? Enumerable.Empty>(), otherInfo.Extension._triggers ?? Enumerable.Empty>())
- && Enumerable.SequenceEqual(Extension._triggerTypes ?? Enumerable.Empty(), otherInfo.Extension._triggerTypes ?? Enumerable.Empty())
- && Extension._maxCascadeCycles == otherInfo.Extension._maxCascadeCycles
- && Extension._cascadeBehavior == otherInfo.Extension._cascadeBehavior
- && Extension._serviceProviderTransform == otherInfo.Extension._serviceProviderTransform;
+ {
+ if (other is not ExtensionInfo otherInfo)
+ {
+ return false;
+ }
+
+ // Check cheap scalar comparisons first
+ if (Extension._maxCascadeCycles != otherInfo.Extension._maxCascadeCycles
+ || Extension._cascadeBehavior != otherInfo.Extension._cascadeBehavior
+ || Extension._serviceProviderTransform != otherInfo.Extension._serviceProviderTransform)
+ {
+ return false;
+ }
+
+ // Check list counts before doing full sequence comparison
+ var triggersCount = Extension._triggers?.Count ?? 0;
+ var otherTriggersCount = otherInfo.Extension._triggers?.Count ?? 0;
+ if (triggersCount != otherTriggersCount)
+ {
+ return false;
+ }
+
+ var triggerTypesCount = Extension._triggerTypes?.Count ?? 0;
+ var otherTriggerTypesCount = otherInfo.Extension._triggerTypes?.Count ?? 0;
+ if (triggerTypesCount != otherTriggerTypesCount)
+ {
+ return false;
+ }
+
+ // Full sequence comparison only when counts match
+ return Enumerable.SequenceEqual(Extension._triggers ?? Enumerable.Empty>(), otherInfo.Extension._triggers ?? Enumerable.Empty>())
+ && Enumerable.SequenceEqual(Extension._triggerTypes ?? Enumerable.Empty(), otherInfo.Extension._triggerTypes ?? Enumerable.Empty());
+ }
}
private ExtensionInfo? _info;
- private IEnumerable<(object typeOrInstance, ServiceLifetime lifetime)>? _triggers;
- private IEnumerable _triggerTypes;
+ private List<(object typeOrInstance, ServiceLifetime lifetime)>? _triggers;
+ private List _triggerTypes;
private int _maxCascadeCycles = 100;
private CascadeBehavior _cascadeBehavior = CascadeBehavior.EntityAndType;
private Func? _serviceProviderTransform;
public TriggersOptionExtension()
{
- _triggerTypes = new[] {
+ _triggerTypes = new List {
typeof(IBeforeSaveTrigger<>),
typeof(IAfterSaveTrigger<>),
typeof(IAfterSaveFailedTrigger<>),
@@ -116,10 +150,10 @@ public TriggersOptionExtension(TriggersOptionExtension copyFrom)
{
if (copyFrom._triggers != null)
{
- _triggers = copyFrom._triggers;
+ _triggers = new List<(object typeOrInstance, ServiceLifetime lifetime)>(copyFrom._triggers);
}
- _triggerTypes = copyFrom._triggerTypes;
+ _triggerTypes = new List(copyFrom._triggerTypes);
_maxCascadeCycles = copyFrom._maxCascadeCycles;
_cascadeBehavior = copyFrom._cascadeBehavior;
_serviceProviderTransform = copyFrom._serviceProviderTransform;
@@ -254,17 +288,8 @@ public TriggersOptionExtension WithAdditionalTrigger(Type triggerType, ServiceLi
}
var clone = Clone();
- var triggerEnumerable = Enumerable.Repeat(((object)triggerType, lifetime), 1);
-
- if (clone._triggers == null)
- {
- clone._triggers = triggerEnumerable;
- }
- else
- {
- clone._triggers = clone._triggers.Concat(triggerEnumerable);
- }
-
+ clone._triggers ??= new List<(object typeOrInstance, ServiceLifetime lifetime)>();
+ clone._triggers.Add(((object)triggerType, lifetime));
return clone;
}
@@ -282,17 +307,8 @@ public TriggersOptionExtension WithAdditionalTrigger(object instance)
}
var clone = Clone();
- var triggersEnumerable = Enumerable.Repeat((instance, ServiceLifetime.Singleton), 1);
-
- if (clone._triggers == null)
- {
- clone._triggers = triggersEnumerable;
- }
- else
- {
- clone._triggers = clone._triggers.Concat(triggersEnumerable);
- }
-
+ clone._triggers ??= new List<(object typeOrInstance, ServiceLifetime lifetime)>();
+ clone._triggers.Add((instance, ServiceLifetime.Singleton));
return clone;
}
@@ -304,19 +320,9 @@ public TriggersOptionExtension WithAdditionalTriggerType(Type triggerType)
throw new ArgumentNullException(nameof(triggerType));
}
-
var clone = Clone();
- var triggerTypesEnumerable = Enumerable.Repeat(triggerType, 1);
-
- if (clone._triggerTypes == null)
- {
- clone._triggerTypes = triggerTypesEnumerable;
- }
- else
- {
- clone._triggerTypes = clone._triggerTypes.Concat(triggerTypesEnumerable);
- }
-
+ clone._triggerTypes ??= new List();
+ clone._triggerTypes.Add(triggerType);
return clone;
}