Skip to content

v6.6.0

Choose a tag to compare

@github-actions github-actions released this 26 Jul 14:07
· 7 commits to master since this release

Migrations you no longer write by hand — and a database that answers for itself

Three pieces land together, because each is only useful with the others.

eQuantic.Core.Data.Abstractions — new package

The contract surface now ships on its own: the repository, unit-of-work and set interfaces, the query and update models, the modeling attributes, the migration operations and the model snapshot.

A domain or application layer references it and compiles against no engine and no store — only the composition root takes the provider packages.

dotnet add package eQuantic.Core.Data.Abstractions

Nothing moved namespace, and nothing moved type identity: 105 type forwards keep anything already compiled against 6.5.0 working. You get the contracts either way — eQuantic.Core.Data depends on them.

eQuantic.Core.Data.Tools — new package

dotnet tool install --global eQuantic.Core.Data.Tools
eqdata migrations add AddCustomerTier --project src/Shop.Api

Compares the model against a snapshot committed beside it and writes the migration that carries one to the other, plus the regenerated snapshot. Both files, or neither — a snapshot that advanced past a change nobody generated is worse than no snapshot, because the next comparison starts from a state the database was never brought to.

The tool reads the model from your application itself, through one class:

public sealed class DesignTimeServices : IDesignTimeServices
{
    public IServiceProvider Create(string[] args) { /* the configuration the app uses */ }
}

Where it stops and asks. Generation is a starting point, not an authority. Where the tooling knows what moved but only a person knows what it means, the generated file emits #error and the solution does not build until it is answered. A comment would let the change run, appear to succeed, and quietly leave the data wrong.

migration.For<global::Shop.OrderData>(entity => entity
    .AddField(x => x.Tier)
    .Update(_ => true, set => set.Set(x => x.Tier, default!)));
#error 'Shop.OrderData.Tier' is added without saying what the records that already exist hold. …

Three things trigger it: a member added with no declared value (every existing record would take default(T)), a rename nobody declared (generating drop-and-add loses the values), and a change no store operation performs.

Renames keep the data — if you say so. A rename and a drop-and-add look identical in a diff, and only one keeps the values. Say where a member came from and the pair becomes RenameField("customer", "buyer"):

[PreviousName("customer")] public string Buyer { get; set; } = "";
[DefaultValue("web")]      public string Channel { get; set; } = "";

Both also have fluent forms: .PreviousName(x => x.Buyer, "customer") and .Default(x => x.Channel, "web").

Refusals are not rendered at all. Cassandra cannot relocate rows under a moved partition or clustering key — there is no ALTER that does it — and no store will redefine a key silently. Nothing is written when a refusal appears, because generating the rest would advance the snapshot past a change that never ran. Each refusal names what to do instead.

eqdata drift

eqdata drift --project src/Shop.Api || exit 1
The postgresql database and the model disagree:

  orders  (Shop.OrderData)
    reference is varchar(50), and the model expects varchar(200)

This is the question a migration history cannot answer. History records which changes ran. It says nothing about a column altered by hand on staging, a migration that stopped halfway, or an environment restored from a backup older than the last release. Only looking answers those.

Exits non-zero when the difference is one the application would fail on, so it works as a deployment gate. A column the model does not map is reported but does not fail the check — databases get shared. Tables you do not map are not read at all. It also reports, separately, when the model has moved beyond the committed snapshot: that is not drift, the database is behind the code on purpose until a migration runs.

Why a clean database is silent. Each dialect reads its own catalogue and returns the type in the spelling it writes itself — PostgreSQL through format_type(), MySQL through column_type (which keeps tinyint(1) and datetime(6)), SQL Server composed in sys.columns (where max_length counts bytes, so an nvarchar(450) reports 900). A check that cries wolf on a healthy schema is a green light nobody reads.

Only the relational providers read their own catalogue. The document stores have no schema to introspect, and drift says it cannot answer rather than answering wrongly.

Two defects fixed on the way

RenameField resolved its source name from the current model. In a generated migration the model has already moved, so the rename resolved to the new name and left the old column untouched. There is now an overload stating both sides — RenameField("customer", "buyer") — honoured by all four executors.

The expected side of a drift check derives nullability from what CREATE TABLE actually writes, not from the CLR type. The engine emits no NOT NULL for ordinary columns, so expecting otherwise reported a finding for every correct table. A column that does carry one is now reported as tightened by hand — which is the constraint that starts rejecting writes your code allows.


Docs: Generating and drift checking