Skip to content

Using it with EF Core

CommonKit has no dependency on EF Core — its only package is TimeZoneConverter, for time-zone ids. But it was designed with EF Core in mind, and four small patterns make them work together nicely. The EF plumbing below lives in your app; the library only supplies the value logic.

The problem

You want your entity properties non-nullable — no ?, no null checks in your code. But the database wants honest NULLs for missing data, not "" or 0 or 1900-01-01. Those two wishes collide on every save and every query.

Pattern 1 — clean values in the entity itself

Let the property getter pass every read through the cleaning function. Then it does not matter what the field holds — a raw form value, a legacy row, a NULL from the database — code that uses the entity always sees a clean value:

public class Member
{
    private string _email = "";

    public string Email
    {
        get => CkEmail.Address(_email);
        set => _email = value;
    }
}

This works because EF Core reads and writes the backing field _email directly, not the property — PreferField is its default whenever the field follows the naming pattern EF looks for, as _email does. So a legacy value that fails today's rules still loads and saves untouched, but the moment your code reads it, it comes through the function. To spell the choice out, add this to your DbContext:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.UsePropertyAccessMode(PropertyAccessMode.PreferField);
}

Pattern 2 — one save hook turns placeholders into NULLs

Instead of remembering CkText.NullIfEmpty on every nullable property, override SaveChanges once. CkSentinel.ToNull looks at any value and gives one verdict: placeholder in, null out; real value in, the same value out, untouched.

public override int SaveChanges()
{
    foreach (var entry in ChangeTracker.Entries())
    {
        if (entry.State is not (EntityState.Added or EntityState.Modified))
        {
            continue;
        }

        foreach (var property in entry.Properties)
        {
            if (property.Metadata.IsNullable == false)
            {
                continue;   // non-nullable columns keep their values, keys included
            }

            property.CurrentValue = CkSentinel.ToNull(property.CurrentValue);
        }
    }

    return base.SaveChanges();
}

What ToNull turns into null: empty and whitespace text, zero numbers and amounts, NaN and infinities, placeholder dates (1900-01-01 and friends, year one included), and the all-zeros UUID. Booleans and times pass through — false and midnight are real values. Zeros can be kept with treatZerosAsMissing: false. Details on the CkSentinel page.

A nullable bool? column is the one case the save hook cannot settle, because no bool value means "unknown". Decide it where the value is read: CkBool.FromValueOrNull answers null for a missing or unreadable value and false only for a "no" somebody said.

(Async apps: override SaveChangesAsync the same way, or put the loop in a shared private method both overrides call.)

Pattern 3 — remember the query trap

Once the hook is in place, new saves write NULL where your code says "". But you never know what the whole data set holds: rows saved before the hook existed, imports, other apps writing to the same tables. So when you query for "missing", always check both forms:

// always check both — the data set can hold "" and NULL side by side
var missing = context.Members.Where(m => m.Referrer == null || m.Referrer == "");

Checking only one form silently misses the rows in the other, and nothing errors to warn you.

And on the way back in, a NULL column arrives as C# null — the cleaning getter from Pattern 1 smooths that over by itself: CkEmail.Address(null) answers "", so your code never meets the null at all.

The other query trap is distance. "Everyone within 50 km" cannot be computed in SQL from stored latitude and longitude columns, so the query has to narrow the rows with a box the index can use and measure the survivors in code — CkGeo.BoundsAround then CkGeo.IsWithin, written out under BoundsAround.

Pattern 4 — always give text columns a length

Left unconfigured, EF Core maps every string property to nvarchar(max) on SQL Server. That happens silently, one forgotten property at a time — and nvarchar(max) columns cannot be indexed the normal way and are slower to read and compare, so the queries crawl later, when the table has grown. Always declare a length:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Member>(member =>
    {
        member.Property(m => m.Email).HasMaxLength(320);
        member.Property(m => m.Name).HasMaxLength(200);
        member.Property(m => m.Country).HasMaxLength(2);
    });
}

(Or put [MaxLength(320)] on the property itself — same effect, your choice of style.)

Pick lengths from what the value can really be: an email address never passes 320 characters, a country code from CkCountry is exactly 2, a cleaned E.164 phone is at most 16. The cleaning getters from Pattern 1 guarantee the values fit, and the length keeps the database honest and fast.

Why not a value converter?

EF Core's value converters look like the natural home for this — but they cannot convert to or from null; EF handles nulls outside the converter, by design. That is exactly the case we care about, which is why the save-hook pattern above is the right tool.