Skip to content

EF Core: opt-in check constraints mirroring strong-type invariants #130

Description

@KaliCZ

Feedback from an r/dotnet thread (link): a primitive-obsession library that supports EF Core is incomplete if it stops at the C# boundary. The database is reachable by things that aren't the application — migrations, scripts, a person with a SQL client — and any invariant the C# type enforces should also be enforced by the column it lives in. Otherwise NonEmptyString guarantees nothing on read; it just throws when someone else has already written a blank.

UseStrongTypes() today attaches a ValueConverter per strong-type property and maps intervals to endpoint columns. It emits no CHECK constraints, so the invariants are app-side only.

Opt-in, not automatic

This must be opt-in. Turning it on by default would mean any existing solution that upgrades gets a migration full of new constraints, and that migration fails against production data that predates the strong types. That's a hurdle at exactly the moment someone is trying to adopt the library. Proposed surface:

services.AddDbContext<AppDbContext>(options => options
    .UseSqlServer(connectionString)
    .UseStrongTypes(strongTypes => strongTypes.UseCheckConstraints()));

Off unless asked for. Existing UseStrongTypes() calls keep their current behaviour and produce no model diff.

Constraints to emit

One per mapped property, covering everything the convention already recognises:

Type Constraint
NonEmptyString column is not blank (mirrors IsNullOrWhiteSpace)
Email not blank, length <= 254
MailAddress not blank
Positive<T> > 0
NonNegative<T> >= 0
Negative<T> < 0
NonPositive<T> <= 0
interval (two-column) Start <= End

Nullable properties need no special casing — a CHECK whose expression evaluates to NULL is satisfied, so a nullable column and a TPH sibling column both behave correctly without an added IS NULL arm.

Naming: CK_<Table>_<Column>_<Rule>, e.g. CK_Users_LoginCount_Positive.

Implementation sketch

An IModelFinalizingConvention registered by the same plugin, walking properties whose CLR type resolves to a strong type and calling AddCheckConstraint. The SQL is provider-specific, so it branches on IDatabaseProvider the way IntervalJsonColumnTypeConvention already does. SQL Server and PostgreSQL first, since the API integration tests run both.

Open questions

  • Blank-check parity. IsNullOrWhiteSpace covers Unicode whitespace; TRIM/btrim don't, and differ per provider. Exact parity isn't reachable in SQL, so the constraint is an approximation and needs documenting as one.
  • JSON-mapped intervals. HasIntervalJsonConversion stores one column; a JSON-path check is provider-specific and awkward. Suggest leaving those uncovered in the first cut.
  • Per-property opt-out for a column that can't take a constraint yet (legacy data being cleaned). Worth adding, or does the global switch suffice?

Also needs updating

  • API integration tests asserting a raw SQL insert of a violating value is rejected, on both SQL Server and PostgreSQL.
  • Skill/references/efcore.md — the opt-in call and the constraint table.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions