Design and maintenance notes for this fork. README.md is for consumers; this file is for whoever maintains the fork.
An MIT build of Z.EntityFramework.Plus.EFCore
for EF Core 10 that does not depend on Z.EntityFramework.Extensions.EFCore or Z.Expressions.Eval.
Upstream Entity Framework Plus is MIT, but its EF Core package declares both of those proprietary ZZZ Projects packages as runtime dependencies. Every application that references it ships both DLLs, whether or not it calls anything in them. This fork removes the dependencies so the licence closure of a consuming application is MIT + EF Core, nothing else.
This fork is not affiliated with or endorsed by ZZZ Projects. Upstream code is compiled unchanged; the fork adds a shim, a Batch Update / Batch Delete implementation on EF Core, one project, two test projects and packaging.
- Never edit upstream source files. Everything the fork needs lives in files upstream does not
have. This is what keeps upstream merges conflict-free. The one exception is
src/Z.EntityFramework.Plus.sln, which gains the fork's projects — about 30 additive lines:Projectentries andDebug|Any CPU/Release|Any CPUmappings for the three csproj,.shprojentries for the two shared projects, nesting under thesharedandtestfolders, and theSharedMSBuildProjectFileslines that tie each.projitemsto the projects importing it. If a merge conflicts there, take upstream's file and re-add the projects from the IDE.dotnet sln addalso works, but it inventsx64andx86solution platforms and writes mappings for every project in the solution (200 lines); strip those before committing — upstream's solution isAny CPUonly. master— read-only, Sync fork only. It mirrors upstream exactly.master-MITis the release branch and the GitHub default branch. Mergemasterinto it. Never rebase it — published packages point at it.master-MIT— no direct writes, pull requests only. Work happens on short-lived branches off it (features/<slug>); it receives merges frommaster(upstream sync) or from a feature branch, through a pull request either way.- Package version = the upstream tag the release is built from.
| Feature | In .MIT? |
Notes |
|---|---|---|
Query Future (Future(), FutureValue(), DeferredXxx().FutureValue()) |
yes | Truly async (ExecuteReaderAsync). Its compile step is reimplemented in the shim — see below. |
| Query Deferred | yes | ExecuteAsync() is Task.Run over the sync call, as upstream. Prefer .FutureValue(). |
| Query Cache | yes | FromCacheAsync is Task.Run, as upstream. Its cache key uses the shim's compile step. |
| Query Filter | yes | |
| Query IncludeFilter (Core), Query IncludeOptimized | yes | Async paths are Task.Run, as upstream. |
| Audit | yes | |
| Query Extensions, Set Dynamic, Set Identity | yes | Set Identity needs a 15-line metadata shim (ToZInfo). |
Batch Update / Batch Delete (Update, Delete, UpdateAsync, DeleteAsync) |
yes | Upstream's are facades over Z.EntityFramework.Extensions (UpdateFromQuery / DeleteFromQuery, including the InMemory hook); Plus's own BatchUpdate.cs is commented out upstream. The fork reimplements the four methods on EF Core's ExecuteUpdate / ExecuteDelete with upstream's signatures — see Batch Update / Batch Delete. Object-initializer factory only; no BatchUpdate / BatchDelete options. |
Query Hook (WithHint, temporal-table and command-executing extensions) |
no | Pure facade over Z.EntityFramework.Extensions.PublicMethodForEFPlus; no Plus implementation behind it. |
Bulk operations (BulkInsert, BulkSaveChanges, WhereBulkContains, …) |
no | Never were in Plus; they are Z.EntityFramework.Extensions features. |
Non-goals: no API changes, no fixes to upstream behaviour (report those upstream), no reimplementation of Query Hook or bulk operations.
Upstream's EF Core shared code reaches the two paid assemblies in two very different ways, and the
shim (src/shared/Z.EF.Plus.MIT.Shared/Shim/) answers both. Everything is internal —
except EntityFrameworkManager, public because its ContextFactory is a hook consumers set (see the
Batch section) — compiled into the Plus assembly, in the namespaces upstream code imports — Shim/Extensions/ holds
Z.EntityFramework.Extensions, Shim/EvalManager.cs holds Z.Expressions, and
Shim/EntityTypeInfoExtensions.cs sits in Z.EntityFramework.Plus because its one caller imports
nothing else.
Call-site counts are for the folders the EF Core project imports, at 10.105.8.1.
| Member | Call sites | Shim behaviour |
|---|---|---|
EntityFrameworkManager.IsEntityFrameworkPlus (bool, written) |
6 static constructors: AuditManager, QueryCacheManager, QueryFilterManager, QueryFutureManager, QueryIncludeFilterManager, QueryIncludeOptimizedManager |
Plain static property. Never read by Plus. |
EntityFrameworkManager.IsCommunity (bool, written) |
EntityFrameworkPlusManager.IsCommunity setter |
Plain static property. |
EvalManager.IsCommunity (bool, written) |
same setter | Plain static property. |
PublicMethods.GetQueryContextFactory(object) : object |
8: BaseQueryFuture, DbContext.MapReader, CreateCommand, GetDbContext ×2, GetInMemoryContext, IQueryable.GetCommand, IsInMemoryQueryContext |
QueryCompiler._queryContextFactory by reflection — the inline code upstream replaced with the EFE call in 48e460d. |
PublicMethods.GetDatabase(object) : object |
3: DbContext.MapReader, CreateCommand, IQueryable.GetCommand |
QueryCompiler._database, same commit. |
PublicExtensions.GetInnerForLinqKit (generic and non-generic) |
5: GetDbContext ×2, GetInMemoryContext, IsInMemoryQueryContext |
Identity. Upstream unwraps LinqKit's ExpandableQuery<T>; the fork does not (see caveats). |
PublicExtensions.GetParameterName(IRelationalParameter) : string |
1: QueryFutureBatch (renames parameters to Z_{n}_… when combining queries) |
The logic upstream replaced in e734b42: for a TypeMappedRelationalParameter whose Name starts with @_ and whose Name.Substring(1) differs from InvariantName, return Name.Substring(1); otherwise InvariantName. |
ToZInfo(IEntityType) for Set Identity is an eighth trivial member: schema and table name via EF
Core's GetSchema() / GetTableName().
EntityFrameworkManager.ContextFactory stands in for nothing upstream calls: it is the fork's InMemory
hook for Batch Update / Delete, kept under EF Extensions' name and signature so a consumer's existing
wiring survives the switch.
On EF Core 3+, Plus has no command-creation code of its own: CreateCommand.cs's EF Core 3+
branch is empty and GetDbCommand<T> is #if EFCORE_2X. Upstream moved the whole step into EFE in
February 2020 (dbb2b69). Two EFE extension methods do it, both on untyped IQueryable:
EFPlusCreateCommand(query, beforeCompile, out RelationalQueryContext, out object compiledQuery) : IRelationalCommand— Query Future. Plus readsCommandTextandParametersoff the command to build the combined SQL, indexesqueryContext.Parametersby invariant name, and later callsGetEnumerator()oncompiledQueryagainst its own fake reader, first nulling the enumerator's_readerColumns.CreateCommand(query, out RelationalQueryContext) : IRelationalCommand— Query Cache's key.
The shim reimplements them as EF Core's own QueryCompiler.ExecuteCore minus the execution, using
public API for every step: EntityQueryProvider._queryCompiler → IQueryContextFactory.Create() →
QueryCompiler.ExtractParameters(expression, queryContext.Parameters, logger) → compile → invoke the
compiled delegate to get the lazy SingleQueryingEnumerable<T> → its _relationalCommandResolver
delegate gives the IRelationalCommandTemplate (a RelationalCommand) for the parameter values.
Why the compile step is not just IDatabase.CompileQuery. A scalar query such as Count() has
ResultCardinality.Single; EF compiles it to a Func<QueryContext, int> — nothing to enumerate.
Wrapping that scalar in a one-element enumerable would be wrong under buffering: with a retrying
execution strategy (EnableRetryOnFailure) EF wraps readers in BufferedDataReader, whose
Initialize consumes every remaining result set, and Plus's fake reader delegates NextResult() to
the real one. Plus guards against this by nulling _readerColumns on the outer enumerator; a wrapped
inner scalar query would escape that guard and silently swallow the following futures. So the shim
runs EF's QueryCompilationContext.CreateQueryExecutorExpression pipeline itself (preprocess →
translate → postprocess → shaper-compile → runtime parameters → lambda → InlineConstants → compile),
mirrored line for line from release/10.0, with one insertion after translate:
shaped.UpdateResultCardinality(ResultCardinality.Enumerable). SELECT COUNT(*) … becomes a genuine
one-row SingleQueryingEnumerable<int> with identical SQL, and Plus's existing handling applies.
Compiled executors are cached in a size-limited MemoryCache keyed by EF's own
ICompiledQueryCacheKeyGenerator key plus the element type. EF's ICompiledQueryCache cannot be
shared: the same key would hold a scalar delegate there and an enumerable one here.
EF Core 11 changed one thing on this path: ExtractParameters lost its generateContextAccessors
parameter (fixed to false inside). That call is the shim's one #if EFCORE_11X branch; the executor
pipeline in release/11.0 is otherwise the same sequence of steps.
| Field | Owner | Used by |
|---|---|---|
EntityQueryProvider._queryCompiler |
upstream already | everything |
QueryCompiler._queryContextFactory, QueryCompiler._database |
shim | PublicMethods |
QueryCompilationContext._runtimeParameters |
shim | InsertRuntimeParameters (global query filters that read context members) |
*QueryingEnumerable<T>._relationalCommandResolver |
shim | command text and parameters |
RelationalConnection._connection, Enumerator._readerColumns, … |
upstream already | Query Future's connection swap and reader handling |
Two [Experimental] members are used with EF9100 suppressed, because EF's own CreateQueryExecutor
calls exactly these two: ILiftableConstantProcessor.InlineConstants and
QueryCompilationContext.SupportsPrecompiledQuery.
All fields exist in EF Core 10.0.x and 11.0 RC 1. A renamed field surfaces as a TypeInitializationException
wrapping MissingFieldException on the first use — not at compile time. The test projects are what
catch it on an EF Core bump.
The compiler is the real check: a new EFE type (as SqlServerTableHintFlags was) or extension
method escapes any grep. So: build the 10x project, then both test projects. The grep below is the
quick pre-read that tells you what changed before the compiler does:
git grep -n -E "EntityFrameworkManager\.|PublicMethods\.|PublicExtensions\.|EvalManager\.|PublicMethodForEFPlus|Z\.Expressions\.\w" -- src/shared \
| grep -v -E ":\s*//" \
| grep -v -E "Batch(Update|Delete)\.Shared|QueryHook|QueryFilterInterceptor|QueryIncludeFilter\.Shared"(QueryFilterInterceptor.Shared and QueryIncludeFilter.Shared are not imported by the EF Core
project; the batch folders are replaced by the fork's Batch/, and the hook folder is excluded.) A new
member means: extend the shim if it is trivially replaceable, otherwise stop and look at what upstream
started depending on.
Nothing in Shim/ derives from Z.EntityFramework.Extensions or Z.Expressions.Eval. Neither
assembly's source is available, and neither was decompiled for this fork. The only inspection of an
EFE binary was a string search confirming which package version first exports GetParameterName,
done to explain why upstream's own 9x project does not build. Each piece comes from:
| Shim piece | Origin |
|---|---|
EntityFrameworkManager, EvalManager |
Nothing to copy: flags the MIT callers write and nothing reads. |
PublicMethods.GetQueryContextFactory / GetDatabase |
Upstream Plus's own inline reflection, removed in 48e460d (MIT, this repository). |
PublicExtensions.GetParameterName |
Upstream Plus's own logic, removed in e734b42 (MIT, this repository). |
GetInnerForLinqKit, ToZInfo |
Identity function; two calls to EF Core public API. |
QueryCommandExtensions |
EF Core's QueryCompiler.ExecuteCore and QueryCompilationContext.CreateQueryExecutorExpression, read from dotnet/efcore release/10.0 and mirrored (MIT, © .NET Foundation and Contributors; notice at the top of the file). |
| Member names, namespaces and signatures | Dictated by the MIT call sites in upstream's shared code, which this fork compiles unchanged. |
Recorded 2026-09-24, when the shim was written.
On EF Core 3+ upstream's Update / Delete extension methods are one-liners over EFE's
UpdateFromQuery / DeleteFromQuery; Plus's own BatchUpdate.cs is commented out. Dropping the feature
would have made every consumer rewrite its call sites against ExecuteUpdate's SetProperty builder
— and, on the InMemory provider, pass the DbContext explicitly, because InMemory has no ExecuteUpdate
and the context behind an IQueryable is not public API. So the fork provides the four public methods
with upstream's exact signatures (namespace Z.EntityFramework.Plus, no context parameter). They live in
Batch/, not Shim/: this is fork-owned public API, not a stand-in for a paid member.
- Relational providers.
Update(factory)becomesExecuteUpdate(setters => …)with oneSetPropertyperMemberAssignmentof the factory's object initializer;Delete()isExecuteDelete(). A value that reads the row (t => new T { Order = t.Order + 1 }) goes through theSetProperty(Expression, Expression)overload; a row-independent value (a constant, a captured local) is evaluated once and goes throughSetProperty(Expression, TProperty), which EF parameterises. The split is not cosmetic: on EF Core 10.0.0–10.0.6 a constant assigned to a nullable member through the expression overload fails to translate ("No coercion operator is defined", dotnet/efcore#37974). - Where the context comes from. Upstream's own internal
IsInMemoryQueryContext()/GetInMemoryContext()(_Core.Shared), which read it off the query'sQueryContextFactorythrough the shim'sGetQueryContextFactory. That is what lets the signatures stay parameterless. - InMemory provider. No
ExecuteUpdate/ExecuteDelete— the provider throws rather than falling back — so the rows are read withAsNoTracking()and saved through a second context over the same database (InMemoryContext): each row is attached there alone (Entry(row).State, which unlikeAttach/Removedoes not walk navigations), the factory's members are applied and marked modified or the row is marked deleted, and that context saves. What happens to it then depends on who owns it: a context the fork built from options is disposed; one the factory returned is left to its owner, with the rows this call attached detached so it goes back as clean as it came. A container typically hands out the same instance for a whole scope, and the next batch call gets it again — disposing it broke a consumer whose cleanup job makes five batch calls per unit of work, the second of which found a disposed context. The query's own context is never touched, which is the statement contract in full — a statement changes rows, not objects: tracked instances keep their values, the unit of work's other pending changes stay pending, nothing new is tracked, and later queries see the new rows. Saving through the query's context cannot give all of that: with a save, every pending change in the unit of work goes to the store early (a consumer's history diff right after a batch call found nothing left to diff); without one, later reads miss the rows. The second context comes fromEntityFrameworkManager.ContextFactory(Func<DbContext, DbContext>, the hook EF Extensions had, so a consumer's existing wiring keeps working) or, when that is unset or returns null, from constructing the context's own type with its ownIDbContextOptions— the constructor an EF Core context has by convention. A context that needs more, one resolved from a container for instance, sets the hook. - Not provided. The
ExpandoObject,IDictionaryand anonymous-object update factories, and theBatchUpdate/BatchDeleteoptions builders (BatchSize,BatchDelayInterval,Executing,InMemoryDbContextFactory,UseTableLock). Those were EFE features with EFE semantics (BatchSizechunks the statement) thatExecuteUpdatecannot honour; upstream's tests for them are theCompile Removelist in the EFCore100 test project. A factory that is not an object initializer throwsArgumentException. - Translation limits are EF Core's. Whatever
ExecuteUpdate/ExecuteDeleterefuse for a provider (Skip, someInclude/Selectshapes), these refuse too. EFE generated its own SQL and accepted more.
Nothing here reflects EF Core internals. The UpdateSettersBuilder<T>.SetProperty overloads are picked by
reflection over public API and cached per (entity type, member type, overload).
.github/workflows/
build.yml # build + test + pack on every push/PR
publish.yml # tag mit/<version> → nuget.org
src/
Z.EntityFramework.Plus.sln # upstream's solution + the fork's projects (see ground rule 1)
EntityFrameworkPlus.EFCore.MIT.EFCore10.slnf # solution filter per EF Core version: the library, upstream's suite and the smoke tests — open these
EntityFrameworkPlus.EFCore.MIT.EFCore11.slnf
Z.EntityFramework.Plus.EFCore10x.NET10/ # the library for EF Core 10 — mirrors EFCore9x.NET8; the csproj alone
Z.EntityFramework.Plus.EFCore10x.NET10.csproj
Z.EntityFramework.Plus.EFCore11x.NET11/ # the library for EF Core 11 — the 10x csproj with net11.0, EF Core 11, EFCORE_11X, version 11.x
Z.EntityFramework.Plus.EFCore11x.NET11.csproj
shared/Z.EF.Plus.MIT.Shared/ # the fork's own code, imported by every EFCore1Nx csproj like upstream's feature folders
Z.EF.Plus.MIT.Shared.projitems # the file list; .shproj beside it is for the IDE
Shim/
Extensions/ # namespace Z.EntityFramework.Extensions
EntityFrameworkManager.cs # + ContextFactory, the InMemory hook (public)
PublicMethods.cs
PublicExtensions.cs
QueryCommandExtensions.cs # the compile step
EvalManager.cs # namespace Z.Expressions
EntityTypeInfoExtensions.cs # namespace Z.EntityFramework.Plus (ToZInfo)
Batch/ # Batch Update / Delete on ExecuteUpdate / ExecuteDelete, upstream's signatures
BatchUpdateExtensions.cs # the ExecuteUpdate proxy: object initializer → SetProperty calls
BatchUpdateExtensions.InMemory.cs # InMemory fallback: read untracked, apply, save through the second context
BatchDeleteExtensions.cs # the ExecuteDelete proxy
BatchDeleteExtensions.InMemory.cs # InMemory fallback: read untracked, mark deleted, save through the second context
InMemoryContext.cs # the second context both fallbacks save through: ContextFactory, or the context's type from its options
test/
Z.Test.EntityFramework.Plus.EFCore100/ # upstream's shared test suite against the EF Core 10 build — mirrors EFCore90
Z.Test.EntityFramework.Plus.EFCore110/ # the same against the EF Core 11 build; links the EFCore100 project's DeleteFromQuery helper
EntityFrameworkPlus.EFCore.MIT.Smoke.Shared/ # fork-owned xunit + Shouldly smoke tests, one folder per feature; .projitems + .shproj
ShouldExtensions.cs # ShouldBeInOrder: sequence assertions as "these elements, in this order"
QueryFuture/ # round trips on SQL Server + InMemory: QueryFutureTests.SqlServer / .InMemory and their fixtures
Batch/ # Batch Update / Delete on InMemory + SQLite: one test class per extension class, one part per method
EntityFrameworkPlus.EFCore.MIT.Smoke.EFCore100/ # the smoke tests against the EF Core 10 build: target framework, package pins, project reference
EntityFrameworkPlus.EFCore.MIT.Smoke.EFCore110/ # the same against the EF Core 11 build
FORK.md # this file
README.md # the fork's readme; packed as the nuget.org readme too
The library csproj mirrors Z.EntityFramework.Plus.EFCore9x.NET8.csproj with these differences:
| 9x (upstream) | 10x (.MIT) |
|
|---|---|---|
TargetFramework |
net8.0 |
net10.0 |
AssemblyName / RootNamespace |
Z.EntityFramework.Plus.EFCore |
EntityFrameworkPlus.EFCore.MIT / Z.EntityFramework.Plus (no .resx anywhere, so the change is safe) |
DefineConstants |
… EFCORE_8X EFCORE_9X |
same + EFCORE_10X (upstream already guards with it) |
<Import> list |
15 projitems | 13 — upstream's without BatchDelete and BatchUpdate (replaced by the fork's Batch/) and QueryHook, plus Z.EF.Plus.MIT.Shared |
PackageReference |
EF Relational 9.0.0, Z.EntityFramework.Extensions.EFCore, Z.Expressions.Eval |
Microsoft.EntityFrameworkCore.Relational 10.0.0 only |
| Fork sources | — | through the Z.EF.Plus.MIT.Shared import; the project folder holds only the csproj |
NoWarn |
— | CS1591 (upstream XML docs are incomplete), EF1001 (upstream reflects EF internals by design) |
SignAssembly |
False |
False (no key needed) |
The 11x csproj is the 10x one with net11.0, Microsoft.EntityFrameworkCore.Relational 11 (the release
candidate until EF Core 11 ships), EFCORE_11X appended to the symbols and <Version>11.105.8.1</Version>:
the first component of the package version is the EF Core major, as in upstream's own 9.x / 10.x pairs,
so both projects share the package id and differ in version. Everything they compile is the shared code.
Package metadata (in the csproj): PackageId EntityFrameworkPlus.EFCore.MIT (two prefixes are reserved on nuget.org: Z.EntityFramework.* by ZZZ Projects and
EntityFramework.* by Microsoft — the first publish attempt, as EntityFramework.Plus.EFCore.MIT, was rejected
for the latter; a 404 from the package index only proves no package exists, reservation shows only on the
Upload page or in the push response); Version = upstream tag; Authors upstream + fork maintainer;
Copyright upstream's; PackageLicenseExpression MIT with upstream's LICENSE packed;
PackageProjectUrl / RepositoryUrl this repository; Description names what is and is not included.
The test project follows upstream's per-version layout exactly: the csproj is
Z.Test.EntityFramework.Plus.EFCore90.csproj with the target, package versions and project reference
changed (current MSTest instead of upstream's 1.1.18, which does not run on .NET 10). Fork-specific
additions sit at the end of the csproj: Compile Remove for the 25 batch test files that pass a
BatchUpdate / BatchDelete options lambda (BatchSize, BatchDelayInterval, Executing,
InMemoryDbContextFactory, the Skip / Take visitor tests) and three repro files that call EFE
directly (UpdateFromQuery, BulkInsert) — every other upstream batch test runs against the fork's
implementation; a Compile Include of the EFCore90 project's own QueryIncludeOptimized/ tests,
compiled from where they live rather than copied (upstream keeps a copy per test project, and the older
copies have drifted); and a fork-owned DeleteFromQuery → ExecuteDelete() helper so one
IncludeOptimized-with-Future repro test keeps compiling. No App.config: it is EF6-era configuration and
the shared tests hard-code their connection string.
The smoke tests follow the same split: the sources are EntityFrameworkPlus.EFCore.MIT.Smoke.Shared, a
.projitems like upstream's, and EntityFrameworkPlus.EFCore.MIT.Smoke.EFCore100 is a csproj holding only
the target framework, the package pins and the project reference. An EF Core 11 target adds
…Smoke.EFCore110 and nothing else. The SQL Server database name EFPlusMitSmoke is shared by every
version, so two versions must not run against one server at once; suffix it per version when the second
arrives.
Prerequisites: .NET SDKs 10.0.x and 11.0.x (the release candidate until EF Core 11 ships); a local
trusted SQL Server (localhost). The test projects create their own databases: upstream's suite uses
Z.Test.EntityFramework.Plus.EFCore (hard-coded upstream, so the EF Core 10 and 11 runs must not overlap),
the smoke fixture EFPlusMitSmoke<EF major>; the smoke connection string can be overridden with
EFPLUS_MIT_SMOKE_CONNECTION. The smoke test's batch theories run on InMemory and on SQLite in-memory,
which need nothing installed.
Upstream's other projects in the solution still need the paid packages — and upstream master does
not even build against the EFE version its 9x project pins (GetParameterName arrived in EFE after
9.104.0.1). So work through the solution filters, one per EF Core version
(src/EntityFrameworkPlus.EFCore.MIT.EFCore10.slnf, …EFCore11.slnf), each selecting that version's
library, upstream's suite and the smoke tests; open a .slnf in Rider or Visual Studio instead of the .sln.
dotnet test src/EntityFrameworkPlus.EFCore.MIT.EFCore10.slnf -c Release
dotnet test src/EntityFrameworkPlus.EFCore.MIT.EFCore11.slnf -c Release # after the first: the upstream suites share a database
dotnet pack src/Z.EntityFramework.Plus.EFCore10x.NET10 -c Release # → bin/Release/*.nupkg; EFCore11x.NET11 likewiseWhat the two test projects prove, at 10.105.8.1 on EF Core 10.0.3, and with the same numbers on EF Core 11 RC 1:
- Upstream suite (270 tests): QueryFilter 79, QueryIncludeOptimized 44, QueryIncludeFilter 38,
BatchUpdate 31, QueryCache 25, BatchDelete 18, QueryFuture 7, QueryDeferred 2, upstream repro cases 26.
The 49 batch tests are upstream's own (
Value,WhereValue,Action,ActionAsync,PrimaryKey,SqlSchema,Transaction,Keyword, oneVisitorcase each), on SQL Server, against the fork'sExecuteUpdate/ExecuteDeleteimplementation; the 25 files bound to EFE options are excluded. Audit contributes nothing on EF Core in upstream's suite either (106 of its 126 test files are#if EF5 || EF6). - Smoke (47 tests). Query Future (8): two entity futures +
DeferredCount+DeferredFirstOrDefaultin one server round trip (SqlConnection.RetrieveStatistics), with and withoutEnableRetryOnFailure— the buffering case is the one that justifies the compile step; anIncludegraph; two queries whose EF parameters have the same name but different values (exercisesGetParameterName); a global query filter reading a context property (runtime parameters); the sync path;FromCachehitting the cache; and the InMemory provider (non-batched path). Batch (39: 17 theories × InMemory and SQLite, so the statement path and the fallback path answer the same assertions, plus five InMemory facts): assigned members set; a constant into a nullable member (the efcore#37974 case); a value reading its own row; rows reached through a join; zero matches; persisted withoutSaveChangeswhile loading nothing into the context; a tracked instance left unchanged when its row is updated or deleted; the unit of work's other pending changes left unsaved; the sync overloads; a non-initializer factory rejected; theContextFactoryhook being the context saved through; both ways the second context cannot be had — the hook returning the query's own context, and a context type the default cannot construct with no hook set; and a hook handing out the same context on every call, which two updates of the same rows and two deletes leave undisposed, usable and tracking nothing.
Line coverage of the fork-owned code (Batch\, Shim\), both suites merged, is 93%: 278 of 299 lines.
Uncovered: the two IsCommunity setters nothing calls, PublicMethods.GetDatabase and the @_ branch of
GetParameterName (paths upstream no longer reaches on EF Core 10), the FieldInfo branches of the
update factory (no test entity maps a field), and ToZInfo for Set Identity, which neither suite
exercises. Collect it with dotnet test … --collect "Code Coverage;Format=Cobertura"; no extra package is
needed, Microsoft.NET.Test.Sdk carries the collector.
Package check after dotnet pack: the nuspec's only dependency is Microsoft.EntityFrameworkCore.Relational,
lib/net10.0/ holds EntityFrameworkPlus.EFCore.MIT.dll + .xml, LICENSE is at the root, and the
<repository> element records the upstream commit the build came from.
Two GitHub Actions workflows in .github/workflows/, both on windows-latest with SQL Server 2022
installed by Potatoqualitee/mssqlsuite (upstream's suite needs localhost + Windows authentication):
-
build.yml— on pushes tomaster-MITandfeatures/**, and PRs tomaster-MIT: with both SDKs installed (11.0.xat preview quality while EF Core 11 is a release candidate), build and test the EF Core 10 filter, then the EF Core 11 one, pack both libraries, upload the.nupkgs as one artifact. -
publish.yml— on a pushed tagmit/<version>(orworkflow_dispatchwith a version): the version's first component picks the project and filter (10.*→EFCore10x.NET10,11.*→EFCore11x.NET11); build, test, pack as<version>, push to nuget.org. The version must equal that csproj's<Version>or be a prerelease of it (10.105.8.1-preview.1), which keeps the "package version = upstream tag" rule mechanical. A 409 fails the run: nuget.org uses it both for "version already exists" and for "package ID is reserved", and--skip-duplicatewould report either as success. Re-running an already published version therefore fails with a clear message, which is the right outcome.Authentication is nuget.org Trusted Publishing: a policy on the package owner's nuget.org account bound to repository
sovist/EntityFramework-Plus-MIT, workflow filepublish.yml, scope "push new packages and package versions", packageEntityFrameworkPlus.EFCore.MIT. The job requests an OIDC token (id-token: write) andNuGet/loginexchanges it for a short-lived API key. No secret is stored in the repository; if the policy is deleted, publishing stops until it is recreated.
Publishing therefore is: merge to master-MIT, then git tag mit/<version> && git push origin mit/<version>.
Publish a -preview.N first when the release has not yet been exercised by a real consumer; nuget.org
versions are immutable and the four-component scheme leaves no room for a fork-only fix.
-
On GitHub, Sync fork → Update branch on
master(orgh repo sync sovist/EntityFramework-Plus-MIT --branch master). Agit push origin masteris rejected; this is the only waymastermoves. -
Locally:
git fetch origin && git fetch upstream --tags # upstream's tags name the release git checkout master && git merge --ff-only origin/master # local mirror catches up git checkout -b features/sync-<upstream-tag> master-MIT && git merge master
-
On that branch, in order: contract-check grep → read the upstream diff for the folders we import (
git diff <previous-upstream-tag>..master -- src/shared/) → build the library → run both test projects (a new upstream test that calls EFE directly shows up as a compile error: add it to theCompile Removelist) → update<Version>in the csproj to the new upstream tag. -
Pull request into
master-MIT, merge commit (ground rule 4) → tagmit/<version>and let CI publish.
If upstream ever adds its own EFCore10x project, diff it against ours and prefer theirs.
- The package version mirrors the upstream tag. Consumers can read "which upstream is this" off the
version alone. The first component is the EF Core major the build is for:
10.105.8.1and11.105.8.1are one upstream release built for EF Core 10 and 11, as upstream's own9.x/10.xare. - Upstream uses four components (
10.105.8.1), which leaves no room for a fork-only patch number. Policy: a fork-only fix waits for the next upstream release. If one cannot wait, bump the fourth component and record the mapping in this section. - Git tags are
mit/<version>, so they never collide with upstream's bare<version>tags (those are fetched into local clones fromupstreambut never pushed to this repository).
The record of published versions is the mit/* tags here and the version history on nuget.org; each
tag points at the master-MIT commit the package was built from, and the History section below says
what changed. A fork-only bump of the fourth component, should one ever happen, gets a note in this
section with the upstream tag it maps to.
- Async. Only Query Future is truly asynchronous. Query Deferred's
ExecuteAsync, Query Cache'sFromCacheAsyncand the IncludeFilter/IncludeOptimized async paths areTask.Runover synchronous code — exactly as upstream. Not a fork regression, and not something the fork fixes. - LinqKit.
GetInnerForLinqKitis the identity function, so a query wrapped by LinqKit'sAsExpandable()is not unwrapped andGetDbContext()on it fails. If that is ever needed, implement the unwrap via reflection onExpandableQuery<T>.InnerQuery. - Precompiled queries / NativeAOT are not supported: the compile step compiles at runtime, like upstream does.
- Reflection on EF Core internals — as upstream, plus the fields listed above. Every EF Core bump needs both test projects.
- Not for applications that reference
Z.EntityFramework.Extensions.EFCoredirectly for its own features; they have the dependency anyway and should stay on upstream. - Batch Update / Delete are EF Core translations, not EFE's SQL generation: a query EFE accepted may not translate, the options builders are gone, and the InMemory fallback loads the matching rows (which is what InMemory is for). Details in the Batch section above.
-
AssemblyNamerenamed toEntityFrameworkPlus.EFCore.MIT: provenance visible inbin/, and no two DLLs with the same name if both packages ever meet. Namespaces stayZ.EntityFramework.Plusbecause upstream code is untouched, so consumer code changes only itsPackageReference. -
Fork-owned code is a shared project,
src/shared/Z.EF.Plus.MIT.Shared(Shim/andBatch/), imported by each versioned csproj exactly like upstream's feature folders, so an EF Core 11 project is a csproj plus#if EFCORE_11Xbranches wherever EF's internals moved. It started inside the 10x project and was promoted once EF Core 11 was two months out: a rename-only change then, rather than a rename buried inside the port. -
Upstream's solution is the entry point; the fork's projects are added to it (ground rule 1).
-
Tests = upstream's own suite + a fork-owned smoke test. Upstream's suite proves the features still behave; the smoke test proves the one thing upstream's suite cannot (single round trip, and scalar futures under buffering).
-
README replaced. Upstream's
README.mdwas marketing for ZZZ Projects' products (including the dependency this fork removes); ours describes the package and is also packed asPackageReadmeFile. If an upstream merge conflicts on it, take ours. -
Batch Update / Delete reimplemented, not dropped. The first release excluded them and pointed at
ExecuteUpdate/ExecuteDelete. Migrating a real consumer showed what that costs: a wrapper extension method in every consumer with aDbContextparameter, because InMemory has noExecuteUpdateand the context behind a query is not reachable through public API. Upstream's internalGetInMemoryContext()already solves that inside the library, so the four methods keep upstream's signatures and a migrating consumer changes only itsPackageReference. Object-initializer factory only; the other factory forms and the options builders were EFE features with EFE semantics. -
The InMemory fallback saves through a second context, as EF Extensions did, not through the query's own. The first version saved through the query's context and restored its tracked instances afterwards; a consumer's test caught what that cannot restore — the unit of work's other pending changes, which a statement leaves alone and which the consumer diffed for history right after the batch call.
EntityFrameworkManager.ContextFactorykeeps EF Extensions' name and signature so the consumer's existing hook needs no change; the options-constructor default covers contexts that need nothing else.
Open:
- nuget.org co-owner for the package, so it does not depend on one account.
- Additional targets (EF Core 8/9): not planned. EF Core 11 is built and tested from RC 1 on;
publishing
11.xwaits for EF Core 11 to ship, or for a consumer on .NET 11, whichever comes first.
- 2026-09-24 — forked from
zzzprojects/EntityFramework-Plusat tag10.105.8.1(0397735);master-MITbranched frommaster; this document written before any code. - 2026-09-24 — library project and shim built. Found that on EF Core 3+ the Query Future / Query Cache
compile step lives entirely in EFE (
dbb2b69, Feb 2020), and that Query Hook is a pure EFE facade; reimplemented the compile step on EF Core's public pipeline with the enumerable-cardinality override. Upstream suite 219/219 and smoke 8/8 green on SQL Server 2022 + InMemory; package packs with EF Core Relational as its only dependency. - 2026-09-24 — first publish (
mit/10.105.8.1-preview.1, asEntityFramework.Plus.EFCore.MIT) was rejected by nuget.org:EntityFramework.*is a Microsoft-reserved prefix. The run still went green because--skip-duplicatereports every 409 as "already exists". Renamed the package toEntityFrameworkPlus.EFCore.MIT, removed--skip-duplicate, deleted the tag; nothing was published. - 2026-09-25 — Batch Update / Batch Delete reimplemented on
ExecuteUpdate/ExecuteDeletewith upstream's signatures (Batch/), after migrating a consumer showed that dropping them forces a context-taking wrapper into every consumer. Upstream's batch tests re-included except the 25 files bound to EFE options: upstream suite 270/270, smoke 38/38 (batch theories on InMemory and SQLite). Found dotnet/efcore#37974 on the way (constant into a nullable member fails to translate on EF Core 10.0.0–10.0.6) and routed row-independent values through theSetPropertyvalue overload. - 2026-09-25 — InMemory fallback reworked to save through a second context, after a consumer test showed
the shared-context version flushing the unit of work's pending changes:
EntityFrameworkManager.ContextFactory(EF Extensions' hook, now public in the shim) or the context's own type from its options;TrackedInstancesremoved. Smoke 43/43, upstream 270/270. - 2026-09-25 —
mit/10.105.8.1-preview.2published frommaster-MITafter PR #3 (batch) and PR #4 (bothContextFactoryfailure paths covered). Fork-owned code promoted to shared projects,Z.EF.Plus.MIT.Sharedfor the library andEntityFrameworkPlus.EFCore.MIT.Smoke.Sharedfor the tests, with one smoke csproj per EF Core version (…Smoke.EFCore100). Package content unchanged. - 2026-09-25 — A context returned by
ContextFactoryis no longer disposed by the fallback: it is its owner's, and a container hands out the same instance per scope, so a consumer's cleanup job making five batch calls per unit of work hitObjectDisposedExceptionon the second. The fallback now detaches the rows it attached instead, and disposes only a context it built itself. Smoke 45/45, upstream 270/270. - 2026-09-25 — EF Core 11 target from RC 1:
EFCore11x.NET11and the twoEFCore110test projects, one solution filter and one CI pass per version,publish.ymlpicking the project by the version's major. One#if EFCORE_11X(ExtractParameters); everything else compiled and passed unchanged: upstream 270/270, smoke 47/47 on EF Core 11 RC 1, the same on EF Core 10.