Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion pages/mdsource/temporal-helper.source.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

`SetCurrentPeriodStart` re-stamps a row's `PeriodStart` (and aligns the most recent history row's `PeriodEnd`) on a [SQL Server temporal table](https://learn.microsoft.com/sql/relational-databases/tables/temporal-tables). It exists to give back-to-back `SaveChanges` calls in tests distinct, deterministic temporal timestamps without relying on `Task.Delay` between them.

The method is exposed on both `SqlInstance<TDbContext>` and `SqlDatabase<TDbContext>`. Schema lookups (table, history table, period columns, PK column) are performed once at `SqlInstance` construction and cached, so per-call overhead is only the SQL execution.
`SetHistoryColumn` is a second helper on the same schema lookup. It writes one column on the history rows of a single entity, to put a history table into a state a freshly migrated database never reaches. See [Simulating a damaged history row](#simulating-a-damaged-history-row).

Both are exposed on `SqlInstance<TDbContext>` and `SqlDatabase<TDbContext>`. Schema lookups (table, history table, period columns, PK column) are performed once at `SqlInstance` construction and cached, so per-call overhead is only the SQL execution.


## Why
Expand Down Expand Up @@ -45,6 +47,23 @@ For each call the helper runs, in separate batches:
Steps 5 and 6 run in a `finally` so a failed UPDATE doesn't leave the table without versioning.


## Simulating a damaged history row

A history table can hold values the current model says are impossible. The usual cause is a migration that drops and re-adds a column on a temporal pair — done to keep column ordinals matching between the two tables, which SQL Server requires. The current table repopulates (or recomputes, if the column is computed); the rows already in the history table are left NULL, and SQL Server does not backfill them.

Nothing in a test suite reproduces that on its own, because every test database is built by migrating from empty. So the read path that trips over those NULLs — typically materialising an entity whose property is non-nullable, which throws `SqlNullValueException` — is exercised for the first time in production.

`SetHistoryColumn` reproduces it:

snippet: SetHistoryColumnUsage

The period columns and the primary key are rejected: rewriting a period on a history row corrupts the timeline `SetCurrentPeriodStart` maintains, and does so silently, while rewriting the key detaches the row from the entity it is history for. Any other mapped column can be set, computed columns included — those are plain columns on the history table.

Versioning is turned off for the write and back on in a `finally`. Unlike `SetCurrentPeriodStart` the `PERIOD` is not dropped, since the period columns are ordinary columns on the history table.

For a null value the column has to permit NULL **in the database**. It is not widened here, and cannot be: SQL Server refuses to re-enable versioning when the current and history tables disagree on nullability, so a row like that could not exist in production either. The case worth reproducing is the column that is nullable in the database while the model declares the property required — a stored computed column whose `CASE` has no `ELSE` is the common way to end up there, and is what the snippet above uses.


## Performance

Each call performs three round trips to SQL Server (opening DDL pair, the two UPDATEs combined, closing DDL pair). The dominant costs are:
Expand Down
85 changes: 79 additions & 6 deletions pages/temporal-helper.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ To change this file edit the source file and then run MarkdownSnippets.

`SetCurrentPeriodStart` re-stamps a row's `PeriodStart` (and aligns the most recent history row's `PeriodEnd`) on a [SQL Server temporal table](https://learn.microsoft.com/sql/relational-databases/tables/temporal-tables). It exists to give back-to-back `SaveChanges` calls in tests distinct, deterministic temporal timestamps without relying on `Task.Delay` between them.

The method is exposed on both `SqlInstance<TDbContext>` and `SqlDatabase<TDbContext>`. Schema lookups (table, history table, period columns, PK column) are performed once at `SqlInstance` construction and cached, so per-call overhead is only the SQL execution.
`SetHistoryColumn` is a second helper on the same schema lookup. It writes one column on the history rows of a single entity, to put a history table into a state a freshly migrated database never reaches. See [Simulating a damaged history row](#simulating-a-damaged-history-row).

Both are exposed on `SqlInstance<TDbContext>` and `SqlDatabase<TDbContext>`. Schema lookups (table, history table, period columns, PK column) are performed once at `SqlInstance` construction and cached, so per-call overhead is only the SQL execution.


## Why
Expand All @@ -31,6 +33,12 @@ public class TravelRequest
public Guid Id { get; set; }
public string Status { get; set; } = "Draft";

// Declared required here. The CASE below always matches a branch, so
// this is never actually null - but it has no ELSE, which leaves the
// column itself nullable. That gap between what the model promises and
// what the schema permits is what SetHistoryColumn exploits.
public int StatusRank { get; set; }

[Timestamp]
public byte[] RowVersion { get; set; } = null!;
}
Expand All @@ -40,12 +48,21 @@ public class MyDbContext(DbContextOptions options) :
{
public DbSet<TravelRequest> TravelRequests { get; set; } = null!;

protected override void OnModelCreating(ModelBuilder model) =>
model.Entity<TravelRequest>()
.ToTable("TravelRequests", _ => _.IsTemporal());
protected override void OnModelCreating(ModelBuilder model)
{
var entity = model.Entity<TravelRequest>();
entity.ToTable("TravelRequests", _ => _.IsTemporal());
entity.Property(_ => _.StatusRank)
.HasComputedColumnSql(
"""
CASE WHEN [Status] = 'Approved' THEN 1
WHEN [Status] <> 'Approved' THEN 0 END
""",
stored: true);
}
}
```
<sup><a href='/src/EfLocalDb.Tests/Snippets/TemporalSnippetTests.cs#L5-L26' title='Snippet source file'>snippet source</a> | <a href='#snippet-TemporalEntityConfig' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/EfLocalDb.Tests/Snippets/TemporalSnippetTests.cs#L5-L41' title='Snippet source file'>snippet source</a> | <a href='#snippet-TemporalEntityConfig' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->


Expand Down Expand Up @@ -76,7 +93,7 @@ await database.SetCurrentPeriodStart(request, anchor.AddMilliseconds(200));
// Subsequent TemporalAsOf queries can now resolve each transition by its
// distinct, deterministic PeriodStart instead of relying on Task.Delay.
```
<sup><a href='/src/EfLocalDb.Tests/Snippets/TemporalSnippetTests.cs#L40-L64' title='Snippet source file'>snippet source</a> | <a href='#snippet-SetCurrentPeriodStartUsage' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/EfLocalDb.Tests/Snippets/TemporalSnippetTests.cs#L55-L79' title='Snippet source file'>snippet source</a> | <a href='#snippet-SetCurrentPeriodStartUsage' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

Two overloads are available on `SqlDatabase<TDbContext>`:
Expand All @@ -101,6 +118,62 @@ For each call the helper runs, in separate batches:
Steps 5 and 6 run in a `finally` so a failed UPDATE doesn't leave the table without versioning.


## Simulating a damaged history row

A history table can hold values the current model says are impossible. The usual cause is a migration that drops and re-adds a column on a temporal pair — done to keep column ordinals matching between the two tables, which SQL Server requires. The current table repopulates (or recomputes, if the column is computed); the rows already in the history table are left NULL, and SQL Server does not backfill them.

Nothing in a test suite reproduces that on its own, because every test database is built by migrating from empty. So the read path that trips over those NULLs — typically materialising an entity whose property is non-nullable, which throws `SqlNullValueException` — is exercised for the first time in production.

`SetHistoryColumn` reproduces it:

<!-- snippet: SetHistoryColumnUsage -->
<a id='snippet-SetHistoryColumnUsage'></a>
```cs
await using var database = await instance.Build();

var request = new TravelRequest { Id = Guid.NewGuid(), Status = "Draft" };
database.Context.Add(request);
await database.Context.SaveChangesAsync();

// Separate the two saves, or they land in the same tick, SQL Server
// discards the zero-length history row, and there is no history left
// to blank. The two helpers are meant to be used together.
var anchor = DateTime.UtcNow.AddSeconds(-10);
await database.SetCurrentPeriodStart(request, anchor);

request.Status = "Approved";
await database.Context.SaveChangesAsync();
await database.SetCurrentPeriodStart(request, anchor.AddMilliseconds(100));

// Blank the column on the history rows only. A column dropped and
// re-added on a temporal pair leaves exactly this: the current row is
// repopulated, the rows already in history are not, and SQL Server
// never backfills them.
await database.SetHistoryColumn<TravelRequest>(
request.Id,
nameof(TravelRequest.StatusRank),
null);

// Materialising that history row now fails on a SqlNullValueException,
// because the model reads StatusRank into a non-nullable int. That is
// the production failure, reproduced in a test.
var exception = CatchAsync(
() => database.Context.Set<TravelRequest>()
.TemporalAll()
.Where(_ => _.Id == request.Id)
.ToListAsync());
NotNull(exception);
```
<sup><a href='/src/EfLocalDb.Tests/Snippets/TemporalSnippetTests.cs#L85-L122' title='Snippet source file'>snippet source</a> | <a href='#snippet-SetHistoryColumnUsage' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

The period columns and the primary key are rejected: rewriting a period on a history row corrupts the timeline `SetCurrentPeriodStart` maintains, and does so silently, while rewriting the key detaches the row from the entity it is history for. Any other mapped column can be set, computed columns included — those are plain columns on the history table.

Versioning is turned off for the write and back on in a `finally`. Unlike `SetCurrentPeriodStart` the `PERIOD` is not dropped, since the period columns are ordinary columns on the history table.

For a null value the column has to permit NULL **in the database**. It is not widened here, and cannot be: SQL Server refuses to re-enable versioning when the current and history tables disagree on nullability, so a row like that could not exist in production either. The case worth reproducing is the column that is nullable in the database while the model declares the property required — a stored computed column whose `CASE` has no `ELSE` is the common way to end up there, and is what the snippet above uses.


## Performance

Each call performs three round trips to SQL Server (opening DDL pair, the two UPDATEs combined, closing DDL pair). The dominant costs are:
Expand Down
73 changes: 69 additions & 4 deletions src/EfLocalDb.Tests/Snippets/TemporalSnippetTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ public class TravelRequest
public Guid Id { get; set; }
public string Status { get; set; } = "Draft";

// Declared required here. The CASE below always matches a branch, so
// this is never actually null - but it has no ELSE, which leaves the
// column itself nullable. That gap between what the model promises and
// what the schema permits is what SetHistoryColumn exploits.
public int StatusRank { get; set; }

[Timestamp]
public byte[] RowVersion { get; set; } = null!;
}
Expand All @@ -18,9 +24,18 @@ public class MyDbContext(DbContextOptions options) :
{
public DbSet<TravelRequest> TravelRequests { get; set; } = null!;

protected override void OnModelCreating(ModelBuilder model) =>
model.Entity<TravelRequest>()
.ToTable("TravelRequests", _ => _.IsTemporal());
protected override void OnModelCreating(ModelBuilder model)
{
var entity = model.Entity<TravelRequest>();
entity.ToTable("TravelRequests", _ => _.IsTemporal());
entity.Property(_ => _.StatusRank)
.HasComputedColumnSql(
"""
CASE WHEN [Status] = 'Approved' THEN 1
WHEN [Status] <> 'Approved' THEN 0 END
""",
stored: true);
}
}

#endregion
Expand Down Expand Up @@ -63,4 +78,54 @@ public async Task SetCurrentPeriodStartUsage()

#endregion
}
}

