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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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