ProphetsWay.EFTools
3.0.0
Prefix Reserved
dotnet add package ProphetsWay.EFTools --version 3.0.0
NuGet\Install-Package ProphetsWay.EFTools -Version 3.0.0
<PackageReference Include="ProphetsWay.EFTools" Version="3.0.0" />
<PackageVersion Include="ProphetsWay.EFTools" Version="3.0.0" />
<PackageReference Include="ProphetsWay.EFTools" />
paket add ProphetsWay.EFTools --version 3.0.0
#r "nuget: ProphetsWay.EFTools, 3.0.0"
#:package ProphetsWay.EFTools@3.0.0
#addin nuget:?package=ProphetsWay.EFTools&version=3.0.0
#tool nuget:?package=ProphetsWay.EFTools&version=3.0.0
ProphetsWay.EFTools
Write your Data Access Layer as declarations. EFTools is a set of abstract Entity Framework Core base
classes that implement the CRUD, paging, soft-delete, ordering, transaction, ownership and disposal plumbing
behind a ProphetsWay.BaseDataAccess Data
Access Layer — so the only code you write is the code your own contract adds.
Which version this document describes
This README documents the 3.0.0 line, which is not released. There is no 3.0.0 package on nuget.org and you cannot install it today. The published package is 2.2.0 — a different library, built on Entity Framework 6 and EF Core, targeting
net461throughnet90, with a class surface 3.0.0 removes. Nothing below applies to it. If you are on 2.2.x, read CHANGELOG.md before you plan an upgrade; the migration is real work and there are no compatibility shims.The 2.2.x classes are gone from the source, not deprecated in place. See Names removed in 3.0.0 for what they were and what took over.
Why EFTools
Write an Entity Framework Data Access Layer by hand and you write the same forty lines per entity, seven
times. Read untracked; order stably or your paging silently overlaps; copy the argument so a rollback can
actually reverse an update; stamp created, updated and deleted dates; detach in a finally so a failed write
does not flush on the next SaveChanges; translate the identifier lookup into a parameterized predicate
rather than a SQL literal. Every one of those is easy to get subtly wrong, and none of them is your domain.
EFTools already wrote them. You pick the base class matching what your Data Access Object actually promises, pass it the context, and stop.
Here is a complete Data Access Object from the reference implementation in this repository:
internal class JobDao : BaseGetAllDao<Job, int>, IJobDao
{
public JobDao(DbContext context) : base(context) { }
}
That class has working Get, Insert, Update, Delete and GetAll — untracked reads, copy-on-write,
stable ordering, parameterized key lookup — and one line of body.
Highlights
- A Data Access Object costs a constructor. Of the seven in this repository's proving ground, one writes nothing beyond it, four write exactly one member of their own, and none writes more than three.
- Soft delete is free, and it is not bypassable.
BaseSoftDaoexpresses every difference as anoverride, never anew, so a soft Data Access Object reached through aBaseDao-typed reference still soft-deletes instead of destroying the row. - Keys are not restricted to
int/long/Guid.TKeycarries no constraint at all, so astringkey or anint?key is legal. - Keyless entities have a real home. Join tables and composite natural keys get a family whose
Root*members implement no capability interface — deriving from one commits you to publishing nothing. - Disposal and transactions are specified, not improvised.
Disposeis sealed, idempotent, never throws, and rolls an open transaction back; every other member throwsObjectDisposedExceptionafterward; the caller states who owns the context. - The library names no database provider. Nothing in its C# calls
UseSqlServer. You build the options; you pick the provider. One caveat, stated honestly: provider neutrality.
Install
3.0.0 is unreleased. To use the library described here, build it from source — see Building & Testing Locally.
The package that exists on nuget.org today is the 2.2.x line, which this document does not describe:
dotnet add package ProphetsWay.EFTools --version 2.2.0
Install-Package ProphetsWay.EFTools -Version 2.2.0
What 3.0.0 will target
| Target framework | net10.0 — and only net10.0 |
| Entity Framework | Core 10.0.11. Entity Framework 6 is not supported |
| Contracts | ProphetsWay.BaseDataAccess 3.2.0 |
The single target is deliberate rather than an oversight — see Architecture & Design Decisions. If you are on .NET Framework, .NET 8 or .NET 9, stay on the 2.2.x line.
Quick Start
Three files: a context, a Data Access Object, and a Data Access Layer that hands them the context.
1 — Derive your context
BaseEFContext selects no provider and adds no members. Whoever builds the options names the database.
using Microsoft.EntityFrameworkCore;
using ProphetsWay.EFTools;
public class ExampleContext : BaseEFContext
{
public ExampleContext(DbContextOptions<ExampleContext> options) : base(options) { }
public DbSet<Company> Companies { get; set; }
public DbSet<Job> Jobs { get; set; }
public DbSet<User> Users { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>().HasOne(x => x.Company).WithMany().HasForeignKey("CompanyId");
modelBuilder.Entity<User>().HasOne(x => x.Job).WithMany().HasForeignKey("JobId");
modelBuilder.Entity<Company>().ToTable("Companies");
modelBuilder.Entity<Job>().ToTable("Jobs");
modelBuilder.Entity<User>().ToTable("Users");
}
}
Deriving from BaseEFContext is optional. BaseEFDataAccess<TContext> constrains its context to
DbContext, so a context you already have — one registered in a dependency-injection container, for
instance — needs no re-parenting.
2 — Derive each Data Access Object from the family matching its capability
using Microsoft.EntityFrameworkCore;
using ProphetsWay.EFTools;
internal class UserDao : BaseDao<User, int>, IUserDao
{
public UserDao(DbContext context) : base(context) { }
public void CustomUserFunctionality(User user)
{
user.Whatever = "custom functionality triggered";
Update(user);
}
}
3 — Derive your Data Access Layer, and say who owns the context
using Microsoft.EntityFrameworkCore;
using ProphetsWay.EFTools;
public class ExampleDataAccess : BaseEFDataAccess<ExampleContext>, IExampleDataAccess
{
private readonly IUserDao _userDao;
//The provider is named here, in your own file. A PostgreSQL consumer writes UseNpgsql instead.
public ExampleDataAccess(string connectionString)
: this(new ExampleContext(new DbContextOptionsBuilder<ExampleContext>()
.UseSqlServer(connectionString)
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking)
.Options), ContextOwnership.Owned)
{ }
//A context someone else built and will dispose - a DI container, or a test with its own database.
public ExampleDataAccess(ExampleContext context)
: this(context, ContextOwnership.Borrowed)
{ }
private ExampleDataAccess(ExampleContext context, ContextOwnership ownership)
: base(context, ownership)
{
_userDao = new UserDao(Context);
}
public User Get(User item)
{
ThrowIfDisposed();
return _userDao.Get(item);
}
public void Insert(User item)
{
ThrowIfDisposed();
_userDao.Insert(item);
}
public int Update(User item)
{
ThrowIfDisposed();
return _userDao.Update(item);
}
public int Delete(User item)
{
ThrowIfDisposed();
return _userDao.Delete(item);
}
}
What you get
Every CRUD call on User works. Reads come back as untracked snapshots. Writes read your argument rather
than adopting it. Insert assigns the store-generated identifier back onto your instance once the row has
committed. The layer disposes the context it built, and refuses every call once disposed.
using (var dal = new ExampleDataAccess(connectionString))
{
dal.Insert(user); //user.Id now holds the stored identifier
var fetched = dal.Get<User>(user.Id);
}
Get<T>(id) comes from ProphetsWay.BaseDataAccess's reflection dispatcher, which BaseEFDataAccess
derives from and overrides. No type parameter on your layer confers it.
Core Concepts
The entity is the argument, and that is the point
Every CRUD method takes the entity as its argument — Get(User item), not GetUser(int id). It looks
redundant until you see what it buys: your Data Access Layer publishes one method name per operation, and
the argument type selects which Data Access Object handles it. Insert(company) and Insert(user) are the
same name on the same interface, and nothing in your business layer names a table, a Data Access Object or a
store.
For Get and Delete, only the identifier property of the instance you pass needs a value.
Get does not promise to return the instance you passed. Use the return value — it is a fresh untracked
snapshot, never your argument and never the store's own tracked object.
Base* publishes; Root* does not
The prefix is load-bearing in this library, and the two are not interchangeable:
Base*implements at least oneProphetsWay.BaseDataAccesscapability interface. Deriving from it means your Data Access Object answers that interface, whether or not your own contract mentions it.Root*is plumbing that implements no capability interface. Deriving from it commits you to nothing; your own interface declares exactly the subset you support.
That distinction is why a join table's Data Access Object can refuse to publish Get and have the refusal
hold structurally rather than by convention.
The key is resolved by name, and its type is open
BaseDao<TEntity, TKey> finds the identifier property by name — {TypeName}Id first, then Id — and
validates it in the constructor, so a mis-wired entity fails when you build the layer rather than on first
use. The property must be a public instance property of type TKey carrying a set accessor of any
visibility.
An explicit
IBaseIdEntity<T>implementation does not satisfy this. It compiles to a non-public, interface-qualified property that neither lookup finds, and the constructor throwsDataAccessConventionException.
TKey carries no constraint, so string and int? are legal keys alongside int, long and Guid.
The layer owns the context, the transaction and disposal
Data Access Objects have no transaction members in 3.0.0. TransactionStart, TransactionCommit and
TransactionRollBack live on BaseEFDataAccess<TContext>, because a transaction in EF Core lives on the
context and every Data Access Object on a layer shares one.
- One transaction per instance, and they do not nest — a second
TransactionStartthrowsInvalidOperationException. - A failed commit leaves nothing open and discards its writes. Do not follow one with
TransactionRollBack; there is nothing left to roll back and that call throws. TransactionStartnever inspectsContext.Database.CurrentTransaction, so it can never check-and-skip. On aBorrowedcontext whose owner already began a transaction directly, EF Core's own exception propagates unwrapped and that outer transaction is left in force.
Disposal is a specified contract, not a convenience. Dispose is sealed, idempotent and never throws; it
rolls an open transaction back; it runs your DisposeCore() hook; and it disposes the context only
under ContextOwnership.Owned. All ten other members throw ObjectDisposedException once it has run.
One obligation is yours. Nothing intercepts calls to members your layer declares. Call the
protected ThrowIfDisposed()as the first statement of every per-entity forwarder and every custom method you write — as the Quick Start layer above does — or a disposedBorrowedlayer will silently serve them from a context that is still alive.
Ownership is stated, never defaulted
public enum ContextOwnership { Borrowed = 0, Owned = 1 }
There is no default to fall into, and the constructor rejects anything that is neither value with
ArgumentOutOfRangeException. A defaulted ownership is the shape that produces a silent double-dispose in a
dependency-injection host and a silent leak in a hand-built one, depending only on which way the default
happened to point.
API Reference
Thirteen public declarations — twelve abstract classes and one enum. All in namespace
ProphetsWay.EFTools, with no sub-namespaces.
The context and the layer
| Type | Member | Purpose |
|---|---|---|
BaseEFContext : DbContext |
protected BaseEFContext(DbContextOptions) |
Its only member. Optional base for your context; selects no provider |
BaseEFDataAccess<TContext> |
protected BaseEFDataAccess(TContext, ContextOwnership) |
Its only constructor. Captures a built context and who disposes it — no query, no connection, no transaction |
protected TContext Context |
The context every Data Access Object on the layer shares, typed so you construct them without a cast | |
protected ContextOwnership Ownership |
What you told it | |
protected bool IsDisposed |
true from the first statement of Dispose onward |
|
protected void ThrowIfDisposed() |
Call this first in every member you declare | |
public override void TransactionStart() |
Begins; throws if one is already open | |
public override void TransactionCommit() |
Commits and clears state; a failure discards the writes | |
public override void TransactionRollBack() |
Rolls back and clears state | |
public sealed override void Dispose() |
Idempotent, non-throwing; rolls back; disposes the context only when Owned |
|
protected virtual void DisposeCore() |
Your hook. Must not throw | |
Get<T>(object id), GetAll<T>(), GetPaged<T>(int, int), GetCount<T>(), Insert<T>, Update<T>, Delete<T> |
Dispatcher overrides inherited from ProphetsWay.BaseDataAccess, each guarded |
|
ContextOwnership (enum) |
Borrowed = 0, Owned = 1 |
No default value |
BaseEFDataAccess<TContext> is declared
: BaseDataAccess.BaseDataAccess, IBaseDataAccess where TContext : DbContext.
The keyed families — where TEntity : class, IBaseIdEntity<TKey>
| Type | Base | Adds |
|---|---|---|
BaseDao<TEntity, TKey> |
— | IBaseDao<TEntity> — Get, Insert, Update, Delete |
BaseGetAllDao<TEntity, TKey> |
BaseDao |
IBaseGetAllDao<TEntity> — publishes GetAll |
BasePagedDao<TEntity, TKey> |
BaseDao |
IBasePagedDao<TEntity> — publishes GetPaged and GetCount |
Each takes protected BaseXxxDao(DbContext context). BaseDao<TEntity, TKey> carries:
| Kind | Members |
|---|---|
| Public | TEntity? Get(TEntity item), void Insert(TEntity item), int Update(TEntity item), int Delete(TEntity item), IList<TEntity> GetAll(TEntity? item), IList<TEntity> GetPaged(TEntity? item, int skip, int take), int GetCount(TEntity? item) |
| Protected state | DbContext Context, DbSet<TEntity> Dataset |
| Protected seams | TrackForWrite, ApplyUpdateValues, GetKey, MatchRow, KeyEquals, KeySelector, ApplyReadFilter, ApplyIncludes, ApplyStableOrder |
The
itemargument onGetAll,GetPagedandGetCountis a type selector only, and it isnullwhen the call arrives through the dispatcher. Never read it.
Getis annotatedTEntity?, and "not found" is what that expresses. The whole library is compiled under#nullable enable, andProphetsWay.BaseDataAccess3.2.0 annotatesIBaseDao<T>to match — so a call through the interface now bindsCompany?where it used to bind an unannotatedCompany. If you compile with nullable warnings on, expect aCS8602at any site that dereferences aGetresult without checking it. It is a new warning, not a break: the change is source- and binary-compatible, and the warning is pointing at a null you could always have received.
The keyed soft-delete families — where TEntity : class, IBaseSoftIdEntity<TKey>
| Type | Base | Adds |
|---|---|---|
BaseSoftDao<TEntity, TKey> |
BaseDao<TEntity, TKey> |
Soft Insert / Update / Delete, the created, updated and deleted timestamps, and a DeletedDate == null read filter |
BaseSoftGetAllDao<TEntity, TKey> |
BaseSoftDao |
IBaseGetAllDao<TEntity> |
BaseSoftPagedDao<TEntity, TKey> |
BaseSoftDao |
IBasePagedDao<TEntity> |
Two timestamp hooks are overridable: protected virtual DateTime GetCurrentTimestamp() and
protected virtual DateTime NormalizeRetrievedTimestamp(DateTime value) — the latter restores the
DateTimeKind a relational provider does not persist.
ApplyReadFilter adds DeletedDate == null and nothing else, so GetAll, GetPaged and GetCount
agree with one another while the unfiltered Get still finds a deleted row.
The keyless families
| Type | Base | Implements | For |
|---|---|---|---|
RootNonIdDao<TEntity> |
— | nothing | TEntity : class, IBaseEntity. Plumbing that commits you to no interface |
BaseNonIdDao<TEntity> |
RootNonIdDao |
IBaseDao<TEntity> |
The same, when you do want the full shape. Adds virtual Get and Update |
RootSoftNonIdDao<TEntity> |
RootNonIdDao |
nothing | TEntity : class, IBaseSoftEntity. Keyless soft delete |
BaseSoftNonIdDao<TEntity> |
RootSoftNonIdDao |
IBaseDao<TEntity> |
Keyless soft delete with the full shape |
RootNonIdDao<TEntity> publishes Insert, Delete, GetAll, GetPaged and GetCount, and exposes
GetCore, UpdateCore, TrackForWrite, ApplyStableOrder, ApplyReadFilter, ApplyIncludes and
ApplyUpdateValues as protected seams. Two members deserve attention:
protected abstract Expression<Func<TEntity, bool>> MatchRow(TEntity item)— the predicate identifying a single row. It replaces the entire identifier apparatus, and it is the only abstract member in the library. It is translated to SQL, not evaluated in memory.ApplyStableOrderthrowsNotSupportedExceptionuntil you override it.GetCoreandUpdateCoreorder nothing and are unaffected, so a write-only join Data Access Object is fully functional withMatchRowalone.
Common Scenarios
Every sample below is taken from ProphetsWay.Example.DataAccess.EF, the working implementation in this
repository.
A paged Data Access Object with one method of its own
protected DbSet<TEntity> Dataset is available on the keyed and keyless families alike, so a custom method
composes over it directly.
using Microsoft.EntityFrameworkCore;
using ProphetsWay.EFTools;
using System.Linq;
internal class CompanyDao : BasePagedDao<Company, int>, ICompanyDao
{
public CompanyDao(DbContext context) : base(context) { }
public Company GetCustomCompanyFunction(int id)
{
return Dataset.OrderBy(x => x.Id).Skip(id % GetCount(null)).First();
}
}
Soft delete, and reaching a row the read filter hides
DepartmentDao derives from BaseSoftPagedDao<Department, int> and satisfies eighteen of its nineteen
contract rules without overriding anything. The nineteenth — Restore — belongs to its own interface and
cannot be expressed through Update, which deliberately preserves the stored DeletedDate.
TrackForWrite is the seam that makes it possible: it starts from the raw Dataset, which is what keeps
ApplyReadFilter off the query. That filter excludes exactly the rows Restore exists to reach, so a
Restore composed on top of it would return 0 for every department it was ever asked about.
public int Restore(Department item)
{
if (item == null)
throw new ArgumentNullException(nameof(item));
var stored = TrackForWrite(item);
if (stored == null)
return 0;
try
{
if (!stored.DeletedDate.HasValue)
return 0;
stored.DeletedDate = null;
Context.SaveChanges();
item.DeletedDate = null;
return 1;
}
finally
{
Context.Entry(stored).State = EntityState.Detached;
}
}
The finally is not optional. stored is tracked from the moment TrackForWrite returns, so every exit
owes the detach — the success path, a throwing SaveChanges, and the early return that writes nothing. One
left behind carries a pending change that the next write on the shared context would flush.
A keyless join table
CompanyResource has no identifier at all; its identity is the (CompanyId, ResourceId) pair. Its interface
does not inherit IBaseDao<T>, so the Data Access Object derives from RootNonIdDao<CompanyResource>, which
publishes no Get. Deriving from BaseNonIdDao<CompanyResource> instead would publish one and remove that
guarantee silently, with every behavioral test still green.
using Microsoft.EntityFrameworkCore;
using ProphetsWay.EFTools;
using System;
using System.Linq;
using System.Linq.Expressions;
internal class CompanyResourceDao : RootNonIdDao<CompanyResource>, ICompanyResourceDao
{
public CompanyResourceDao(DbContext context) : base(context) { }
protected override Expression<Func<CompanyResource, bool>> MatchRow(CompanyResource item)
{
return x => x.CompanyId == item.CompanyId && x.ResourceId == item.ResourceId;
}
protected override IOrderedQueryable<CompanyResource> ApplyStableOrder(IQueryable<CompanyResource> query)
{
return query.OrderBy(x => x.CompanyId).ThenBy(x => x.ResourceId);
}
//This Data Access Object's own contract: a pair the store already holds is a silent no-op, not an error.
public override void Insert(CompanyResource item)
{
if (item == null)
throw new ArgumentNullException(nameof(item));
if (Dataset.IgnoreQueryFilters().AsNoTracking().Any(MatchRow(item)))
return;
base.Insert(item);
}
}
IgnoreQueryFilters() is required for the same reason every MatchRow-located path in the library calls it:
a consumer-declared global query filter would otherwise hide the stored row from the check, the guard would
fall through, and the base member would hit the primary key.
Map the composite key in your context:
modelBuilder.Entity<CompanyResource>().HasKey(x => new { x.CompanyId, x.ResourceId });
modelBuilder.Entity<CompanyResource>().ToTable("CompanyResources");
Borrowing a context from a dependency-injection container
Illustrative — not currently present in the repo.
services.AddDbContext<ExampleContext>(options => options.UseNpgsql(connectionString));
services.AddScoped<IExampleDataAccess>(sp => new ExampleDataAccess(sp.GetRequiredService<ExampleContext>()));
The ExampleDataAccess(ExampleContext) constructor this calls is real, and it passes
ContextOwnership.Borrowed, so the layer never disposes a context the container owns. The library itself
imposes no dependency-injection framework and references none.
Architecture & Design Decisions
Entity Framework Core only
2.2.0 shipped two implementations chosen by target framework — EF 6.5.1 on .NET Framework and EF Core on
.NET 8 and 9 — and the two did not behave alike. Most visibly, the EF6 Update was an upsert that would
store a row that did not exist, while the EF Core one requires the row to be there already. One package did
not expose one contract. The EF6 branch is removed outright; 2.2.x remains the answer for .NET Framework.
One target framework
net10.0, alone. This library is a thin layer over EF Core, and carrying runtimes EF Core itself no longer
serves cost more than it returned. This is a deliberate, ratified exception to the wider ProphetsWay
convention of shipping netstandard2.0 beside a current LTS — taken here because the dependency makes that
extra reach illusory.
No compatibility wrappers
None exist and none are planned. The 3.0.0 break is real, and the migration is stated plainly in CHANGELOG.md. A shim that let 2.2.x source compile against 3.0.0 semantics would be worse than the break, because the two lines differ behaviorally and not merely structurally.
The key type is open, and the key value is parameterized
The 2.2.x design closed the key over three value types through a where TIdType : struct constraint, which
is why it needed three namespaces. Dropping the constraint collapses eighteen classes into six and makes a
string or int? key expressible for the first time.
The key value is carried into the expression tree by reference, through a private carrier field, so the
provider parameterizes it. Expression.Constant over the key itself would be emitted as a SQL literal and
give a distinct query plan per key value.
Soft delete overrides; it never shadows
Every soft-delete difference is an override. Had any been new, a soft Data Access Object upcast to
BaseDao<TEntity, TKey> — which is what happens whenever a layer holds its Data Access Objects behind their
interfaces — would hard-delete. That is a data-loss bug no test written against the interface would catch.
Keyed and keyless are unrelated inheritance branches
They share no base class, deliberately: a keyless entity has no identifier to make optional. What they do
share is behavior, and that lives in two internal static helpers — EntityGraph for the copy-for-store
walk, the inverse-navigation cleanup and the finally detach, and SoftTimestamps for the stamping policy.
Duplication of declarations across the two branches is structural and accepted; duplication of logic is
not.
Provider neutrality — done in code, not yet in packaging
The library's C# names no provider. UseSqlServer and UseInMemoryDatabase appear nowhere in it. You
build the DbContextOptions, so you choose SQL Server, PostgreSQL, SQLite or in-memory — and 3.0.0 removed
the BaseEFContext(string) constructor that used to make that choice for you.
The packaging does not match yet, and you should know before you plan a restore.
ProphetsWay.EFTools.csproj still carries PackageReference entries for
Microsoft.EntityFrameworkCore.SqlServer and Microsoft.EntityFrameworkCore.InMemory, so a consumer on
PostgreSQL still transitively restores two providers they will never use. Removing a transitive reference is
itself a breaking change, so it is scheduled to land inside 3.0.0 rather than after it. Tracked as FR 7 in
docs/feature-requests.md.
Names removed in 3.0.0
The 2.2.x class surface was deleted outright rather than deprecated in place, so nothing below is in the source, and nothing below has a shim:
- the eighteen key-specific closures in
ProphetsWay.EFTools.Guid,.Intand.Long— those three namespaces no longer exist, so ausingon one stops resolving and the file fails at the top rather than at the class - the
RootBaseDao<T, TIdType>andRootBaseSoftDao<T, TIdType>bridges beneath them, whosewhere TIdType : structconstraint is the one the open key exists to drop LegacyBaseNonIdDao<T>,LegacyBaseSoftNonIdDao<T>andLegacyRootNonIdDao<T>— development-branch scaffolding that lived for a handful of commits so the original names could be taken by the types that replace them. If an alpha or beta cut showed you one, it is not part of 3.0.0 and there is nothing to migrate onto
Each of the eighteen closures has a direct replacement, formed by moving the key type out of the namespace
and into a second type argument — Int.BaseDao<T> becomes BaseDao<T, int>, and so on through all of them.
The full mapping is in CHANGELOG.md; it is not repeated here. The two bridges have no
equivalent — derive instead from whichever open-key family matches what your Data Access Object interface
publishes.
One name survives the deletion with a different meaning: 2.2.x had an internal RootNonIdDao<T>
engine, and 3.0.0 has a public RootNonIdDao<TEntity> extension point. They are different types that
happen to share a name — different visibility, different members, and a MatchRow contract the old one
never had. The internal one was not promoted; it was deleted and its body absorbed into the bases it served.
Nothing transitional ships. The library is the thirteen public declarations listed in the
API Reference plus two internal static helpers, in one flat namespace, and it contains
no conditionally compiled code at all.
Further reading
| Document | Contents |
|---|---|
| docs/api-contract.md | The specification of the 3.0.0 surface, member by member |
| docs/repo-profile.md | Evidence-based inventory — API surface, dependencies, packaging audit, test facts |
| docs/purpose-and-scope.md | What this library is for, and the owner decisions behind it |
| docs/feature-requests.md | The durable record of what was considered and deliberately not built |
Building & Testing Locally
The reference implementation lives partly in a git submodule, so clone recursively:
git clone --recurse-submodules https://github.com/ProphetManX/ProphetsWay.EFTools.git
cd ProphetsWay.EFTools
dotnet restore
dotnet build
If you already cloned without it, run git submodule update --init --recursive.
The solution contains seven projects — three owned here (ProphetsWay.EFTools,
ProphetsWay.Example.DataAccess.EF, ProphetsWay.EFTools.Tests) and four from the
ProphetsWay.Example submodule.
Current SQL Server and Azure test runs
Follow docs/azure-sql-test-execution.md. Local SQL Server defaults to
Disposable; Azure requires EFTOOLS_SQLSERVER_STORE_LIFECYCLE=Reusable.
Set EFTOOLS_PROVIDER=SqlServer explicitly and supply EFTOOLS_SQLSERVER_CONNECTION_STRING
securely as the base connection. Set EFTOOLS_SQLSERVER_SCRATCH_DATABASE_1 and
EFTOOLS_SQLSERVER_SCRATCH_DATABASE_2 to two distinct, pre-existing, dedicated scratch databases
matching ^EFToolsScratch_[A-Za-z0-9_]{1,113}$. Those two configured names are the reset allowlist.
Keep settings private and session-local; do not commit or log connection values. Run only one test
process against a scratch pair at a time: the lease pool coordinates callers within that process.
Start with two empty scratch databases; do not publish the Example DACPAC to either. The fixtures
create their own models after reset.
Destructive reset: reusable leases drop every user foreign key and table before and after use, destroying their contents. Never point scratch settings at a database containing anything you need. Reusable mode does not create or drop databases, or run migrations.
The fixed ProphetsWay.Example database is separate and never scratch-reset. The owner reports its
Example DACPAC and synthetic seed data already applied. Before another authorized live run, validate
the existing schema, connection and authentication; do not automatically republish the DACPAC.
No commit is needed to run tests.
For a separately approved trial, configure the settings above and run from the repository root:
dotnet test ProphetsWay.EFTools.Tests/ProphetsWay.EFTools.Tests.csproj -f net10.0 --filter "Execution!=LocalPhysicalLifecycle"
The filter excludes the six local physical-lifecycle checks, not all Guard=Seam checks.
Keep those physical checks in the unfiltered local Disposable run; their prior 6/6 result is
separate historical evidence, not part of the filtered trial total.
Owner-run filtered trial, 2026-09-08: the retained TRX reconciliation confirms 369 included executions, all passing, zero failures and zero skips, including all 14 provider-selection-exempt comparison cases. The owner reports 99.4 seconds for the trial and a successful build in 103.5 seconds. See the dated result for the evidence boundaries. Azure endpoint and authentication were not independently verified. Gate 2, FR 19, and release remain pending owner review; this passing milestone is not certification or release sign-off. The two owner-specific defaults were removed from the fixture template. The owner prefers history removal but accepts leaving prior history intact and accepts the fixture source as-is. Those decisions do not independently verify live infrastructure or clear the infrastructure review gate.
Running the tests
dotnet test
SQL Server is the default test provider.
TestStore.cs owns connection configuration: localhost,
integrated security and TrustServerCertificate=True by default, with an optional
EFTOOLS_SQLSERVER_CONNECTION_STRING override.
Constants.cs names the fixed ProphetsWay.Example database
and delegates configuration to TestStore. Its schema comes from the submodule's
ProphetsWay.Example.Database project; the existing baseline is prepared separately and is not
scratch-reset. CI policy is unchanged: app-variables.yml sets LocalTestsOnly: 'yes' and the pipeline
skips the suite entirely.
Direct fixtures also use TestStore and follow its provider selection. Set EFTOOLS_PROVIDER=Sqlite
in the test process for SQLite in-memory checks without SQL Server; leaving it unset selects SQL Server.
The following trait filter selects the keyless and insert areas, not a provider:
dotnet test --filter "Area=Keyless|Area=Insert"
How the conformance suite works
The interesting half of the suite is not written here. ProphetsWay.Example.Tests — in the submodule —
tests IExampleDataAccess without naming any implementation. This repository adapts those classes with
one-line subclasses (public class EFUserDaoTests : UserDaoTests { }) and repoints them at the Entity
Framework implementation through a single [ModuleInitializer] in TestSeam.cs.
The same test bodies also run against an in-memory implementation upstream. That is the argument for the whole paradigm: swap the Data Access Layer, change nothing else, and the tests still pass.
The
ProphetsWay.Examplesubmodule is a pinned pointer to another repository. Never edit files underProphetsWay.Example/from here — change them upstream and advance the pointer.
Contributing
Issues and pull requests are welcome at github.com/ProphetManX/ProphetsWay.EFTools.
Two things to know before you open one:
- The 3.0.0 line is open and breaking. The 2.2.x surface is already deleted; additions to the twelve-class surface that replaced it are weighed against docs/api-contract.md first.
- Versions are set by hand in
app-variables.yml, by the owner alone. Do not bump them in a pull request.
Versioning
We use SemVer for versioning. For the versions available, see the tags on this repository.
Changelog
See CHANGELOG.md. The 3.0.0 entry is the migration guide — read it before upgrading from 2.2.x.
Authors
- G. Gordon Nasseri - Initial work - ProphetManX
See also the list of contributors who participated in this project.
License
This project is licensed under the MIT License - see the LICENSE file for details.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.EntityFrameworkCore (>= 10.0.11)
- ProphetsWay.BaseDataAccess (>= 3.2.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 3.0.0 | 110 | 9/12/2026 |
| 3.0.0-518.Beta | 75 | 9/12/2026 |
| 3.0.0-518.Alpha | 65 | 9/12/2026 |
| 3.0.0-514.Alpha | 92 | 8/25/2026 |
| 3.0.0-513.Alpha | 73 | 8/24/2026 |
| 3.0.0-512.Alpha | 78 | 8/23/2026 |
| 3.0.0-510.Alpha | 83 | 8/23/2026 |
| 3.0.0-509.Beta | 71 | 8/23/2026 |
| 3.0.0-509.Alpha | 68 | 8/23/2026 |
| 3.0.0-508.Alpha | 76 | 8/23/2026 |
| 3.0.0-502.Alpha | 76 | 8/23/2026 |
| 2.2.0 | 289 | 5/2/2025 |
| 2.2.0-471.Beta | 171 | 5/2/2025 |
| 2.2.0-471.Alpha | 154 | 5/2/2025 |
| 2.1.1 | 239 | 5/1/2025 |
| 2.1.1-465.Beta | 169 | 5/1/2025 |
| 2.1.1-465.Alpha | 177 | 5/1/2025 |
| 2.1.0-461.Alpha | 162 | 5/1/2025 |
| 2.0.0-445.Beta | 762 | 8/15/2022 |
| 2.0.0-445.Alpha | 250 | 8/15/2022 |
# v3.0.0
Not yet published — nuget.org still serves 2.2.0 as the latest version of this package. Everything below is a
change against that published 2.2.0 package.
This is a rewrite of the library on top of Entity Framework Core alone, and it breaks nearly every consumer.
Read the whole entry before upgrading. There are no compatibility wrappers and none are planned; if the
migration is not worth your afternoon, 2.2.x remains installable and is unchanged — but read the next section
before you decide, because *unchanged* includes four defects this release fixes.
### If you are on 2.2.0: four defects you are exposed to, and three of them are silent
The published 2.2.0 package carries four defects. **Three of the four fail silently** — no exception, no
failed build, no log line, just wrong data — so never having noticed one is not evidence you are unaffected.
Each has its own ```Fixed:``` section further down; this is the short list, so that a reader who is not
upgrading does not have to read the rest of the entry to find them.
- **A leaked context and a leaked connection per Data Access Layer instance.** ```BaseEFDataAccess```
constructed its own ```DbContext``` and never disposed it, and there was no ```Dispose``` for you to call
either. This is the one that is not silent forever: it surfaces as connection-pool exhaustion under load,
and it is attributable once you go looking.
- **Commit and rollback that silently do nothing.** The keyless Data Access Object base began a transaction
only when none was already open, then reached the transaction it never stored through ```?.``` when
committing and rolling back. Both calls returned normally having done nothing at all.
- **```Update``` that un-deletes a soft-deleted row.** Whole-object replacement wiped the stored
```DeletedDate```, so updating a soft-deleted row brought it back into every read. A second ```Delete```
also moved the original timestamp forward and returned ```1``` rather than ```0```.
- **Reads and writes that hand back the store's own tracked object.** An edit you made and never submitted
was written to the database on the next ```SaveChanges``` anywhere on that layer.
**No 2.2.1 will be cut.** These are documented rather than patched — a deliberate decision taken because the
2.2.x consumer base is judged to be effectively empty, not an oversight — and the upgrade to 3.0.0 is the
remedy on offer. **That remedy is not available to everyone.** This release targets ```net10.0``` and
nothing else, so if you are on ```net48```, ```net8.0``` or ```net9.0``` you cannot take it at all, and what
follows is a description of what you are running rather than an upgrade path. For that case the mitigations
live in your own code: there is no ```Dispose``` in 2.2.0 to call, so create as few Data Access Layer
instances as you can and keep them short-lived; drive transactions through ```Context.Database``` yourself
rather than through a Data Access Object's ```EnsureBeginTransaction```/```EnsureTransactionCommit```/
```EnsureTransactionRollback``` members; never pass a soft-deleted entity to ```Update```; and configure
```QueryTrackingBehavior.NoTracking``` on the context you hand in, which closes the ```Get``` half of the
fourth defect though not the ```Insert``` half.
### Entity Framework 6 support is gone
The 2.2.0 package shipped two implementations chosen by target framework — EntityFramework 6.5.1 on the .NET
Framework targets and EF Core on .NET 8 and 9 — and the two did not behave alike. Most visibly, the EF6
```Update``` was an upsert that would store a row that did not exist, while the EF Core one requires the row to
be there already. The EF6 branch has been removed outright. If you were relying on the upsert, you now need an
explicit ```Insert```.
### Target frameworks collapse to net10.0
```net461```, ```net471```, ```net48```, ```net80``` and ```net90``` are all gone and ```net10.0``` is the only
target. This is deliberate rather than incidental — this library is a thin layer over EF Core, and carrying
runtimes EF Core itself no longer serves was costing more than it returned. If you are on .NET Framework, .NET 8
or .NET 9 you cannot restore this version and should remain on the 2.2.x line. Entity Framework Core moves from
9.0.4 to 10.0.11 alongside it.
### ProphetsWay.BaseDataAccess moves from 2.5.0 to 3.2.0
Three changes in that dependency reach you through this one. ```IBaseDataAccess``` now extends ```IDisposable```,
so every Data Access Layer has a ```Dispose``` and callers are expected to use one. Exceptions thrown by your
own Data Access Layer methods are no longer wrapped in ```TargetInvocationException``` on their way out of the
dispatcher — if you were catching that wrapper and unwrapping ```InnerException```, catch the real exception type
instead. And 3.2.0 annotates that package's contracts for nullable reference types, which changes what the
compiler can tell you rather than what runs.
The annotation that reaches an ordinary caller is on ```IBaseDao<T>```, whose ```Get``` every earlier version of
that package left null-oblivious:
```c#
// 2.5.0 and 3.1.0 — null-oblivious
T Get(T item);
// 3.2.0
T? Get(T item);
```
Nothing about the behavior is new — a ```Get``` for a row that is not there has always come back ```null``` — but
a call through your own ```ICompanyDao : IBaseDao<Company>``` now binds ```Company?``` instead of an oblivious
```Company```, so a project with nullable enabled may see a ```CS8602``` where it previously saw nothing. That is
a new warning and not a break: no signature changed, nothing you have compiled needs rebuilding, and a project
without nullable enabled sees none of it. If you implement ```IBaseDao<T>``` yourself rather than deriving from a
base in this library, read that package's own 3.2.0 entry as well — ```GetAll```, ```GetPaged``` and
```GetCount``` gained an annotated parameter, and an implementation declaring a plain ```T``` there is now
stricter than the interface allows and will report ```CS8767```.
Taking 3.2.0 is also what let the last of this library's warning suppressions go. Ten
```#pragma warning disable CS8766``` pairs across eight files were holding down a diagnostic raised because the
bases here declared the nullable ```Get``` return their documentation described while the interface they
implemented could not say the same thing. Both sides agree now, so the suppressions are deleted rather than
replaced and no ```#pragma``` of any kind is left in the library — the compiler verifies what it was previously
being asked to ignore.
### BaseNonIdDao and BaseSoftNonIdDao keep their names but are different types
This is the change most likely to be missed, because these two names do not disappear. They still resolve, so
the upgrade reads like a small refactor rather than a replacement. In 2.2.0 these were:
```c#
BaseNonIdDao<T> : IBaseDao<T> where T: class, IBaseEntity
BaseSoftNonIdDao<T> : BaseNonIdDao<T>, IBaseDao<T> where T : class, IBaseSoftEntity
```
They are now built on a separate plumbing class, and the members underneath you are not the members that were
there before:
```c#
BaseNonIdDao<TEntity> : RootNonIdDao<TEntity>, IBaseDao<TEntity>
BaseSoftNonIdDao<TEntity> : RootSoftNonIdDao<TEntity>, IBaseDao<TEntity>
```
```Get``` and ```Update``` were abstract and are now virtual with working defaults, so an existing override
keeps compiling while the code it overrides has changed underneath it. ```Insert``` and ```Delete``` were
concrete and non-virtual and are now inherited virtual members with new implementations. ```Context``` and
```Dataset``` were public and are now protected. The public ```EnsureBeginTransaction```,
```EnsureTransactionCommit``` and ```EnsureTransactionRollback``` members are no longer on this surface. In
their place ```RootNonIdDao``` declares one new abstract member, ```MatchRow```, which returns the predicate
identifying a single row — so your derived class will at least fail to compile until you supply it, which is the
one piece of this the compiler will catch for you.
The migration is to rewrite the class onto the new keyless family described below, supplying ```MatchRow``` and
dropping any overrides that no longer correspond to anything. No wrapper preserving the old shape ships in this
release, so there is nothing to fall back to and no reason to delay the rewrite.
### BaseEFDataAccess takes one type parameter and is constructed differently
The published line documented ```BaseEFDataAccess<TContextType, TIdType>```. That type no longer exists. The
identifier type parameter was never used for anything the class did, and it is gone:
```c#
BaseEFDataAccess<TContext> : BaseDataAccess.BaseDataAccess, IBaseDataAccess where TContext : DbContext
```
The class is now abstract, and its context constraint has been relaxed from ```BaseEFContext``` to
```DbContext```, so any context you already have will do. The protected ```Context``` property is typed
```TContext``` rather than ```DbContext```, which means your derived layer sees its own context type without a
cast.
Both public constructors are gone. ```BaseEFDataAccess(string connectionString)``` and
```BaseEFDataAccess(DbContextOptions options)``` are replaced by a single protected constructor that takes a
built context and a statement of who owns it:
```c#
protected BaseEFDataAccess(TContext context, ContextOwnership ownership)
```
A derived layer that called ```: base(connectionString)``` must now build the context itself — or accept one
from its caller — and say whether it is responsible for disposing it.
### BaseEFContext no longer takes a connection string, and no longer picks a provider
```BaseEFContext(string connectionString)``` has been removed; the ```DbContextOptions``` constructor is the
only one left. On the EF Core side that removed constructor called ```UseSqlServer``` for you, which meant the
library chose your database provider. It no longer does — whoever builds the options names the provider. This
also removes the v2.0.0 behavior where omitting a connection string produced an in-memory context; build the
options with the provider you want, in-memory included — though as the next section describes, the in-memory
provider is no longer one this package hands you. Deriving from ```BaseEFContext``` is now optional, since
```BaseEFDataAccess<TContext>``` accepts any ```DbContext```.
### The package no longer brings a database provider with it
Installing 2.2.0 also installed ```Microsoft.EntityFrameworkCore.SqlServer``` and
```Microsoft.EntityFrameworkCore.InMemory```, because both were ```PackageReference```s of the library itself.
A consumer on PostgreSQL, MySQL or SQLite restored two providers they were never going to call, and carried them
into every build and every deployment. Both are gone from this package.
```Microsoft.EntityFrameworkCore``` 10.0.11 stays, because that is the dependency the library genuinely has.
Measured across the change, the dependency closure of a project taking this package goes from
```EntityFrameworkCore```, ```EntityFrameworkCore.SqlServer``` and ```EntityFrameworkCore.InMemory``` at the top
level — with ```EntityFrameworkCore.Relational``` and ```Microsoft.SqlServer.Server``` behind them — to
```EntityFrameworkCore``` alone, behind which sit only ```EntityFrameworkCore.Abstractions``` and
```EntityFrameworkCore.Analyzers```. No provider of any kind, and no relational assembly, remains anywhere in
the closure.
**This is a breaking change if you were calling a provider you never referenced yourself.** A line such as
```options.UseSqlServer(connectionString)``` compiled under 2.2.0 without
```Microsoft.EntityFrameworkCore.SqlServer``` ever appearing in your project file, because this package supplied
it for you. That line will now fail to compile. The remedy is one line in your own project file:
```xml
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.11" />
```
Name whichever provider you actually use — ```Npgsql.EntityFrameworkCore.PostgreSQL```,
```Pomelo.EntityFrameworkCore.MySql```, ```Microsoft.EntityFrameworkCore.Sqlite```, or
```Microsoft.EntityFrameworkCore.InMemory``` if what you wanted it for was tests. Nothing else about the call
changes: no signature moved, no type was removed, and the code you already wrote keeps working the moment the
package it depends on is declared where it belongs.
This is the packaging catching up to the code rather than a change of behavior, and it is worth being precise
about which. The library has named no provider in code since the ```BaseEFContext``` change described above —
there is no ```UseSqlServer``` or ```UseInMemoryDatabase``` call anywhere in it, and ```BaseEFContext``` exposes
a single ```protected BaseEFContext(DbContextOptions)``` constructor that names nothing. What lagged behind was
the package manifest, which went on shipping two providers the code had already stopped using.
It lands in 3.0.0 because it cannot land anywhere later. Removing a dependency changes what restores for
everyone who has already taken the package, so doing this after 3.0.0 shipped would have cost a 4.0.0 on its own.
Provider neutrality is the shape this library was meant to have, and this release is the last opportunity to
reach it without spending another major version to get there.
### The Guid, Int and Long namespaces have been removed
```ProphetsWay.EFTools.Guid```, ```ProphetsWay.EFTools.Int``` and ```ProphetsWay.EFTools.Long``` are gone, and
with them the 18 key-specific base classes they held — six in each namespace, being ```BaseDao```,
```BaseGetAllDao```, ```BasePagedDao``` and their three soft-delete counterparts. This is likely to be the first
error you see, and it is a blunter one than the rest of this release: ```using ProphetsWay.EFTools.Int;``` no
longer resolves, so the file stops compiling at the top rather than at the class.
Every one of the eighteen has a direct replacement in the open-key families described below, formed by moving the
key type out of the namespace and into a second type argument:
```c#
// 2.2.x
using ProphetsWay.EFTools.Int;
public class UserDao : BasePagedDao<User>, IUserDao { /* … */ }
// 3.0.0
using ProphetsWay.EFTools;
public class UserDao : BasePagedDao<User, int>, IUserDao { /* … */ }
```
```Int.BaseDao<T>``` becomes ```BaseDao<T, int>```, ```Guid.BaseSoftPagedDao<T>``` becomes
```BaseSoftPagedDao<T, Guid>```, ```Long.BaseGetAllDao<T>``` becomes ```BaseGetAllDao<T, long>```, and so on
through all eighteen. Constructor signatures are unchanged, so for most Data Access Objects the edit is the
```using``` line and one type argument. The library now lives entirely in the single ```ProphetsWay.EFTools```
namespace.
One collision disappears with them. ```ProphetsWay.EFTools.Guid``` shadowed ```System.Guid``` in any file that
imported it, which is why a ```Guid``` key sometimes had to be written out as ```System.Guid``` to compile at
all. That no longer happens.
The two bridge classes underneath the closures, ```RootBaseDao<T, TIdType>``` and
```RootBaseSoftDao<T, TIdType>```, have been removed alongside them. Both were marked
```[EditorBrowsable(Never)]``` and were not meant to be named directly; if you did name one, there is no 3.0.0
equivalent, and the replacement is to derive from whichever open-key family matches the capabilities your Data
Access Object interface publishes.
### New: keyed Data Access Object families with an open key type
The key-specific bases required a value-type key, through a ```where TIdType : struct``` constraint on the
bridge they closed over. The new generic families drop it:
```c#
BaseDao<TEntity, TKey> : IBaseDao<TEntity> where TEntity : class, IBaseIdEntity<TKey>
BaseGetAllDao<TEntity, TKey> : BaseDao<TEntity, TKey>, IBaseGetAllDao<TEntity>
BasePagedDao<TEntity, TKey> : BaseDao<TEntity, TKey>, IBasePagedDao<TEntity>
```
```TKey``` carries no constraint at all, so a ```string``` key or a ```Nullable<int>``` key is now legal where
before it was not expressible. ```BaseSoftDao<TEntity, TKey>```, ```BaseSoftGetAllDao<TEntity, TKey>``` and
```BaseSoftPagedDao<TEntity, TKey>``` are the soft-delete counterparts and manage the created, updated and
deleted timestamps as the 2.x soft classes did.
### New: keyless Data Access Object families
An entity with no identifier — a join table, or one whose only key is a composite natural key — previously had
to be presented through ```IBaseDao<T>``` whether its contract promised that or not. Four types replace that
arrangement, and they split the plumbing from what you publish:
```c#
RootNonIdDao<TEntity> where TEntity : class, IBaseEntity
BaseNonIdDao<TEntity> : RootNonIdDao<TEntity>, IBaseDao<TEntity>
RootSoftNonIdDao<TEntity> : RootNonIdDao<TEntity> where TEntity : class, IBaseSoftEntity
BaseSoftNonIdDao<TEntity> : RootSoftNonIdDao<TEntity>, IBaseDao<TEntity>
```
The ```Root``` classes declare no capability interface, so a Data Access Object deriving from one publishes only
the members it chooses. The ```Base``` classes add ```IBaseDao<TEntity>``` and no members, for the case where
you do want the full interface. Reads are filtered, ordered and paged through overridable seams —
```ApplyReadFilter```, ```ApplyStableOrder```, ```ApplyIncludes``` — and the row a write targets is identified by
your ```MatchRow``` predicate, which is translated to SQL rather than evaluated in memory.
### New: ContextOwnership, and a real disposal contract
```ContextOwnership``` is a two-value enum, ```Borrowed``` and ```Owned```, with **no default value you can fall
into** — the constructor makes you say which one applies. A layer told ```Owned``` disposes the context along
with itself; one told ```Borrowed``` never touches it, because someone else will.
```c#
public enum ContextOwnership { Borrowed = 0, Owned = 1 }
```
```BaseEFDataAccess``` implements the disposal contract that ```ProphetsWay.BaseDataAccess``` 3.x specifies.
```Dispose``` is sealed, idempotent and does not throw; it rolls back a transaction still open at the time; and
it disposes the context only when it owns it. Every other member throws ```ObjectDisposedException``` once the
layer has been disposed. Override ```DisposeCore``` to release anything your own layer created.
### Fixed: a Data Access Layer no longer leaks its context and its connection
In 2.2.0 both of ```BaseEFDataAccess```' constructors built the context themselves —
```Context = (DbContext)Activator.CreateInstance(typeof(TContextType), new object[] { connectionString });```
— and **nothing ever disposed it.** There was nowhere to: ```IBaseDataAccess``` did not extend
```IDisposable``` in that release, the class declared no ```Dispose```, no ```IDisposable``` and no finalizer,
and so a conscientious caller writing ```using``` around a Data Access Layer instance could not compile it.
Every instance you constructed left a ```DbContext```, and the database connection behind it, to be collected
whenever the garbage collector got to it.
This is the one of the four that eventually announces itself. A long-running or high-throughput application
reaches connection-pool exhaustion and starts timing out on acquisition, and the cause is findable once you
suspect it — but a low-traffic application can run for a very long time without arriving there, which is why
it shipped.
In 3.0.0 the layer is disposable and the disposal is specified rather than incidental: ```Dispose``` is
```sealed```, idempotent and does not throw, it rolls back a transaction still open at the time, and it
disposes the context **only when it owns it** — which is now a thing the constructor makes you state, through
the ```ContextOwnership``` argument described above. A layer handed a container-managed context leaves it
alone; a layer that was given one to own releases it.
### Fixed: commit and rollback no longer silently do nothing
This is the most dangerous of the four, because there is no observable difference between it working and it
not. In 2.2.0 the keyless Data Access Object base began a transaction only when the context did not already
carry one:
```c#
// 2.2.0
if (Context.Database.CurrentTransaction == null)
_transaction = Context.Database.BeginTransaction();
```
When a transaction **was** already open — an outer unit of work, or another Data Access Object on the same
shared context that got there first — ```_transaction``` stayed ```null```. The matching commit and rollback
then both reached it through ```?.```, so **both returned normally having done nothing.** No exception, no
return value indicating a skip, nothing in a log. A Data Access Object that believed it had committed its
work had not committed anything, and a rollback written to reverse a failed write did not run — the write was
left to whatever the outer transaction decided later.
In 3.0.0 transactions belong to the Data Access Layer and not to a Data Access Object. ```TransactionStart```,
```TransactionCommit``` and ```TransactionRollBack``` live on ```BaseEFDataAccess<TContext>```, no Data Access
Object base in the library carries a transaction member of any kind, and the library never consults
```CurrentTransaction``` to decide whether to begin one — so the check-and-skip that caused this cannot be
expressed. Every misuse now throws instead of returning quietly: a second ```TransactionStart``` throws
```InvalidOperationException``` because transactions do not nest, and a commit or a rollback with nothing open
throws for the same reason. If you hand the layer a ```Borrowed``` context whose owner has already begun a
transaction directly, Entity Framework Core's own exception propagates unwrapped and that outer transaction is
left in force — this layer will not silently enroll itself in someone else's transaction.
### Fixed: Update no longer un-deletes a soft-deleted row
In 2.2.0 the soft-delete base implemented ```Update``` as whole-object replacement —
```entry.CurrentValues.SetValues(item)``` over every mapped column, ```DeletedDate``` included. An
```Update``` carrying an instance that was fetched before the delete, or built fresh from a form or a message,
wrote ```null``` over the stored ```DeletedDate``` and **the row came back to life**, reappearing in every
```GetAll```, every ```GetPaged``` and every count. Nothing threw and no row count looked wrong.
Two more halves of the same defect sat beside it. ```Delete``` stamped ```DeletedDate``` unconditionally
without asking whether the row was live, so a **second** delete of an already-deleted row overwrote the
original timestamp with a later one and returned ```1``` rather than ```0``` — meaning any retention window,
audit trail or "deleted before" query reading that column was answering from a timestamp that had quietly
moved. And ```Insert``` stamped ```CreatedDate``` without clearing the other two, so a caller reusing an
instance carried stale timestamps into a brand-new row — including, in the worst case, a ```DeletedDate```,
storing a row that was invisible to every read the moment it was written.
In 3.0.0 all three are corrected together. ```Update``` preserves the stored ```CreatedDate``` and
```DeletedDate``` and ignores whatever the incoming instance carries for them, so an update can neither
rewrite history nor soft-delete a row behind ```Delete```'s back; updating a deleted row is allowed and leaves
it deleted. ```Delete``` checks liveness first — an already-deleted or absent row returns ```0```, changes
nothing, and **never refreshes an existing ```DeletedDate```**, so the stamp always reports when the row was
actually deleted and a second call is idempotent rather than an error. ```Insert``` forces
```UpdatedDate``` and ```DeletedDate``` to ```null``` whatever the caller assigned. All of them are declared
```override``` rather than ```new```, so a soft Data Access Object reached through a
```BaseDao<TEntity, TKey>```-typed reference still soft-deletes instead of quietly hard-deleting.
### Fixed: a read or a write no longer hands you the store's own object
In 2.2.0 ```Insert``` was ```Dataset.Add(item); Context.SaveChanges();```. ```SaveChanges``` moves the entity
from ```Added``` to ```Unchanged``` and **leaves it in the change tracker**, so the instance you passed in
became the layer's instance. ```Get``` was ```Dataset.Where(i => i.Id == item.Id).SingleOrDefault()``` with
no ```AsNoTracking``` and no projection, so it handed back the store's own tracked object rather than a copy
of it. Entity Framework Core tracks by default and nothing in the library changed that, so unless you had
configured ```QueryTrackingBehavior.NoTracking``` on the context yourself, **an edit you made to a retrieved
entity — or to something hanging off its navigation properties — and never submitted was written to the
database on the next ```SaveChanges``` anywhere on that layer.** It is silent, and unlike the leaked
connection the damage lands in your data rather than in your process.
In 3.0.0 ```Get```, ```GetAll``` and ```GetPaged``` read ```AsNoTracking``` and return fresh untracked
instances — never the argument you passed and never the store's object. ```Insert``` gives the store a copy,
attaches everything reachable through your navigation properties as ```Unchanged``` so related rows are read
and never written, and writes back only the identifier the row ended up carrying, once the save has returned.
Every write detaches what it tracked before it returns.
**What stops happening, which is the part to read even if none of the above sounds familiar.** This is the
only one of the four whose fix takes something away, and a 2.2.0 application can be depending on it without
anyone having decided to. A stray edit made through an entity you retrieved **no longer reaches the
database** — if some part of your code has been relying on a change persisting without an explicit
```Update```, that write is now silently dropped where it used to be silently applied, and it is the one item
in this release that can change what your stored data looks like without changing a line of your own code.
And ```Update``` against a row that does not exist now returns ```0```, where 2.2.0 threw
```InvalidOperationException``` out of ```Single``` — so a ```catch``` you wrote around that call **stops
firing**, and a return value you may never have checked becomes the only signal that nothing was written.
Check it.
### Fixed: a failed Insert no longer leaves timestamps or an identifier on your instance
A soft entity's ```Insert``` stamped the created and updated timestamps onto the caller's instance before
attempting the write, so a ```SaveChanges``` that threw left you holding an instance that claimed to describe a
stored row when nothing had been stored. The subtler half of the same defect survived a successful save: the
keyed insert assigns the identifier, clears inverse navigations and detaches, all after the row is committed, and
anything throwing in those steps left a real database identifier sitting beside a creation date naming no row.
Writes now stamp a copy and write back to your instance only once the save has returned, so the state you are
holding and the state of the database agree in both cases.
### Fixed: TargetException on a committed write
Clearing inverse navigations read a collection navigation as though it were a single principal and reflected onto
the collection object itself, raising ```TargetException``` one statement after the row had already been
committed. The call appeared to fail while the write had in fact succeeded. Collection and reference
navigations are now handled distinctly.
### The whole surface, and a note on prerelease builds
Nothing transitional ships. The library is twelve public classes and one enum: ```BaseEFContext``` and
```BaseEFDataAccess<TContext>```; the keyed ```BaseDao```, ```BaseGetAllDao``` and ```BasePagedDao```; their
soft-delete counterparts ```BaseSoftDao```, ```BaseSoftGetAllDao``` and ```BaseSoftPagedDao```; the keyless
```RootNonIdDao```, ```BaseNonIdDao```, ```RootSoftNonIdDao``` and ```BaseSoftNonIdDao```; and
```ContextOwnership```. All of them are in the ```ProphetsWay.EFTools``` namespace, and there is no longer any
conditionally compiled code in the library at all.
If you tried an alpha or beta package cut while this line was still in development, you may have seen three
```Legacy```-prefixed keyless classes, or the key-typed namespaces described above, sitting alongside the new
families. Those were scaffolding internal to the development branch — the older classes were renamed for a
handful of commits so the new ones could take their names — and **none of them is part of 3.0.0**. There is no
```LegacyBaseNonIdDao<T>``` to migrate onto. Plan on the generic families.