[Test]
public async Task SetHistoryColumnUsage()
{
#region SetHistoryColumnUsage

await using var database = await instance.Build();

var request = new TravelRequest { Id = Guid.NewGuid(), Status = "Draft" };
database.Context.Add(request);
await database.Context.SaveChangesAsync();

// Separate the two saves, or they land in the same tick, SQL Server
// discards the zero-length history row, and there is no history left
// to blank. The two helpers are meant to be used together.
var anchor = DateTime.UtcNow.AddSeconds(-10);
await database.SetCurrentPeriodStart(request, anchor);

request.Status = "Approved";
await database.Context.SaveChangesAsync();
await database.SetCurrentPeriodStart(request, anchor.AddMilliseconds(100));

// Blank the column on the history rows only. A column dropped and
// re-added on a temporal pair leaves exactly this: the current row is
// repopulated, the rows already in history are not, and SQL Server
// never backfills them.
await database.SetHistoryColumn<TravelRequest>(
request.Id,
nameof(TravelRequest.StatusRank),
null);

// Materialising that history row now fails on a SqlNullValueException,
// because the model reads StatusRank into a non-nullable int. That is
// the production failure, reproduced in a test.
var exception = CatchAsync(
() => database.Context.Set<TravelRequest>()
.TemporalAll()
.Where(_ => _.Id == request.Id)
.ToListAsync());
NotNull(exception);

#endregion

// The current row is untouched, so ordinary queries keep working.
var current = await database.Context.Set<TravelRequest>()
.Where(_ => _.Id == request.Id)
.Select(_ => _.StatusRank)
.SingleAsync();
AreEqual(1, current);
}
}
68 changes: 68 additions & 0 deletions src/EfLocalDb.Tests/TemporalTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,74 @@ public async Task EntityOverload_ReloadsRowVersion()
await database.Context.SaveChangesAsync();
}

