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.