From 9d72faed9f153a772746544304939592ee54b6ba Mon Sep 17 00:00:00 2001 From: Anthony Keller Date: Sun, 8 Mar 2026 11:45:25 +1000 Subject: [PATCH 1/2] Add CLAUDE.md with repo guidance Add CLAUDE.md to provide guidance for Claude Code when working with this repository. The file documents project overview, build and test commands, multi-version build system (V1/V2/V3), global build settings, architecture and core flow of the trigger library, key internal types, and test project layout. Intended as a quick reference for building, testing, and understanding trigger execution and project structure. --- CLAUDE.md | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 CLAUDE.md 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. From 0cc6aa136593cdea15491cf86436af01da4ba741 Mon Sep 17 00:00:00 2001 From: Anthony Keller Date: Mon, 9 Mar 2026 11:20:29 +1000 Subject: [PATCH 2/2] fix(perf): Materialize trigger collections to eliminate ConcatIterator CPU waste Replace IEnumerable fields with List to avoid deeply nested ConcatIterator chains from repeated .Concat() calls. Each WithAdditionalTrigger call now uses List.Add() instead. Also cache the service provider hash code, use O(1) List.Count property instead of LINQ .Count(), and add count-based fast paths in ShouldUseSameServiceProvider to short-circuit before SequenceEqual. Co-Authored-By: Claude Opus 4.6 --- .../Internal/TriggersOptionExtension.cs | 102 +++++++++--------- 1 file changed, 54 insertions(+), 48 deletions(-) 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; }