[Test]
public async Task SetHistoryColumn_LeavesCurrentRowUntouched()
{
await using var database = await instance.Build();
var entity = new TemporalEntity { Id = Guid.NewGuid(), Property = "v1" };
database.Context.Add(entity);
await database.Context.SaveChangesAsync();

var anchor = DateTime.UtcNow.AddSeconds(-30);
await database.SetCurrentPeriodStart(entity, anchor);

entity.Property = "v2";
await database.Context.SaveChangesAsync();
await database.SetCurrentPeriodStart(entity, anchor.AddSeconds(1));

await database.SetHistoryColumn<TemporalEntity>(entity.Id, nameof(TemporalEntity.Property), null);

// The history row now holds a value the model says is impossible, which is the point:
// this is what a column dropped and re-added on a temporal pair leaves behind.
var maxPeriod = new DateTime(9999, 12, 31, 23, 59, 59, 999, DateTimeKind.Utc);
var history = await database.Context.Set<TemporalEntity>()
.TemporalAll()
.Where(_ => _.Id == entity.Id && EF.Property<DateTime>(_, "PeriodEnd") < maxPeriod)
.Select(_ => _.Property)
.SingleAsync();
Null(history);

var current = await database.Context.Set<TemporalEntity>()
.Where(_ => _.Id == entity.Id)
.Select(_ => _.Property)
.SingleAsync();
AreEqual("v2", current);
}

