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; }