fix(datacopy): tag copied timestamps as UTC instead of enabling legacy behaviour

DataCopy read SqlServer datetime2 values, which carry Kind=Unspecified, straight into Postgres timestamptz columns that reject them. It previously worked around this with the process-wide Npgsql legacy switch; now every copied entity has its DateTime and DateTime? properties normalized to Kind=Utc before the write. That names the existing value without shifting it, so the stored instant stays exactly what SqlServer held, and no global behaviour is changed.
This commit is contained in:
2026-10-17 16:53:12 +03:30
parent 2db3202c71
commit f029ddb003

View File

@@ -47,12 +47,6 @@ internal static class Program
?? throw new InvalidOperationException("Missing ConnectionStrings:Postgres"); ?? throw new InvalidOperationException("Missing ConnectionStrings:Postgres");
var reset = args.Contains("--reset"); var reset = args.Contains("--reset");
// ponytail: this tool passes DateTime values through verbatim (SqlServer datetime2 reads
// back as Kind=Unspecified, the API's timestamptz columns reject that). Legacy behaviour
// makes Npgsql accept either Kind for this pass-through copy only — the API process does
// not enable it. Retire this if the copy ever has to reinterpret time zones.
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
using var source = new GreenHomeDbContext( using var source = new GreenHomeDbContext(
new DbContextOptionsBuilder<GreenHomeDbContext>().UseSqlServer(sqlServerCs).Options); new DbContextOptionsBuilder<GreenHomeDbContext>().UseSqlServer(sqlServerCs).Options);
// the target must be the Postgres-specific context so Migrate() picks the Postgres // the target must be the Postgres-specific context so Migrate() picks the Postgres
@@ -170,6 +164,7 @@ internal static class Program
.Where(where).OrderBy(orderBy).Take(batchSize).ToListAsync(); .Where(where).OrderBy(orderBy).Take(batchSize).ToListAsync();
if (batch.Count == 0) break; if (batch.Count == 0) break;
foreach (var item in batch) NormalizeTimestampsToUtc(item);
target.Set<TEntity>().AddRange(batch); target.Set<TEntity>().AddRange(batch);
await target.SaveChangesAsync(); await target.SaveChangesAsync();
copied += batch.Count; copied += batch.Count;
@@ -204,6 +199,7 @@ internal static class Program
foreach (var row in await source.Set<TEntity>().AsNoTracking().ToListAsync()) foreach (var row in await source.Set<TEntity>().AsNoTracking().ToListAsync())
{ {
if (existing.Contains(KeyOf(row, pkNames))) continue; if (existing.Contains(KeyOf(row, pkNames))) continue;
NormalizeTimestampsToUtc(row);
target.Set<TEntity>().Add(row); target.Set<TEntity>().Add(row);
existing.Add(KeyOf(row, pkNames)); existing.Add(KeyOf(row, pkNames));
copied++; copied++;
@@ -217,6 +213,32 @@ internal static class Program
// Plumbing // Plumbing
// ============================================================ // ============================================================
/// <summary>
/// SqlServer's datetime2 carries no Kind, so values are read back as Unspecified. The app has
/// always written UTC (DateTime.UtcNow) and the target columns are timestamptz, which rejects an
/// Unspecified value. Tag every timestamp as Utc before it is written — this names the value,
/// it never shifts it, so the stored instant stays exactly what SqlServer held.
/// </summary>
static void NormalizeTimestampsToUtc(object entity)
{
foreach (var property in entity.GetType().GetProperties())
{
if (!property.CanRead || !property.CanWrite) continue;
if (property.PropertyType == typeof(DateTime))
{
var value = (DateTime)property.GetValue(entity)!;
if (value.Kind != DateTimeKind.Utc)
property.SetValue(entity, DateTime.SpecifyKind(value, DateTimeKind.Utc));
}
else if (property.PropertyType == typeof(DateTime?))
{
if (property.GetValue(entity) is DateTime value && value.Kind != DateTimeKind.Utc)
property.SetValue(entity, DateTime.SpecifyKind(value, DateTimeKind.Utc));
}
}
}
static async Task<object?> ScalarAsync(DbConnection conn, string sql) static async Task<object?> ScalarAsync(DbConnection conn, string sql)
{ {
await using var cmd = conn.CreateCommand(); await using var cmd = conn.CreateCommand();