[Test]
public async Task SetHistoryColumn_LeavesVersioningOn()
{
await using var database = await instance.Build();
var entity = new TemporalEntity { Id = Guid.NewGuid(), Property = "v1" };
database.Context.Add(entity);
await database.Context.SaveChangesAsync();

await database.SetHistoryColumn<TemporalEntity>(entity.Id, nameof(TemporalEntity.Property), null);

// Versioning has to be off to write to a history table, so a helper that failed to turn
// it back on would silently stop every later save in the test from being versioned.
// The saves need distinct periods or SQL Server drops the zero-length history row, and
// the count below then fails for a reason that has nothing to do with versioning.
await database.SetCurrentPeriodStart(entity, DateTime.UtcNow.AddSeconds(-10));

entity.Property = "v2";
await database.Context.SaveChangesAsync();

var versions = await database.Context.Set<TemporalEntity>()
.TemporalAll()
.CountAsync(_ => _.Id == entity.Id);
AreEqual(2, versions);
}

[Test]
public async Task SetHistoryColumn_ThrowsForPeriodColumn()
{
await using var database = await instance.Build();
var ex = ThrowsAsync<InvalidOperationException>(() =>
database.SetHistoryColumn<TemporalEntity>(Guid.NewGuid(), "PeriodEnd", DateTime.UtcNow));
That(ex!.Message, Does.Contain("not a settable history column"));
}

[Test]
public async Task Throws_WhenEntityNotTemporal()
{
Expand Down
13 changes: 13 additions & 0 deletions src/EfLocalDb/SqlDatabase_Temporal.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ public Task SetCurrentPeriodStart<TEntity>(object id, DateTime periodStart)
where TEntity : class =>
instance.SetCurrentPeriodStart<TEntity>(Context, id, periodStart);

/// <summary>
/// Sets one column on every history row for <paramref name="id"/>, leaving the current row
/// untouched. Use in tests to reproduce a history table that a migration has left in a state
/// a freshly built database never reaches - most usefully a NULL in a column that was dropped
/// and re-added on the temporal pair, since SQL Server does not backfill such a column into
/// the rows already in history.
/// <para>The period columns cannot be set this way: rewriting those corrupts the timeline
/// <see cref="SetCurrentPeriodStart{TEntity}(object, DateTime)"/> maintains.</para>
/// </summary>
public Task SetHistoryColumn<TEntity>(object id, string propertyName, object? value)
where TEntity : class =>
instance.SetHistoryColumn<TEntity>(Context, id, propertyName, value);

/// <summary>
/// Convenience overload that extracts the PK from <paramref name="entity"/> and reloads
/// it from the database afterward (so the bumped RowVersion doesn't break optimistic
Expand Down
Loading
Loading