Complex properties and owned references
A policy's insured address is part of the policy. When the address changes, that is a new version of the policy.
A temporal entity can have complex properties and owned references stored in its own table, the default mapping for both. Their columns are versioned exactly like the entity's own columns.
public sealed class HomePolicy
{
public int Id { get; set; }
public required string Number { get; set; }
public InsuredAddress Address { get; set; } = new(); // complex property
public BankAccount? PaymentAccount { get; set; } // optional complex property
public PolicyHolder? Holder { get; set; } // owned reference
}
public sealed class InsuredAddress
{
public string Street { get; set; } = "";
public string City { get; set; } = "";
public string PostalCode { get; set; } = "";
}
public sealed class BankAccount
{
public string Iban { get; set; } = "";
}
public sealed class PolicyHolder
{
public string Name { get; set; } = "";
public string? Email { get; set; }
}modelBuilder.Entity<HomePolicy>(policy =>
{
policy.ToTable("home_policies");
policy.ComplexProperty(p => p.Address); // columns Address_Street, Address_City, …
policy.ComplexProperty(p => p.PaymentAccount); // column PaymentAccount_Iban
policy.OwnsOne(p => p.Holder); // columns Holder_Name, Holder_Email
policy.IsTemporal(); // every one of those columns is versioned
});What gets versioned
- Every column of the member goes into history, under the same name as in the main table, nested members of nested members included. The history table has
Address_Street,Address_City,PaymentAccount_Iban,Holder_Nameand so on, nullable like every history column. - Changing only a member writes a version of the entity:
policy.Address.City = "Paris"orpolicy.Holder.Email = …followed bySaveChangescloses the current version and opens a new one, in both writers. So does replacing the owned object, or setting an optional member tonullor back. - One version per save. Changing the entity and its members in the same
SaveChangeswrites one version, not one per member. - Replacing a complex property with an equal value is not a change. EF Core compares complex properties by value, so no version is written.
Reading it back
AsOf, AllVersions, FromTo and ContainedIn and History<T> rebuild the members from the history columns. Filters and projections on them translate to SQL as usual:
var insuredInMinskAtRenewal = await db.HomePolicies
.AsOf(renewalDate)
.Where(p => p.Address.City == "Minsk")
.Select(p => new { p.Number, Holder = p.Holder!.Name, p.PaymentAccount })
.ToListAsync();The query reads the members' columns straight from the history table:
-- @AsOfUtc='1970-01-01T00:00:00.0000000Z' (DbType = DateTime)
SELECT p.id, p.number, p.address_city, p.address_street, p.address_geo_lat, p.billing_iban IS NULL, p.billing_day, p.billing_iban, p.holder_name IS NULL, p.holder_name, p.holder_contact_phone IS NULL, p.holder_contact_phone, p.marketing_channel IS NULL, p.marketing_score IS NULL, p.marketing_channel, p.marketing_score
FROM policies_history AS p
WHERE p.valid_from <= @AsOfUtc AND p.valid_to > @AsOfUtcAn optional member (a nullable complex property, or an owned reference) comes back as null by the rule EF Core itself uses when it reads the main table:
- if the member has a required property of its own (such as
PolicyHolder.Nameabove), it isnullwhen that property's column isNULL; - otherwise it is
nullwhen all of its columns areNULL.
A filter on an optional member with no required property
For the second kind, EF Core cannot translate a Where or OrderBy on the member's properties into SQL, and the query throws InvalidOperationException. Reading the member works. Give the member a required property, or filter after the query runs.
Diff
Diff reports a member's properties one by one. PropertyChange.Path names where each one sits:
var versions = await db.History<HomePolicy>()
.Where(v => v.Entity.Id == policyId && v.Operation != VersionOperation.Delete)
.OrderBy(v => v.ValidFrom)
.ToListAsync();
foreach (var change in db.Diff(versions[^2], versions[^1]))
{
// Path names the member: "Address.City", "Holder.Email", "Number".
Console.WriteLine($"{change.Path}: {change.OldValue} → {change.NewValue}");
}Adding, removing and renaming members
A member's columns follow the rules for any other column; see Evolving a temporal entity. Adding a member, or a property to one, adds history columns. Removing one keeps its history columns as nullable orphans with their old values. Renaming a complex property or an owned reference renames its columns, so history gets new columns and keeps the old ones.
Versions written before a member existed have NULL in its columns, like versions written before any other added column. An optional member reads back as null for them.
What is not supported
These are rejected when the model is built, with a NotSupportedException that names the member:
| member | why |
|---|---|
Owned collection (OwnsMany) | Its rows live in another table, not in the entity's columns. |
Owned reference mapped to a table of its own (OwnsOne(...).ToTable(...)) | Same: another table. |
Complex property or owned reference mapped to JSON (ToJson()), complex collection | History would hold the JSON document, but rebuilding the member from it would not follow EF Core's JSON mapping, so values could come back wrong. |
Map such a member as a table-split ComplexProperty or OwnsOne, or flatten it into properties of the entity. Exclude(...) takes only the entity's own properties: a member is versioned as a whole.