From d235f4208a6ad6e64f9131f50f16e8516ca77df4 Mon Sep 17 00:00:00 2001 From: Adolfo Marinucci Date: Wed, 16 Sep 2026 09:32:08 +0200 Subject: [PATCH 1/2] Support tinyint and time columns in synchronization A table holding a SQL Server tinyint or time column could not be synchronized at all: SyncItemValue.DetectTypeOfObject threw NotSupportedException as soon as a provider read one, because System.Byte and System.TimeSpan have no SyncItemValueType. Rather than add enum members - which an older peer would reject on both the SyncProviderHttpClient and the SyncAgentController side - normalize the value to a type that already has one, so the wire format is unchanged: - byte, sbyte and ushort become Int32, uint becomes Int64; - TimeSpan becomes an invariant "c" formatted string, the same representation Microsoft.Data.Sqlite and EF Core use to store a TimeSpan (ticks must never be used: Microsoft.Data.Sqlite reads an integer into a TimeSpan as days); - char becomes a single character string, which the SQLite provider could already produce but nothing could carry. Apply side: - SqlServer/SqlServerCT ConvertToSqlType narrows to byte for SqlDbType.TinyInt (also covering the boolean a MySql TINYINT(1) yields) and parses a string back into a TimeSpan for SqlDbType.Time; - PostgreSQL and MySql convert a string back into a TimeSpan for their time and interval columns; - SqliteSyncProvider.GetValueFromRecord reads a nullable property through its underlying type and reads a TimeSpan explicitly, so a byte?/TimeSpan?/DateTime? column no longer falls through to GetValue() and is sent with the same SyncItemValueType the server sends down for the very same column. --- src/CoreSync.MySql/MySqlSyncTable.cs | 10 + .../PostgreSQLSyncTable.cs | 15 + src/CoreSync.SqlServer/Utils.cs | 12 + src/CoreSync.SqlServerCT/Utils.cs | 12 + src/CoreSync.Sqlite/SqliteSyncProvider.cs | 9 + src/CoreSync.Tests/ColumnTypeMappingTests.cs | 490 ++++++++++++++++++ src/CoreSync/SyncItemValue.cs | 88 +++- 7 files changed, 635 insertions(+), 1 deletion(-) create mode 100644 src/CoreSync.Tests/ColumnTypeMappingTests.cs diff --git a/src/CoreSync.MySql/MySqlSyncTable.cs b/src/CoreSync.MySql/MySqlSyncTable.cs index c12684d..9eba6bf 100644 --- a/src/CoreSync.MySql/MySqlSyncTable.cs +++ b/src/CoreSync.MySql/MySqlSyncTable.cs @@ -1,6 +1,7 @@ using MySqlConnector; using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; namespace CoreSync.MySql @@ -96,6 +97,15 @@ value is string stringDate && { return parsedDate; } + + //a time column travels as an invariant "c" formatted string (see SyncItemValue): it has + //no dedicated SyncItemValueType and that is also how SQLite stores it + if (column.DataType.Equals("time", StringComparison.OrdinalIgnoreCase) && + value is string stringTime && + TimeSpan.TryParse(stringTime, CultureInfo.InvariantCulture, out var parsedTime)) + { + return parsedTime; + } } return value; diff --git a/src/CoreSync.PostgreSQL/PostgreSQLSyncTable.cs b/src/CoreSync.PostgreSQL/PostgreSQLSyncTable.cs index 94bf024..2a37bdb 100644 --- a/src/CoreSync.PostgreSQL/PostgreSQLSyncTable.cs +++ b/src/CoreSync.PostgreSQL/PostgreSQLSyncTable.cs @@ -1,6 +1,7 @@ using Npgsql; using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; namespace CoreSync.PostgreSQL @@ -91,6 +92,20 @@ private object ConvertValueForColumn(string columnName, object? value) } } + // A time/interval column travels as an invariant "c" formatted string (see SyncItemValue): + // it has no dedicated SyncItemValueType and that is also how SQLite stores it. + if (Columns.TryGetValue(columnName, out var timeColumn) && + (timeColumn.Type.Equals("time", StringComparison.OrdinalIgnoreCase) || + timeColumn.Type.Equals("time without time zone", StringComparison.OrdinalIgnoreCase) || + timeColumn.Type.Equals("interval", StringComparison.OrdinalIgnoreCase))) + { + if (value is string timeString && + TimeSpan.TryParse(timeString, CultureInfo.InvariantCulture, out var timeSpanValue)) + { + return timeSpanValue; + } + } + return value; } diff --git a/src/CoreSync.SqlServer/Utils.cs b/src/CoreSync.SqlServer/Utils.cs index 5be97e1..68b8a54 100644 --- a/src/CoreSync.SqlServer/Utils.cs +++ b/src/CoreSync.SqlServer/Utils.cs @@ -21,6 +21,18 @@ public static object ConvertToSqlType(SyncItemValue value, SqlDbType dbType) value.Value is decimal == false) return Convert.ToDecimal(value.Value, CultureInfo.InvariantCulture); + //a time column travels as an invariant "c" formatted string (see SyncItemValue): it has no + //dedicated SyncItemValueType and that is also how SQLite stores it + if (dbType == SqlDbType.Time && + value.Value is string timeValue) + return TimeSpan.Parse(timeValue, CultureInfo.InvariantCulture); + + //a tinyint column travels as an Int32 (or as a boolean, from MySql TINYINT(1)): SqlParameter + //does not narrow it on its own + if (dbType == SqlDbType.TinyInt && + value.Value is byte == false) + return Convert.ToByte(value.Value, CultureInfo.InvariantCulture); + return value.Value; } } diff --git a/src/CoreSync.SqlServerCT/Utils.cs b/src/CoreSync.SqlServerCT/Utils.cs index 5237bb9..11374c5 100644 --- a/src/CoreSync.SqlServerCT/Utils.cs +++ b/src/CoreSync.SqlServerCT/Utils.cs @@ -19,6 +19,18 @@ public static object ConvertToSqlType(SyncItemValue value, SqlDbType dbType) value.Value is decimal == false) return Convert.ToDecimal(value.Value, CultureInfo.InvariantCulture); + //a time column travels as an invariant "c" formatted string (see SyncItemValue): it has no + //dedicated SyncItemValueType and that is also how SQLite stores it + if (dbType == SqlDbType.Time && + value.Value is string timeValue) + return TimeSpan.Parse(timeValue, CultureInfo.InvariantCulture); + + //a tinyint column travels as an Int32 (or as a boolean, from MySql TINYINT(1)): SqlParameter + //does not narrow it on its own + if (dbType == SqlDbType.TinyInt && + value.Value is byte == false) + return Convert.ToByte(value.Value, CultureInfo.InvariantCulture); + return value.Value; } } diff --git a/src/CoreSync.Sqlite/SqliteSyncProvider.cs b/src/CoreSync.Sqlite/SqliteSyncProvider.cs index 543cfd4..95725a0 100644 --- a/src/CoreSync.Sqlite/SqliteSyncProvider.cs +++ b/src/CoreSync.Sqlite/SqliteSyncProvider.cs @@ -662,10 +662,19 @@ private static ChangeType DetectChangeType(Dictionary values) private static object GetValueFromRecord(SqliteDataReader r, int columnOrdinal, Type propertyType) { + //a nullable property is read exactly like its underlying type: the caller has already + //excluded DBNull. Without this, every nullable column fell through to GetValue() and was + //read back as whatever SQLite stored it as (an INTEGER for a byte?, a string for a + //TimeSpan?/DateTime?), which does not round-trip to the same SyncItemValueType the + //server sends down for the very same column. + propertyType = Nullable.GetUnderlyingType(propertyType) ?? propertyType; + if (propertyType == typeof(string)) return r.GetString(columnOrdinal); if (propertyType == typeof(DateTime)) return r.GetDateTime(columnOrdinal); + if (propertyType == typeof(TimeSpan)) + return r.GetTimeSpan(columnOrdinal); if (propertyType == typeof(int)) return r.GetInt32(columnOrdinal); if (propertyType == typeof(bool)) diff --git a/src/CoreSync.Tests/ColumnTypeMappingTests.cs b/src/CoreSync.Tests/ColumnTypeMappingTests.cs new file mode 100644 index 0000000..fe16f87 --- /dev/null +++ b/src/CoreSync.Tests/ColumnTypeMappingTests.cs @@ -0,0 +1,490 @@ +using CoreSync.Http; +using CoreSync.Sqlite; +using CoreSync.SqlServer; +using Microsoft.Data.SqlClient; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Shouldly; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; + +namespace CoreSync.Tests; + +/// +/// Covers the column types that have no dedicated and used to make +/// synchronization fail outright with +/// NotSupportedException: Type of value ('System.Byte') is not supported for synchronization +/// as soon as a synchronized table contained a SQL Server tinyint or time column: +/// +/// tinyint is read as and must travel as an +/// , then be narrowed back to a byte when applied; +/// time is read as and must travel as an invariant +/// "c" formatted - the very format both +/// Microsoft.Data.Sqlite and EF Core use to store a - then be parsed back; +/// no new member may appear, or a peer running an +/// older version of the library would throw on the payload. +/// +/// The round trips also carry smallint, decimal, uniqueidentifier and +/// datetime2 columns, both null and non-null, so that the normalization cannot regress the +/// types that already worked. +/// +[TestClass] +public class ColumnTypeMappingTests +{ + private static string SqlServerConnectionString => Environment.GetEnvironmentVariable("CORE-SYNC_CONNECTION_STRING") ?? + "Server=localhost;User Id=sa;Password=CoreSync_Test123!;TrustServerCertificate=True"; + + private static string SqlServerConnectionStringFor(string databaseName) + => new SqlConnectionStringBuilder(SqlServerConnectionString) { InitialCatalog = databaseName }.ConnectionString; + + #region Model + + /// + /// Mirrors the raw DDL below. Property types are what the mobile side of a real deployment declares, + /// so the SQLite provider reads every column through its typed reader path. + /// + public class Measurement + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public byte StartMonth { get; set; } + public byte? EndMonth { get; set; } + public short Zone { get; set; } + public TimeSpan CountTime { get; set; } + public TimeSpan? Duration { get; set; } + public decimal Weight { get; set; } + public Guid ExternalId { get; set; } + public DateTime Recorded { get; set; } + } + + private sealed class MeasurementDbContext : DbContext + { + private readonly string _connectionString; + + public MeasurementDbContext(string connectionString) => _connectionString = connectionString; + + public DbSet Measurements => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseSqlite(_connectionString); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Measurements"); + entity.HasKey(_ => _.Id); + entity.Property(_ => _.Id).ValueGeneratedNever(); + entity.Property(_ => _.Weight).HasColumnType("TEXT"); + }); + } + } + + private const string SqlServerDdl = @" + CREATE TABLE [dbo].[Measurements] ( + [Id] INT NOT NULL PRIMARY KEY, + [Name] NVARCHAR(100) NOT NULL, + [StartMonth] TINYINT NOT NULL, + [EndMonth] TINYINT NULL, + [Zone] SMALLINT NOT NULL, + [CountTime] TIME(0) NOT NULL, + [Duration] TIME(7) NULL, + [Weight] DECIMAL(10,3) NOT NULL, + [ExternalId] UNIQUEIDENTIFIER NOT NULL, + [Recorded] DATETIME2 NOT NULL + );"; + + // Declared types are the ones EF Core's SQLite provider generates for the model above. + private const string SqliteDdl = @" + CREATE TABLE [Measurements] ( + [Id] INTEGER NOT NULL PRIMARY KEY, + [Name] TEXT NOT NULL, + [StartMonth] INTEGER NOT NULL, + [EndMonth] INTEGER NULL, + [Zone] INTEGER NOT NULL, + [CountTime] TEXT NOT NULL, + [Duration] TEXT NULL, + [Weight] TEXT NOT NULL, + [ExternalId] TEXT NOT NULL, + [Recorded] TEXT NOT NULL + );"; + + #endregion + + #region Helpers + + private static async Task ExecuteSqlServerNonQuery(string connectionString, string commandText) + { + using var conn = new SqlConnection(connectionString); + await conn.OpenAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = commandText; + await cmd.ExecuteNonQueryAsync(); + } + + private static async Task CreateSqlServerDatabase(string dbName) + { + using var conn = new SqlConnection(SqlServerConnectionStringFor("master")); + await conn.OpenAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = $@" + IF DB_ID('{dbName}') IS NOT NULL + BEGIN + ALTER DATABASE [{dbName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + DROP DATABASE [{dbName}]; + END + CREATE DATABASE [{dbName}];"; + await cmd.ExecuteNonQueryAsync(); + } + + private static async Task DropSqlServerDatabase(string dbName) + { + using var conn = new SqlConnection(SqlServerConnectionStringFor("master")); + await conn.OpenAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = $@" + IF DB_ID('{dbName}') IS NOT NULL + BEGIN + ALTER DATABASE [{dbName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + DROP DATABASE [{dbName}]; + END"; + await cmd.ExecuteNonQueryAsync(); + } + + private static async Task CreateSqliteDatabase(string connectionString) + { + using var conn = new SqliteConnection(connectionString); + await conn.OpenAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = SqliteDdl; + await cmd.ExecuteNonQueryAsync(); + } + + private static async Task ReadFromSqlServer(string connectionString, int id) + { + using var conn = new SqlConnection(connectionString); + await conn.OpenAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"SELECT [Name],[StartMonth],[EndMonth],[Zone],[CountTime],[Duration],[Weight],[ExternalId],[Recorded] +FROM [dbo].[Measurements] WHERE [Id] = @id"; + cmd.Parameters.AddWithValue("@id", id); + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) + return null; + + return new Measurement + { + Id = id, + Name = reader.GetString(0), + // GetByte/GetTimeSpan, not GetValue: the column must really be a tinyint/time on arrival, + // not something SQL Server silently coerced. + StartMonth = reader.GetByte(1), + EndMonth = reader.IsDBNull(2) ? null : reader.GetByte(2), + Zone = reader.GetInt16(3), + CountTime = reader.GetTimeSpan(4), + Duration = reader.IsDBNull(5) ? null : reader.GetTimeSpan(5), + Weight = reader.GetDecimal(6), + ExternalId = reader.GetGuid(7), + Recorded = reader.GetDateTime(8) + }; + } + + private enum Transport + { + Direct, + HttpJson, + HttpBinary + } + + /// + /// Runs a full SQL Server → SQLite → SQL Server round trip over the requested transport. + /// + private static async Task RunRoundTrip(string testName, Transport transport) + { + var dbName = $"CoreSyncTypes_{testName}"; + var sqliteFile = Path.Combine(Path.GetTempPath(), $"CoreSyncTypes_{testName}.sqlite"); + var sqliteConnStr = $"Data Source={sqliteFile}"; + + SqliteConnection.ClearAllPools(); + if (File.Exists(sqliteFile)) File.Delete(sqliteFile); + + SyncTestServer? server = null; + + try + { + await CreateSqlServerDatabase(dbName); + var sqlConnStr = SqlServerConnectionStringFor(dbName); + await ExecuteSqlServerNonQuery(sqlConnStr, SqlServerDdl); + await CreateSqliteDatabase(sqliteConnStr); + + var remoteProvider = new SqlSyncProvider( + new SqlSyncConfigurationBuilder(sqlConnStr).Table("Measurements").Build(), + logger: new ConsoleLogger("REM")); + await remoteProvider.ApplyProvisionAsync(); + + var localProvider = new SqliteSyncProvider( + new SqliteSyncConfigurationBuilder(sqliteConnStr).Table("Measurements").Build(), + logger: new ConsoleLogger("LOC")); + await localProvider.ApplyProvisionAsync(); + + ISyncProviderBase remote = remoteProvider; + if (transport != Transport.Direct) + { + server = SyncTestServer.Create(remoteProvider, useBinaryFormat: transport == Transport.HttpBinary); + remote = server.HttpSyncProvider; + } + + var agent = new SyncAgent(localProvider, remote); + + // ---------- SQL Server → SQLite ---------- + + var downloadedGuid = new Guid("0f8fad5b-d9cb-469f-a165-70867728950e"); + var downloadedRecorded = new DateTime(2026, 9, 15, 21, 21, 33).AddTicks(1234567); + + await ExecuteSqlServerNonQuery(sqlConnStr, $@" + INSERT INTO [dbo].[Measurements] + ([Id],[Name],[StartMonth],[EndMonth],[Zone],[CountTime],[Duration],[Weight],[ExternalId],[Recorded]) + VALUES + (1, N'Spring', 3, 250, -5, '14:30:00', '01:02:03.4567890', 12.345, '{downloadedGuid}', '2026-09-15T21:21:33.1234567'), + (2, N'Winter', 0, NULL, 32767, '00:00:00', NULL, 0.001, '{Guid.Empty}', '2026-01-01T00:00:00.0000000');"); + + await agent.SynchronizeAsync(); + + { + using var db = new MeasurementDbContext(sqliteConnStr); + + var full = await db.Measurements.SingleAsync(_ => _.Id == 1); + full.Name.ShouldBe("Spring"); + full.StartMonth.ShouldBe((byte)3); + // 250 does not fit in an sbyte: it proves the value was widened, not reinterpreted. + full.EndMonth.ShouldBe((byte)250); + full.Zone.ShouldBe((short)-5); + full.CountTime.ShouldBe(new TimeSpan(14, 30, 0)); + full.Duration.ShouldBe(new TimeSpan(0, 1, 2, 3).Add(TimeSpan.FromTicks(4567890))); + full.Weight.ShouldBe(12.345m); + full.ExternalId.ShouldBe(downloadedGuid); + full.Recorded.ShouldBe(downloadedRecorded); + + var nulls = await db.Measurements.SingleAsync(_ => _.Id == 2); + nulls.StartMonth.ShouldBe((byte)0); + nulls.EndMonth.ShouldBeNull(); + nulls.Zone.ShouldBe((short)32767); + nulls.CountTime.ShouldBe(TimeSpan.Zero); + nulls.Duration.ShouldBeNull(); + nulls.ExternalId.ShouldBe(Guid.Empty); + } + + // ---------- SQLite → SQL Server ---------- + + var uploadedGuid = new Guid("7c9e6679-7425-40de-944b-e07fc1f90ae7"); + var uploadedRecorded = new DateTime(2026, 2, 3, 4, 5, 6).AddTicks(7654321); + var uploadedDuration = new TimeSpan(0, 12, 34, 56).Add(TimeSpan.FromTicks(7891234)); + + { + using var db = new MeasurementDbContext(sqliteConnStr); + db.Measurements.Add(new Measurement + { + Id = 100, + Name = "Uploaded", + StartMonth = 255, + EndMonth = 1, + Zone = -32768, + CountTime = new TimeSpan(23, 59, 59), + Duration = uploadedDuration, + Weight = 999.999m, + ExternalId = uploadedGuid, + Recorded = uploadedRecorded + }); + db.Measurements.Add(new Measurement + { + Id = 101, + Name = "UploadedNulls", + StartMonth = 7, + EndMonth = null, + Zone = 1, + CountTime = TimeSpan.Zero, + Duration = null, + Weight = 0m, + ExternalId = Guid.Empty, + Recorded = new DateTime(2026, 3, 4, 5, 6, 7) + }); + await db.SaveChangesAsync(); + } + + await agent.SynchronizeAsync(); + + var uploaded = await ReadFromSqlServer(sqlConnStr, 100); + uploaded.ShouldNotBeNull(); + uploaded.Name.ShouldBe("Uploaded"); + uploaded.StartMonth.ShouldBe((byte)255); + uploaded.EndMonth.ShouldBe((byte)1); + uploaded.Zone.ShouldBe((short)-32768); + uploaded.CountTime.ShouldBe(new TimeSpan(23, 59, 59)); + // time(7) keeps the sub-second part: a "c" formatted string must not lose it on the way in. + uploaded.Duration.ShouldBe(uploadedDuration); + uploaded.Weight.ShouldBe(999.999m); + uploaded.ExternalId.ShouldBe(uploadedGuid); + uploaded.Recorded.ShouldBe(uploadedRecorded); + + var uploadedNulls = await ReadFromSqlServer(sqlConnStr, 101); + uploadedNulls.ShouldNotBeNull(); + uploadedNulls.StartMonth.ShouldBe((byte)7); + uploadedNulls.EndMonth.ShouldBeNull(); + uploadedNulls.CountTime.ShouldBe(TimeSpan.Zero); + uploadedNulls.Duration.ShouldBeNull(); + } + finally + { + server?.Dispose(); + SqlConnection.ClearAllPools(); + SqliteConnection.ClearAllPools(); + await DropSqlServerDatabase(dbName); + if (File.Exists(sqliteFile)) File.Delete(sqliteFile); + } + } + + #endregion + + #region Value normalization (no database) + + /// + /// The types without a dedicated must be normalized to a value whose + /// runtime type matches the declared , so that the JSON and the + /// MessagePack transports (which serialize by its runtime type) + /// deliver the same thing. + /// + [TestMethod] + public void SyncItemValue_NormalizesTypesWithoutADedicatedSyncItemValueType() + { + var tinyInt = new SyncItemValue((byte)250); + tinyInt.Type.ShouldBe(SyncItemValueType.Int32); + tinyInt.Value.ShouldBeOfType().ShouldBe(250); + + var signedByte = new SyncItemValue((sbyte)-3); + signedByte.Type.ShouldBe(SyncItemValueType.Int32); + signedByte.Value.ShouldBeOfType().ShouldBe(-3); + + var unsignedShort = new SyncItemValue((ushort)65535); + unsignedShort.Type.ShouldBe(SyncItemValueType.Int32); + unsignedShort.Value.ShouldBeOfType().ShouldBe(65535); + + var unsignedInt = new SyncItemValue(4294967295u); + unsignedInt.Type.ShouldBe(SyncItemValueType.Int64); + unsignedInt.Value.ShouldBeOfType().ShouldBe(4294967295L); + + var time = new SyncItemValue(new TimeSpan(0, 14, 30, 0)); + time.Type.ShouldBe(SyncItemValueType.String); + time.Value.ShouldBeOfType().ShouldBe("14:30:00"); + + var timeWithFraction = new SyncItemValue(new TimeSpan(0, 1, 2, 3).Add(TimeSpan.FromTicks(4567890))); + timeWithFraction.Value.ShouldBeOfType().ShouldBe("01:02:03.4567890"); + TimeSpan.Parse((string)timeWithFraction.Value!, CultureInfo.InvariantCulture) + .ShouldBe(new TimeSpan(0, 1, 2, 3).Add(TimeSpan.FromTicks(4567890))); + + var character = new SyncItemValue('U'); + character.Type.ShouldBe(SyncItemValueType.String); + character.Value.ShouldBeOfType().ShouldBe("U"); + + // The types that already worked are untouched. + new SyncItemValue(42).Type.ShouldBe(SyncItemValueType.Int32); + new SyncItemValue((short)7).Type.ShouldBe(SyncItemValueType.Int32); + new SyncItemValue(42L).Type.ShouldBe(SyncItemValueType.Int64); + new SyncItemValue(1.5m).Type.ShouldBe(SyncItemValueType.Decimal); + new SyncItemValue(Guid.Empty).Type.ShouldBe(SyncItemValueType.Guid); + new SyncItemValue(DBNull.Value).Type.ShouldBe(SyncItemValueType.Null); + new SyncItemValue(DBNull.Value).Value.ShouldBeNull(); + new SyncItemValue(null).Type.ShouldBe(SyncItemValueType.Null); + } + + /// + /// A member added for these types would be an unknown enum value to a + /// peer running an older build, and both SyncProviderHttpClient and SyncAgentController + /// answer an unknown member with . The enum must stay as it is. + /// + [TestMethod] + public void SyncItemValueType_HasNoNewMembers() + { + Enum.GetNames(typeof(SyncItemValueType)).ShouldBe( + new[] { "Null", "String", "Int32", "Int64", "Float", "Double", "DateTime", "Boolean", "ByteArray", "Guid", "Decimal" }, + ignoreOrder: false); + } + + /// + /// The JSON transport reads a value back with JsonElement.GetInt32()/GetString() chosen + /// from . A tinyint must therefore be on the wire as a JSON number and + /// a time as a JSON string. + /// + [TestMethod] + public void SyncItemValue_SurvivesJsonRoundTrip() + { + var item = new SyncItem("Measurements", ChangeType.Insert, new Dictionary + { + ["StartMonth"] = (byte)250, + ["CountTime"] = new TimeSpan(0, 1, 2, 3).Add(TimeSpan.FromTicks(4567890)), + ["EndMonth"] = null + }); + + var roundTripped = JsonSerializer.Deserialize(JsonSerializer.Serialize(item))!; + + roundTripped.Values["StartMonth"].Type.ShouldBe(SyncItemValueType.Int32); + ((JsonElement)roundTripped.Values["StartMonth"].Value!).GetInt32().ShouldBe(250); + + roundTripped.Values["CountTime"].Type.ShouldBe(SyncItemValueType.String); + ((JsonElement)roundTripped.Values["CountTime"].Value!).GetString().ShouldBe("01:02:03.4567890"); + + roundTripped.Values["EndMonth"].Type.ShouldBe(SyncItemValueType.Null); + roundTripped.Values["EndMonth"].Value.ShouldBeNull(); + } + + /// + /// The binary transport uses MessagePack's typeless resolver, which writes the runtime type of + /// into the payload. Without normalization a tinyint would arrive + /// as a and a time as a , which is exactly what the apply + /// side cannot bind. + /// + [TestMethod] + public async Task SyncItemValue_SurvivesMessagePackRoundTrip() + { + var item = new SyncItem("Measurements", ChangeType.Insert, new Dictionary + { + ["StartMonth"] = (byte)250, + ["CountTime"] = new TimeSpan(0, 1, 2, 3).Add(TimeSpan.FromTicks(4567890)), + ["EndMonth"] = null + }); + + using var stream = new MemoryStream(CoreSyncMessagePackSerializer.Serialize(item)); + var roundTripped = await CoreSyncMessagePackSerializer.DeserializeAsync(stream); + + roundTripped.Values["StartMonth"].Type.ShouldBe(SyncItemValueType.Int32); + roundTripped.Values["StartMonth"].Value.ShouldBeOfType().ShouldBe(250); + + roundTripped.Values["CountTime"].Type.ShouldBe(SyncItemValueType.String); + roundTripped.Values["CountTime"].Value.ShouldBeOfType().ShouldBe("01:02:03.4567890"); + + roundTripped.Values["EndMonth"].Type.ShouldBe(SyncItemValueType.Null); + roundTripped.Values["EndMonth"].Value.ShouldBeNull(); + } + + #endregion + + #region Round trips + + [TestMethod] + public Task SqlServerAndSqlite_RoundTripTinyIntAndTime_Direct() + => RunRoundTrip("Direct", Transport.Direct); + + [TestMethod] + public Task SqlServerAndSqlite_RoundTripTinyIntAndTime_HttpJson() + => RunRoundTrip("HttpJson", Transport.HttpJson); + + [TestMethod] + public Task SqlServerAndSqlite_RoundTripTinyIntAndTime_HttpBinary() + => RunRoundTrip("HttpBinary", Transport.HttpBinary); + + #endregion +} diff --git a/src/CoreSync/SyncItemValue.cs b/src/CoreSync/SyncItemValue.cs index 239a505..2a83181 100644 --- a/src/CoreSync/SyncItemValue.cs +++ b/src/CoreSync/SyncItemValue.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Text; namespace CoreSync @@ -8,6 +9,19 @@ namespace CoreSync /// Wraps a column value along with its detected for type-safe serialization /// during synchronization. /// + /// + /// Types that have no dedicated are normalized to one that does, so that + /// the wire format stays unchanged and peers running an older version of the library keep working: + /// + /// (SQL tinyint), and + /// become / ; + /// becomes / ; + /// (SQL time) becomes an invariant "c" formatted + /// / , which is also how both Microsoft.Data.Sqlite + /// and EF Core persist a ; + /// becomes a single character . + /// + /// public class SyncItemValue { /// @@ -24,38 +38,110 @@ public SyncItemValue() /// The runtime type of is not supported. public SyncItemValue(object? value) { - Value = value == DBNull.Value ? null : value; DetectTypeOfObject(value); } private void DetectTypeOfObject(object? value) { if (value == null || value is DBNull) + { + Value = null; Type = SyncItemValueType.Null; + } else if (value is string) + { + Value = value; Type = SyncItemValueType.String; + } else if (value is bool) + { + Value = value; Type = SyncItemValueType.Boolean; + } else if (value is byte[]) + { + Value = value; Type = SyncItemValueType.ByteArray; + } else if (value is DateTime) + { + Value = value; Type = SyncItemValueType.DateTime; + } else if (value is double) + { + Value = value; Type = SyncItemValueType.Double; + } else if (value is int) + { + Value = value; Type = SyncItemValueType.Int32; + } else if (value is float) + { + Value = value; Type = SyncItemValueType.Float; + } else if (value is Guid) + { + Value = value; Type = SyncItemValueType.Guid; + } else if (value is long) + { + Value = value; Type = SyncItemValueType.Int64; + } else if (value is short) + { + Value = value; Type = SyncItemValueType.Int32; + } else if (value is decimal) + { + Value = value; Type = SyncItemValueType.Decimal; + } + //types below have no dedicated SyncItemValueType: they are widened/formatted to an + //existing one so that the serialized payload remains readable by any version of the library + else if (value is byte byteValue) + { + Value = (int)byteValue; + Type = SyncItemValueType.Int32; + } + else if (value is sbyte sbyteValue) + { + Value = (int)sbyteValue; + Type = SyncItemValueType.Int32; + } + else if (value is ushort ushortValue) + { + Value = (int)ushortValue; + Type = SyncItemValueType.Int32; + } + else if (value is uint uintValue) + { + Value = (long)uintValue; + Type = SyncItemValueType.Int64; + } + else if (value is TimeSpan timeSpanValue) + { + //"c" is the invariant [-][d.]hh:mm:ss[.fffffff] format used by Microsoft.Data.Sqlite + //and EF Core to store a TimeSpan as text. Ticks must never be used: Microsoft.Data.Sqlite + //reads an integer column into a TimeSpan as a number of days. + Value = timeSpanValue.ToString("c", CultureInfo.InvariantCulture); + Type = SyncItemValueType.String; + } + else if (value is char charValue) + { + Value = charValue.ToString(); + Type = SyncItemValueType.String; + } else + { throw new NotSupportedException($"Type of value ('{value.GetType()}') is not supported for synchronization"); + } } /// From 9902bd4f860bcf2eb0462d384e475aedeaa64d1e Mon Sep 17 00:00:00 2001 From: Adolfo Marinucci Date: Wed, 16 Sep 2026 12:27:35 +0200 Subject: [PATCH 2/2] Harden tinyint and time support and cover every provider - SqlServer and SqlServerCT: reject a time outside 00:00:00 to 23:59:59.9999999 with a clear error, instead of an opaque SqlClient failure or silent truncation. - PostgreSQL: read time and interval columns as TimeSpan, since Npgsql defaults time to TimeOnly, which cannot be synchronized. timetz and intervals carrying months remain unsupported and are documented as such. - SQLite: when a typed getter cannot parse a legacy value in a nullable column, fall back to the raw stored value and log a warning. - Tests: SQL Server, SqlServerCT, MySQL and PostgreSQL round trips against SQLite (direct, HTTP JSON, HTTP binary); old-client payloads accepted by every server provider; time range edges and rounding; MySQL tinyint flavours; SQLite nullable and legacy reads. --- src/CoreSync.PostgreSQL/PostgreSQLColumn.cs | 18 + .../PostgreSQLSyncProvider.cs | 7 + .../PostgreSQLSyncTable.cs | 5 +- src/CoreSync.SqlServer/Utils.cs | 31 +- src/CoreSync.SqlServerCT/Utils.cs | 31 +- src/CoreSync.Sqlite/SqliteSyncProvider.cs | 37 +- .../ColumnTypeMappingTests.MySqlPostgreSQL.cs | 820 +++++++++++++ src/CoreSync.Tests/ColumnTypeMappingTests.cs | 1027 ++++++++++++++--- 8 files changed, 1808 insertions(+), 168 deletions(-) create mode 100644 src/CoreSync.Tests/ColumnTypeMappingTests.MySqlPostgreSQL.cs diff --git a/src/CoreSync.PostgreSQL/PostgreSQLColumn.cs b/src/CoreSync.PostgreSQL/PostgreSQLColumn.cs index e222576..3cc0043 100644 --- a/src/CoreSync.PostgreSQL/PostgreSQLColumn.cs +++ b/src/CoreSync.PostgreSQL/PostgreSQLColumn.cs @@ -16,5 +16,23 @@ public PostgreSQLColumn(string name, string type, bool primaryKey = false) public string Name { get; } public string Type { get; } public bool IsPrimaryKey { get; } + + /// + /// True for the PostgreSQL types Npgsql can materialize as a . + /// + /// + /// time with time zone (timetz) is deliberately excluded: Npgsql materializes it + /// as a , which has no and is not + /// supported for synchronization. + /// + /// An interval is included, but only one made of days/hours/minutes/seconds round-trips: + /// an interval carrying months or years has no fixed length and Npgsql refuses to convert it to + /// a . + /// + /// + public bool IsTimeLike => + Type.Equals("time without time zone", StringComparison.OrdinalIgnoreCase) || + Type.Equals("time", StringComparison.OrdinalIgnoreCase) || + Type.Equals("interval", StringComparison.OrdinalIgnoreCase); } } \ No newline at end of file diff --git a/src/CoreSync.PostgreSQL/PostgreSQLSyncProvider.cs b/src/CoreSync.PostgreSQL/PostgreSQLSyncProvider.cs index 6d3e04e..5a748cf 100644 --- a/src/CoreSync.PostgreSQL/PostgreSQLSyncProvider.cs +++ b/src/CoreSync.PostgreSQL/PostgreSQLSyncProvider.cs @@ -490,6 +490,13 @@ private static ChangeType DetectChangeType(Dictionary values) if (r.IsDBNull(columnOrdinal)) return null; + //a time/interval column must be materialized as a TimeSpan: Npgsql's default CLR type for + //'time without time zone' is TimeOnly, which has no SyncItemValueType (and cannot even be + //named from netstandard2.0). SyncItemValue then turns the TimeSpan into an invariant "c" + //formatted string, the representation every other provider understands. + if (table.Columns.TryGetValue(columnName, out var timeColumn) && timeColumn.IsTimeLike) + return r.GetFieldValue(columnOrdinal); + if (table.RecordType == null) return r.GetValue(columnOrdinal); diff --git a/src/CoreSync.PostgreSQL/PostgreSQLSyncTable.cs b/src/CoreSync.PostgreSQL/PostgreSQLSyncTable.cs index 2a37bdb..dbaa0a6 100644 --- a/src/CoreSync.PostgreSQL/PostgreSQLSyncTable.cs +++ b/src/CoreSync.PostgreSQL/PostgreSQLSyncTable.cs @@ -94,10 +94,7 @@ private object ConvertValueForColumn(string columnName, object? value) // A time/interval column travels as an invariant "c" formatted string (see SyncItemValue): // it has no dedicated SyncItemValueType and that is also how SQLite stores it. - if (Columns.TryGetValue(columnName, out var timeColumn) && - (timeColumn.Type.Equals("time", StringComparison.OrdinalIgnoreCase) || - timeColumn.Type.Equals("time without time zone", StringComparison.OrdinalIgnoreCase) || - timeColumn.Type.Equals("interval", StringComparison.OrdinalIgnoreCase))) + if (Columns.TryGetValue(columnName, out var timeColumn) && timeColumn.IsTimeLike) { if (value is string timeString && TimeSpan.TryParse(timeString, CultureInfo.InvariantCulture, out var timeSpanValue)) diff --git a/src/CoreSync.SqlServer/Utils.cs b/src/CoreSync.SqlServer/Utils.cs index 68b8a54..eca4538 100644 --- a/src/CoreSync.SqlServer/Utils.cs +++ b/src/CoreSync.SqlServer/Utils.cs @@ -23,9 +23,14 @@ public static object ConvertToSqlType(SyncItemValue value, SqlDbType dbType) //a time column travels as an invariant "c" formatted string (see SyncItemValue): it has no //dedicated SyncItemValueType and that is also how SQLite stores it - if (dbType == SqlDbType.Time && - value.Value is string timeValue) - return TimeSpan.Parse(timeValue, CultureInfo.InvariantCulture); + if (dbType == SqlDbType.Time) + { + if (value.Value is string timeValue) + return ValidateTimeOfDay(TimeSpan.Parse(timeValue, CultureInfo.InvariantCulture)); + + if (value.Value is TimeSpan timeSpanValue) + return ValidateTimeOfDay(timeSpanValue); + } //a tinyint column travels as an Int32 (or as a boolean, from MySql TINYINT(1)): SqlParameter //does not narrow it on its own @@ -35,5 +40,25 @@ public static object ConvertToSqlType(SyncItemValue value, SqlDbType dbType) return value.Value; } + + /// + /// A SQL Server time column is a time of day: 00:00:00.0000000 to 23:59:59.9999999. + /// Other stores are wider - a MySql TIME spans -838:59:59 to 838:59:59 and a PostgreSQL + /// interval is unbounded - so a value coming from one of them can be out of range here. + /// Fail with a message naming the value instead of letting it surface as an opaque + /// SqlClient/SQL Server error, and never silently truncate it: that would be data loss. + /// + private static object ValidateTimeOfDay(TimeSpan value) + { + if (value < TimeSpan.Zero || value >= TimeSpan.FromDays(1)) + { + throw new NotSupportedException( + $"Value '{value.ToString("c", CultureInfo.InvariantCulture)}' cannot be applied to a SQL Server 'time' column: " + + "it only holds a time of day, from 00:00:00.0000000 to 23:59:59.9999999. " + + "Map the source column to a wider type (for example bigint holding ticks) to synchronize it."); + } + + return value; + } } } diff --git a/src/CoreSync.SqlServerCT/Utils.cs b/src/CoreSync.SqlServerCT/Utils.cs index 11374c5..5ea8d98 100644 --- a/src/CoreSync.SqlServerCT/Utils.cs +++ b/src/CoreSync.SqlServerCT/Utils.cs @@ -21,9 +21,14 @@ public static object ConvertToSqlType(SyncItemValue value, SqlDbType dbType) //a time column travels as an invariant "c" formatted string (see SyncItemValue): it has no //dedicated SyncItemValueType and that is also how SQLite stores it - if (dbType == SqlDbType.Time && - value.Value is string timeValue) - return TimeSpan.Parse(timeValue, CultureInfo.InvariantCulture); + if (dbType == SqlDbType.Time) + { + if (value.Value is string timeValue) + return ValidateTimeOfDay(TimeSpan.Parse(timeValue, CultureInfo.InvariantCulture)); + + if (value.Value is TimeSpan timeSpanValue) + return ValidateTimeOfDay(timeSpanValue); + } //a tinyint column travels as an Int32 (or as a boolean, from MySql TINYINT(1)): SqlParameter //does not narrow it on its own @@ -33,5 +38,25 @@ public static object ConvertToSqlType(SyncItemValue value, SqlDbType dbType) return value.Value; } + + /// + /// A SQL Server time column is a time of day: 00:00:00.0000000 to 23:59:59.9999999. + /// Other stores are wider - a MySql TIME spans -838:59:59 to 838:59:59 and a PostgreSQL + /// interval is unbounded - so a value coming from one of them can be out of range here. + /// Fail with a message naming the value instead of letting it surface as an opaque + /// SqlClient/SQL Server error, and never silently truncate it: that would be data loss. + /// + private static object ValidateTimeOfDay(TimeSpan value) + { + if (value < TimeSpan.Zero || value >= TimeSpan.FromDays(1)) + { + throw new NotSupportedException( + $"Value '{value.ToString("c", CultureInfo.InvariantCulture)}' cannot be applied to a SQL Server 'time' column: " + + "it only holds a time of day, from 00:00:00.0000000 to 23:59:59.9999999. " + + "Map the source column to a wider type (for example bigint holding ticks) to synchronize it."); + } + + return value; + } } } diff --git a/src/CoreSync.Sqlite/SqliteSyncProvider.cs b/src/CoreSync.Sqlite/SqliteSyncProvider.cs index 95725a0..93d718e 100644 --- a/src/CoreSync.Sqlite/SqliteSyncProvider.cs +++ b/src/CoreSync.Sqlite/SqliteSyncProvider.cs @@ -497,7 +497,7 @@ public async Task GetChangesAsync(Guid otherStoreId, SyncFilterPa while (await r.ReadAsync(cancellationToken)) { var values = Enumerable.Range(0, r.FieldCount) - .ToDictionary(_ => r.GetName(_), _ => GetValueFromRecord(table, r.GetName(_), _, r)); + .ToDictionary(_ => r.GetName(_), _ => GetValueFromRecord(table, r.GetName(_), _, r, _logger)); items.Add(new SqliteSyncItem(table, ChangeType.Insert, values)); //snapshotItems.Add(values[table.PrimaryColumnName]); _logger?.Trace($"[{_storeId}] Initial snapshot {items.Last()}"); @@ -519,7 +519,7 @@ public async Task GetChangesAsync(Guid otherStoreId, SyncFilterPa { while (await r.ReadAsync(cancellationToken)) { - var values = Enumerable.Range(0, r.FieldCount).ToDictionary(_ => r.GetName(_), _ => GetValueFromRecord(table, r.GetName(_), _, r)); + var values = Enumerable.Range(0, r.FieldCount).ToDictionary(_ => r.GetName(_), _ => GetValueFromRecord(table, r.GetName(_), _, r, _logger)); //if (snapshotItems.Contains(values[table.PrimaryColumnName])) // continue; @@ -537,7 +537,7 @@ public async Task GetChangesAsync(Guid otherStoreId, SyncFilterPa { while (await r.ReadAsync(cancellationToken)) { - var values = Enumerable.Range(0, r.FieldCount).ToDictionary(_ => r.GetName(_), _ => GetValueFromRecord(table, r.GetName(_), _, r)); + var values = Enumerable.Range(0, r.FieldCount).ToDictionary(_ => r.GetName(_), _ => GetValueFromRecord(table, r.GetName(_), _, r, _logger)); items.Add(new SqliteSyncItem(table, ChangeType.Delete, values)); _logger?.Trace($"[{_storeId}] Incremental delete {items.Last()}"); } @@ -630,7 +630,7 @@ private static ChangeType DetectChangeType(Dictionary values) return ChangeType.Insert; } - private static object? GetValueFromRecord(SqliteSyncTable table, string columnName, int columnOrdinal, SqliteDataReader r) + private static object? GetValueFromRecord(SqliteSyncTable table, string columnName, int columnOrdinal, SqliteDataReader r, ISyncLogger? logger) { if (r.IsDBNull(columnOrdinal)) return null; @@ -640,7 +640,7 @@ private static ChangeType DetectChangeType(Dictionary values) var property = table.RecordType.GetProperty(columnName); if (property != null) - return GetValueFromRecord(r, columnOrdinal, property.PropertyType); + return GetValueFromRecord(r, columnOrdinal, property.PropertyType, logger); property = table.RecordType.GetProperties().FirstOrDefault(_ => { @@ -654,13 +654,13 @@ private static ChangeType DetectChangeType(Dictionary values) }); if (property != null) - return GetValueFromRecord(r, columnOrdinal, property.PropertyType); + return GetValueFromRecord(r, columnOrdinal, property.PropertyType, logger); //fallback to getvalue return r.GetValue(columnOrdinal); } - private static object GetValueFromRecord(SqliteDataReader r, int columnOrdinal, Type propertyType) + private static object GetValueFromRecord(SqliteDataReader r, int columnOrdinal, Type propertyType, ISyncLogger? logger) { //a nullable property is read exactly like its underlying type: the caller has already //excluded DBNull. Without this, every nullable column fell through to GetValue() and was @@ -669,6 +669,29 @@ private static object GetValueFromRecord(SqliteDataReader r, int columnOrdinal, //server sends down for the very same column. propertyType = Nullable.GetUnderlyingType(propertyType) ?? propertyType; + try + { + return GetTypedValueFromRecord(r, columnOrdinal, propertyType); + } + catch (Exception ex) when ( + ex is FormatException || + ex is InvalidCastException || + ex is OverflowException || + ex is ArgumentException) + { + //SQLite has no column types, only affinities, so a store written by something other + //than this library (or by an older version of the application) can hold a value the + //typed getter refuses to parse - a date in a legacy format, a decimal written as a + //REAL, and so on. Returning the raw stored value keeps such a store synchronizable, + //which is what this method did for every nullable column before typed reads were + //extended to them. + logger?.Warning($"Unable to read column {r.GetName(columnOrdinal)} as {propertyType}, falling back to the raw stored value: {ex.Message}"); + return r.GetValue(columnOrdinal); + } + } + + private static object GetTypedValueFromRecord(SqliteDataReader r, int columnOrdinal, Type propertyType) + { if (propertyType == typeof(string)) return r.GetString(columnOrdinal); if (propertyType == typeof(DateTime)) diff --git a/src/CoreSync.Tests/ColumnTypeMappingTests.MySqlPostgreSQL.cs b/src/CoreSync.Tests/ColumnTypeMappingTests.MySqlPostgreSQL.cs new file mode 100644 index 0000000..d650044 --- /dev/null +++ b/src/CoreSync.Tests/ColumnTypeMappingTests.MySqlPostgreSQL.cs @@ -0,0 +1,820 @@ +using CoreSync.MySql; +using CoreSync.PostgreSQL; +using CoreSync.Sqlite; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using MySqlConnector; +using Npgsql; +using Shouldly; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; + +namespace CoreSync.Tests; + +/// +/// The MySql and PostgreSQL half of : the same tinyint-like and +/// time-like round trips, plus the behaviours that are specific to those two stores. +/// +/// MySql reports a TINYINT(1) as a , a TINYINT as an +/// and a TINYINT UNSIGNED as a - three different CLR types +/// for one SQL type, and only one of them used to be carryable. +/// A MySql TIME spans -838:59:59 to 838:59:59 and a PostgreSQL interval +/// is unbounded, so both can hold values a SQL Server time column cannot. The "c" wire +/// format carries them; see ApplyChanges_TimeValueOutsideTimeOfDayRange_IsRejectedWithAClearMessage +/// for what happens when one is pushed into SQL Server anyway. +/// PostgreSQL's default CLR type for time without time zone is +/// TimeOnly, which has no and cannot even be named from +/// netstandard2.0, so the provider materializes those columns as a explicitly. +/// timetz and a month-bearing interval stay unsupported, on purpose. +/// +/// +public partial class ColumnTypeMappingTests +{ + private static string MySqlConnectionString => Environment.GetEnvironmentVariable("CORE-SYNC_MYSQL_CONNECTION_STRING") ?? + "Server=localhost;Port=3306;Database=coresync_test;User=root;Password=test123;GuidFormat=Char36"; + + private static string PostgreSQLConnectionString => Environment.GetEnvironmentVariable("CORE-SYNC_POSTGRESQL_CONNECTION_STRING") ?? + "Host=localhost;Port=5432;Database=coresync_test;Username=coresync;Password=test123"; + + private static string MySqlConnectionStringFor(string database) + => new MySqlConnectionStringBuilder(MySqlConnectionString) { Database = database }.ConnectionString; + + private static string PostgreSQLConnectionStringFor(string database) + => new NpgsqlConnectionStringBuilder(PostgreSQLConnectionString) { Database = database }.ConnectionString; + + #region Model + + /// + /// The MySql/PostgreSQL counterpart of . It carries no + /// column: how a Guid is spelled differs per store (MySql CHAR(36) uppercased, PostgreSQL + /// uuid) and that is unrelated to the types under test, which + /// already covers. + /// + public class Reading + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public byte StartMonth { get; set; } + public byte? EndMonth { get; set; } + public short Zone { get; set; } + public TimeSpan CountTime { get; set; } + public TimeSpan? Duration { get; set; } + public decimal Weight { get; set; } + public DateTime Recorded { get; set; } + } + + /// A single time-like column, used for the values a time of day cannot express. + public class Span + { + public int Id { get; set; } + public TimeSpan Value { get; set; } + } + + private sealed class ReadingDbContext : DbContext + { + private readonly string _connectionString; + + public ReadingDbContext(string connectionString) => _connectionString = connectionString; + + public DbSet Readings => Set(); + public DbSet Spans => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseSqlite(_connectionString); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.ToTable("Readings"); + entity.HasKey(_ => _.Id); + entity.Property(_ => _.Id).ValueGeneratedNever(); + entity.Property(_ => _.Weight).HasColumnType("TEXT"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("Spans"); + entity.HasKey(_ => _.Id); + entity.Property(_ => _.Id).ValueGeneratedNever(); + }); + } + } + + private const string SqliteReadingsDdl = @" + CREATE TABLE [Readings] ( + [Id] INTEGER NOT NULL PRIMARY KEY, + [Name] TEXT NOT NULL, + [StartMonth] INTEGER NOT NULL, + [EndMonth] INTEGER NULL, + [Zone] INTEGER NOT NULL, + [CountTime] TEXT NOT NULL, + [Duration] TEXT NULL, + [Weight] TEXT NOT NULL, + [Recorded] TEXT NOT NULL + ); + CREATE TABLE [Spans] ( + [Id] INTEGER NOT NULL PRIMARY KEY, + [Value] TEXT NOT NULL + );"; + + // MySql resolves TIME/DATETIME to microseconds at most, PostgreSQL likewise, while a TimeSpan tick is + // 100ns. Every value below is therefore a whole number of microseconds, so a mismatch means a real + // conversion bug rather than the store's declared resolution. + private static TimeSpan MicroDuration => new TimeSpan(0, 1, 2, 3).Add(TimeSpan.FromTicks(4567890)); + private static TimeSpan MicroUploadedDuration => new TimeSpan(0, 12, 34, 56).Add(TimeSpan.FromTicks(7891230)); + private static DateTime MicroRecorded => new DateTime(2026, 9, 15, 21, 21, 33).AddTicks(1234560); + private static DateTime MicroUploadedRecorded => new DateTime(2026, 2, 3, 4, 5, 6).AddTicks(7654320); + + #endregion + + #region Helpers - MySql + + private const string MySqlDdl = @" + CREATE TABLE `Readings` ( + `Id` INT NOT NULL PRIMARY KEY, + `Name` VARCHAR(100) NOT NULL, + `StartMonth` TINYINT UNSIGNED NOT NULL, + `EndMonth` TINYINT UNSIGNED NULL, + `Zone` SMALLINT NOT NULL, + `CountTime` TIME NOT NULL, + `Duration` TIME(6) NULL, + `Weight` DECIMAL(10,3) NOT NULL, + `Recorded` DATETIME(6) NOT NULL + ); + CREATE TABLE `Spans` ( + `Id` INT NOT NULL PRIMARY KEY, + `Value` TIME NOT NULL + ); + CREATE TABLE `TinyIntFlavors` ( + `Id` INT NOT NULL PRIMARY KEY, + `Flag` TINYINT(1) NOT NULL, + `Signed` TINYINT NOT NULL, + `Unsigned` TINYINT UNSIGNED NOT NULL + );"; + + private static async Task ExecuteMySqlNonQuery(string connectionString, string commandText) + { + using var conn = new MySqlConnection(connectionString); + await conn.OpenAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = commandText; + await cmd.ExecuteNonQueryAsync(); + } + + private static async Task CreateMySqlDatabase(string dbName) + { + MySqlConnection.ClearAllPools(); + using var conn = new MySqlConnection(MySqlConnectionString); + await conn.OpenAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = $"DROP DATABASE IF EXISTS `{dbName}`; CREATE DATABASE `{dbName}`;"; + await cmd.ExecuteNonQueryAsync(); + } + + private static async Task DropMySqlDatabase(string dbName) + { + MySqlConnection.ClearAllPools(); + using var conn = new MySqlConnection(MySqlConnectionString); + await conn.OpenAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = $"DROP DATABASE IF EXISTS `{dbName}`;"; + await cmd.ExecuteNonQueryAsync(); + } + + private static async Task ReadReadingFromMySql(string connectionString, int id) + { + using var conn = new MySqlConnection(connectionString); + await conn.OpenAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT `Name`,`StartMonth`,`EndMonth`,`Zone`,`CountTime`,`Duration`,`Weight`,`Recorded` FROM `Readings` WHERE `Id` = @id"; + cmd.Parameters.AddWithValue("@id", id); + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) + return null; + + return new Reading + { + Id = id, + Name = reader.GetString(0), + // GetByte/GetTimeSpan: the column must really be a tinyint/time on arrival. + StartMonth = reader.GetByte(1), + EndMonth = reader.IsDBNull(2) ? null : reader.GetByte(2), + Zone = reader.GetInt16(3), + CountTime = reader.GetTimeSpan(4), + Duration = reader.IsDBNull(5) ? null : reader.GetTimeSpan(5), + Weight = reader.GetDecimal(6), + Recorded = reader.GetDateTime(7) + }; + } + + private static async Task ReadSpanFromMySql(string connectionString, int id) + { + using var conn = new MySqlConnection(connectionString); + await conn.OpenAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT `Value` FROM `Spans` WHERE `Id` = @id"; + cmd.Parameters.AddWithValue("@id", id); + using var reader = await cmd.ExecuteReaderAsync(); + (await reader.ReadAsync()).ShouldBeTrue($"row {id} must exist in Spans"); + return reader.GetTimeSpan(0); + } + + private static MySqlSyncProvider CreateMySqlProvider(string connectionString, params string[] tables) + { + var builder = new MySqlSyncConfigurationBuilder(connectionString); + foreach (var table in tables) + builder.Table(table); + return new MySqlSyncProvider(builder.Build(), logger: new ConsoleLogger("MYSQL")); + } + + #endregion + + #region Helpers - PostgreSQL + + // Identifiers are quoted so PostgreSQL keeps their case: the column names have to match the ones the + // SQLite peer sends, and an unquoted identifier would be folded to lower case. + private const string PostgreSQLDdl = @" + CREATE TABLE ""Readings"" ( + ""Id"" INTEGER NOT NULL PRIMARY KEY, + ""Name"" TEXT NOT NULL, + ""StartMonth"" SMALLINT NOT NULL, + ""EndMonth"" SMALLINT NULL, + ""Zone"" SMALLINT NOT NULL, + ""CountTime"" TIME(6) NOT NULL, + ""Duration"" TIME(6) NULL, + ""Weight"" NUMERIC(10,3) NOT NULL, + ""Recorded"" TIMESTAMP NOT NULL + ); + CREATE TABLE ""Spans"" ( + ""Id"" INTEGER NOT NULL PRIMARY KEY, + ""Value"" INTERVAL NOT NULL + );"; + + private static async Task ExecutePostgreSQLNonQuery(string connectionString, string commandText) + { + using var conn = new NpgsqlConnection(connectionString); + await conn.OpenAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = commandText; + await cmd.ExecuteNonQueryAsync(); + } + + private static async Task CreatePostgreSQLDatabase(string dbName) + { + NpgsqlConnection.ClearAllPools(); + using var conn = new NpgsqlConnection(PostgreSQLConnectionString); + await conn.OpenAsync(); + using (var drop = conn.CreateCommand()) + { + drop.CommandText = $"DROP DATABASE IF EXISTS \"{dbName}\" WITH (FORCE)"; + await drop.ExecuteNonQueryAsync(); + } + using var create = conn.CreateCommand(); + create.CommandText = $"CREATE DATABASE \"{dbName}\""; + await create.ExecuteNonQueryAsync(); + } + + private static async Task DropPostgreSQLDatabase(string dbName) + { + NpgsqlConnection.ClearAllPools(); + using var conn = new NpgsqlConnection(PostgreSQLConnectionString); + await conn.OpenAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = $"DROP DATABASE IF EXISTS \"{dbName}\" WITH (FORCE)"; + await cmd.ExecuteNonQueryAsync(); + } + + private static async Task ReadReadingFromPostgreSQL(string connectionString, int id) + { + using var conn = new NpgsqlConnection(connectionString); + await conn.OpenAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"SELECT ""Name"",""StartMonth"",""EndMonth"",""Zone"",""CountTime"",""Duration"",""Weight"",""Recorded"" FROM ""Readings"" WHERE ""Id"" = $1"; + cmd.Parameters.Add(new NpgsqlParameter { Value = id }); + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) + return null; + + return new Reading + { + Id = id, + Name = reader.GetString(0), + StartMonth = (byte)reader.GetInt16(1), + EndMonth = reader.IsDBNull(2) ? null : (byte)reader.GetInt16(2), + Zone = reader.GetInt16(3), + // GetFieldValue: a 'time without time zone' column materializes as TimeOnly by + // default, so asking for a TimeSpan proves the column really is a time. + CountTime = reader.GetFieldValue(4), + Duration = reader.IsDBNull(5) ? null : reader.GetFieldValue(5), + Weight = reader.GetDecimal(6), + Recorded = reader.GetDateTime(7) + }; + } + + private static async Task ReadSpanFromPostgreSQL(string connectionString, int id) + { + using var conn = new NpgsqlConnection(connectionString); + await conn.OpenAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"SELECT ""Value"" FROM ""Spans"" WHERE ""Id"" = $1"; + cmd.Parameters.Add(new NpgsqlParameter { Value = id }); + using var reader = await cmd.ExecuteReaderAsync(); + (await reader.ReadAsync()).ShouldBeTrue($"row {id} must exist in Spans"); + return reader.GetFieldValue(0); + } + + private static PostgreSQLSyncProvider CreatePostgreSQLProvider(string connectionString, params string[] tables) + { + var builder = new PostgreSQLSyncConfigurationBuilder(connectionString); + foreach (var table in tables) + builder.Table(table); + return new PostgreSQLSyncProvider(builder.Build(), logger: new ConsoleLogger("PGSQL")); + } + + #endregion + + #region Helpers - SQLite side of these tests + + private static SqliteSyncProvider CreateSqliteReadingsProvider(string connectionString) + => new(new SqliteSyncConfigurationBuilder(connectionString) + .Table("Readings") + .Table("Spans") + .Build(), + logger: new ConsoleLogger("LOC")); + + #endregion + + #region MySql ⇄ SQLite round trips + + private static async Task RunMySqlRoundTrip(string testName, Transport transport) + { + var dbName = $"coresync_types_{testName.ToLowerInvariant()}"; + var sqliteFile = CreateSqliteFile(testName); + var sqliteConnStr = $"Data Source={sqliteFile}"; + + SyncTestServer? server = null; + + try + { + await CreateMySqlDatabase(dbName); + var mysqlConnStr = MySqlConnectionStringFor(dbName); + await ExecuteMySqlNonQuery(mysqlConnStr, MySqlDdl); + await ExecuteSqliteNonQuery(sqliteConnStr, SqliteReadingsDdl); + + var remoteProvider = CreateMySqlProvider(mysqlConnStr, "Readings", "Spans"); + await remoteProvider.ApplyProvisionAsync(); + + var localProvider = CreateSqliteReadingsProvider(sqliteConnStr); + await localProvider.ApplyProvisionAsync(); + + (var remote, server) = Wrap(remoteProvider, transport); + var agent = new SyncAgent(localProvider, remote); + + // ---------- MySql → SQLite ---------- + + await ExecuteMySqlNonQuery(mysqlConnStr, @" + INSERT INTO `Readings` (`Id`,`Name`,`StartMonth`,`EndMonth`,`Zone`,`CountTime`,`Duration`,`Weight`,`Recorded`) + VALUES + (1, 'Spring', 3, 250, -5, '14:30:00', '01:02:03.456789', 12.345, '2026-09-15 21:21:33.123456'), + (2, 'Winter', 0, NULL, 32767, '00:00:00', NULL, 0.001, '2026-01-01 00:00:00.000000'); + INSERT INTO `Spans` (`Id`,`Value`) VALUES + (1, '100:00:00'), + (2, '-01:02:03'), + (3, '838:59:59');"); + + await agent.SynchronizeAsync(); + + { + using var db = new ReadingDbContext(sqliteConnStr); + + var full = await db.Readings.SingleAsync(_ => _.Id == 1); + full.StartMonth.ShouldBe((byte)3); + // 250 is out of range for the sbyte MySql reports a signed TINYINT as: it proves the + // value was widened, not reinterpreted. + full.EndMonth.ShouldBe((byte)250); + full.Zone.ShouldBe((short)-5); + full.CountTime.ShouldBe(new TimeSpan(14, 30, 0)); + full.Duration.ShouldBe(MicroDuration); + full.Weight.ShouldBe(12.345m); + full.Recorded.ShouldBe(MicroRecorded); + + var nulls = await db.Readings.SingleAsync(_ => _.Id == 2); + nulls.EndMonth.ShouldBeNull(); + nulls.Duration.ShouldBeNull(); + nulls.CountTime.ShouldBe(TimeSpan.Zero); + + // A MySql TIME is not a time of day, and the "c" wire format carries all of it. + (await db.Spans.SingleAsync(_ => _.Id == 1)).Value.ShouldBe(TimeSpan.FromHours(100)); + (await db.Spans.SingleAsync(_ => _.Id == 2)).Value.ShouldBe(new TimeSpan(0, -1, -2, -3)); + (await db.Spans.SingleAsync(_ => _.Id == 3)).Value.ShouldBe(new TimeSpan(838, 59, 59)); + } + + // ---------- SQLite → MySql ---------- + + { + using var db = new ReadingDbContext(sqliteConnStr); + db.Readings.Add(new Reading + { + Id = 100, + Name = "Uploaded", + StartMonth = 255, + EndMonth = 1, + Zone = -32768, + CountTime = new TimeSpan(23, 59, 59), + Duration = MicroUploadedDuration, + Weight = 999.999m, + Recorded = MicroUploadedRecorded + }); + db.Readings.Add(new Reading + { + Id = 101, + Name = "UploadedNulls", + StartMonth = 0, + EndMonth = null, + Zone = 1, + CountTime = TimeSpan.Zero, + Duration = null, + Weight = 0m, + Recorded = new DateTime(2026, 3, 4, 5, 6, 7) + }); + db.Spans.Add(new Span { Id = 100, Value = TimeSpan.FromHours(-100) }); + db.Spans.Add(new Span { Id = 101, Value = new TimeSpan(500, 0, 0) }); + await db.SaveChangesAsync(); + } + + await agent.SynchronizeAsync(); + + var uploaded = await ReadReadingFromMySql(mysqlConnStr, 100); + uploaded.ShouldNotBeNull(); + uploaded.StartMonth.ShouldBe((byte)255); + uploaded.EndMonth.ShouldBe((byte)1); + uploaded.Zone.ShouldBe((short)-32768); + uploaded.CountTime.ShouldBe(new TimeSpan(23, 59, 59)); + uploaded.Duration.ShouldBe(MicroUploadedDuration); + uploaded.Weight.ShouldBe(999.999m); + uploaded.Recorded.ShouldBe(MicroUploadedRecorded); + + var uploadedNulls = await ReadReadingFromMySql(mysqlConnStr, 101); + uploadedNulls.ShouldNotBeNull(); + uploadedNulls.StartMonth.ShouldBe((byte)0); + uploadedNulls.EndMonth.ShouldBeNull(); + uploadedNulls.Duration.ShouldBeNull(); + + (await ReadSpanFromMySql(mysqlConnStr, 100)).ShouldBe(TimeSpan.FromHours(-100)); + (await ReadSpanFromMySql(mysqlConnStr, 101)).ShouldBe(new TimeSpan(500, 0, 0)); + } + finally + { + server?.Dispose(); + DeleteSqliteFile(sqliteFile); + await DropMySqlDatabase(dbName); + } + } + + [TestMethod] + public Task MySqlAndSqlite_RoundTripTinyIntAndTime_Direct() + => RunMySqlRoundTrip("MySqlDirect", Transport.Direct); + + [TestMethod] + public Task MySqlAndSqlite_RoundTripTinyIntAndTime_HttpJson() + => RunMySqlRoundTrip("MySqlHttpJson", Transport.HttpJson); + + [TestMethod] + public Task MySqlAndSqlite_RoundTripTinyIntAndTime_HttpBinary() + => RunMySqlRoundTrip("MySqlHttpBinary", Transport.HttpBinary); + + /// + /// One SQL type, three CLR types: MySqlConnector reports TINYINT(1) as a , + /// TINYINT as an and TINYINT UNSIGNED as a . + /// Only the first was carryable before; all three must be now, and each must land on the + /// that keeps its value intact. + /// + [TestMethod] + public async Task MySql_ReportsEveryTinyIntFlavourWithACarryableType() + { + var dbName = "coresync_types_tinyintflavors"; + + try + { + await CreateMySqlDatabase(dbName); + var connStr = MySqlConnectionStringFor(dbName); + await ExecuteMySqlNonQuery(connStr, MySqlDdl); + + var provider = CreateMySqlProvider(connStr, "TinyIntFlavors"); + await provider.ApplyProvisionAsync(); + + await ExecuteMySqlNonQuery(connStr, @" + INSERT INTO `TinyIntFlavors` (`Id`,`Flag`,`Signed`,`Unsigned`) VALUES + (1, 1, -128, 255), + (2, 0, 127, 0);"); + + var changes = await provider.GetChangesAsync(Guid.NewGuid()); + changes.Items.Count.ShouldBe(2); + + var first = changes.Items.Single(_ => Convert.ToInt32(_.Values["Id"].Value) == 1).Values; + first["Flag"].Type.ShouldBe(SyncItemValueType.Boolean, "MySql reports TINYINT(1) as a bool"); + first["Flag"].Value.ShouldBe(true); + first["Signed"].Type.ShouldBe(SyncItemValueType.Int32, "MySql reports TINYINT as an sbyte"); + first["Signed"].Value.ShouldBeOfType().ShouldBe(-128); + first["Unsigned"].Type.ShouldBe(SyncItemValueType.Int32, "MySql reports TINYINT UNSIGNED as a byte"); + first["Unsigned"].Value.ShouldBeOfType().ShouldBe(255); + + var second = changes.Items.Single(_ => Convert.ToInt32(_.Values["Id"].Value) == 2).Values; + second["Flag"].Value.ShouldBe(false); + second["Signed"].Value.ShouldBe(127); + second["Unsigned"].Value.ShouldBe(0); + } + finally + { + await DropMySqlDatabase(dbName); + } + } + + #endregion + + #region PostgreSQL ⇄ SQLite round trips + + private static async Task RunPostgreSQLRoundTrip(string testName, Transport transport) + { + var dbName = $"coresync_types_{testName.ToLowerInvariant()}"; + var sqliteFile = CreateSqliteFile(testName); + var sqliteConnStr = $"Data Source={sqliteFile}"; + + SyncTestServer? server = null; + + try + { + await CreatePostgreSQLDatabase(dbName); + var pgConnStr = PostgreSQLConnectionStringFor(dbName); + await ExecutePostgreSQLNonQuery(pgConnStr, PostgreSQLDdl); + await ExecuteSqliteNonQuery(sqliteConnStr, SqliteReadingsDdl); + + var remoteProvider = CreatePostgreSQLProvider(pgConnStr, "Readings", "Spans"); + await remoteProvider.ApplyProvisionAsync(); + + var localProvider = CreateSqliteReadingsProvider(sqliteConnStr); + await localProvider.ApplyProvisionAsync(); + + (var remote, server) = Wrap(remoteProvider, transport); + var agent = new SyncAgent(localProvider, remote); + + // ---------- PostgreSQL → SQLite ---------- + + await ExecutePostgreSQLNonQuery(pgConnStr, @" + INSERT INTO ""Readings"" (""Id"",""Name"",""StartMonth"",""EndMonth"",""Zone"",""CountTime"",""Duration"",""Weight"",""Recorded"") + VALUES + (1, 'Spring', 3, 250, -5, TIME '14:30:00', TIME '01:02:03.456789', 12.345, TIMESTAMP '2026-09-15 21:21:33.123456'), + (2, 'Winter', 0, NULL, 32767, TIME '00:00:00', NULL, 0.001, TIMESTAMP '2026-01-01 00:00:00'); + INSERT INTO ""Spans"" (""Id"",""Value"") VALUES + (1, INTERVAL '100 hours'), + (2, INTERVAL '-1 hour -2 minutes -3 seconds'), + (3, INTERVAL '2 days 3 hours');"); + + await agent.SynchronizeAsync(); + + { + using var db = new ReadingDbContext(sqliteConnStr); + + var full = await db.Readings.SingleAsync(_ => _.Id == 1); + full.StartMonth.ShouldBe((byte)3); + full.EndMonth.ShouldBe((byte)250); + full.Zone.ShouldBe((short)-5); + // PostgreSQL materializes a 'time without time zone' as a TimeOnly by default, which has + // no SyncItemValueType: the provider has to ask for a TimeSpan explicitly. + full.CountTime.ShouldBe(new TimeSpan(14, 30, 0)); + full.Duration.ShouldBe(MicroDuration); + full.Weight.ShouldBe(12.345m); + full.Recorded.ShouldBe(MicroRecorded); + + var nulls = await db.Readings.SingleAsync(_ => _.Id == 2); + nulls.EndMonth.ShouldBeNull(); + nulls.Duration.ShouldBeNull(); + + (await db.Spans.SingleAsync(_ => _.Id == 1)).Value.ShouldBe(TimeSpan.FromHours(100)); + (await db.Spans.SingleAsync(_ => _.Id == 2)).Value.ShouldBe(new TimeSpan(0, -1, -2, -3)); + (await db.Spans.SingleAsync(_ => _.Id == 3)).Value.ShouldBe(new TimeSpan(2, 3, 0, 0)); + } + + // ---------- SQLite → PostgreSQL ---------- + + { + using var db = new ReadingDbContext(sqliteConnStr); + db.Readings.Add(new Reading + { + Id = 100, + Name = "Uploaded", + StartMonth = 255, + EndMonth = 1, + Zone = -32768, + CountTime = new TimeSpan(23, 59, 59), + Duration = MicroUploadedDuration, + Weight = 999.999m, + Recorded = MicroUploadedRecorded + }); + db.Readings.Add(new Reading + { + Id = 101, + Name = "UploadedNulls", + StartMonth = 0, + EndMonth = null, + Zone = 1, + CountTime = TimeSpan.Zero, + Duration = null, + Weight = 0m, + Recorded = new DateTime(2026, 3, 4, 5, 6, 7) + }); + db.Spans.Add(new Span { Id = 100, Value = TimeSpan.FromHours(-100) }); + db.Spans.Add(new Span { Id = 101, Value = new TimeSpan(500, 0, 0) }); + await db.SaveChangesAsync(); + } + + await agent.SynchronizeAsync(); + + var uploaded = await ReadReadingFromPostgreSQL(pgConnStr, 100); + uploaded.ShouldNotBeNull(); + uploaded.StartMonth.ShouldBe((byte)255); + uploaded.EndMonth.ShouldBe((byte)1); + uploaded.Zone.ShouldBe((short)-32768); + uploaded.CountTime.ShouldBe(new TimeSpan(23, 59, 59)); + uploaded.Duration.ShouldBe(MicroUploadedDuration); + uploaded.Weight.ShouldBe(999.999m); + uploaded.Recorded.ShouldBe(MicroUploadedRecorded); + + var uploadedNulls = await ReadReadingFromPostgreSQL(pgConnStr, 101); + uploadedNulls.ShouldNotBeNull(); + uploadedNulls.StartMonth.ShouldBe((byte)0); + uploadedNulls.EndMonth.ShouldBeNull(); + uploadedNulls.Duration.ShouldBeNull(); + + (await ReadSpanFromPostgreSQL(pgConnStr, 100)).ShouldBe(TimeSpan.FromHours(-100)); + (await ReadSpanFromPostgreSQL(pgConnStr, 101)).ShouldBe(new TimeSpan(500, 0, 0)); + } + finally + { + server?.Dispose(); + DeleteSqliteFile(sqliteFile); + await DropPostgreSQLDatabase(dbName); + } + } + + [TestMethod] + public Task PostgreSQLAndSqlite_RoundTripSmallIntAndTime_Direct() + => RunPostgreSQLRoundTrip("PgDirect", Transport.Direct); + + [TestMethod] + public Task PostgreSQLAndSqlite_RoundTripSmallIntAndTime_HttpJson() + => RunPostgreSQLRoundTrip("PgHttpJson", Transport.HttpJson); + + [TestMethod] + public Task PostgreSQLAndSqlite_RoundTripSmallIntAndTime_HttpBinary() + => RunPostgreSQLRoundTrip("PgHttpBinary", Transport.HttpBinary); + + /// + /// The two PostgreSQL shapes that stay unsupported, asserted so the boundary is explicit rather than + /// implied: a timetz materializes as a , and an interval carrying + /// months has no fixed length so it cannot become a at all. + /// + [TestMethod] + public async Task PostgreSQL_TimetzAndMonthBearingIntervalsRemainUnsupported() + { + var dbName = "coresync_types_pgunsupported"; + + try + { + await CreatePostgreSQLDatabase(dbName); + var connStr = PostgreSQLConnectionStringFor(dbName); + await ExecutePostgreSQLNonQuery(connStr, @" + CREATE TABLE ""WithTimetz"" ( + ""Id"" INTEGER NOT NULL PRIMARY KEY, + ""Moment"" TIMETZ NOT NULL + ); + CREATE TABLE ""Spans"" ( + ""Id"" INTEGER NOT NULL PRIMARY KEY, + ""Value"" INTERVAL NOT NULL + );"); + + var provider = CreatePostgreSQLProvider(connStr, "WithTimetz", "Spans"); + await provider.ApplyProvisionAsync(); + + await ExecutePostgreSQLNonQuery(connStr, @" + INSERT INTO ""WithTimetz"" (""Id"",""Moment"") VALUES (1, TIMETZ '14:30:00+02');"); + + var timetzFailure = await Should.ThrowAsync(() => provider.GetChangesAsync(Guid.NewGuid())); + var notSupported = FindInChain(timetzFailure); + notSupported.ShouldNotBeNull("a timetz column has no SyncItemValueType and must say so"); + notSupported.Message.ShouldContain("DateTimeOffset"); + + await ExecutePostgreSQLNonQuery(connStr, @"DELETE FROM ""WithTimetz""; DELETE FROM __core_sync_ct;"); + await ExecutePostgreSQLNonQuery(connStr, @"INSERT INTO ""Spans"" (""Id"",""Value"") VALUES (1, INTERVAL '1 month');"); + + // A month has no fixed length, so Npgsql refuses to hand it over as a TimeSpan. The failure is + // Npgsql's, not this library's, and it is the correct answer: there is nothing to carry. + await Should.ThrowAsync(() => provider.GetChangesAsync(Guid.NewGuid())); + } + finally + { + await DropPostgreSQLDatabase(dbName); + } + } + + #endregion + + #region Old peer compatibility - MySql and PostgreSQL + + /// + /// Backward compatibility, old client → new server, for the two providers that were never able to + /// carry these columns at all. The payload is the one a released SQLite client produces: a nullable + /// byte column read with GetValue() arrives as an and a + /// nullable TimeSpan column as the raw stored text. + /// + [TestMethod] + public async Task OldClientPayload_IsStillAcceptedByMySql() + { + var dbName = "coresync_types_oldpeer_mysql"; + + try + { + await CreateMySqlDatabase(dbName); + var connStr = MySqlConnectionStringFor(dbName); + await ExecuteMySqlNonQuery(connStr, MySqlDdl); + + var provider = CreateMySqlProvider(connStr, "Readings", "Spans"); + await provider.ApplyProvisionAsync(); + + await provider.ApplyChangesAsync(OldClientReadingChangeSet()); + + var applied = await ReadReadingFromMySql(connStr, 1); + applied.ShouldNotBeNull(); + applied.StartMonth.ShouldBe((byte)3); + applied.EndMonth.ShouldBe((byte)250); + applied.Zone.ShouldBe((short)-5); + applied.CountTime.ShouldBe(new TimeSpan(14, 30, 0)); + applied.Duration.ShouldBe(MicroDuration); + applied.Weight.ShouldBe(12.345m); + applied.Recorded.ShouldBe(MicroRecorded); + } + finally + { + await DropMySqlDatabase(dbName); + } + } + + [TestMethod] + public async Task OldClientPayload_IsStillAcceptedByPostgreSQL() + { + var dbName = "coresync_types_oldpeer_pg"; + + try + { + await CreatePostgreSQLDatabase(dbName); + var connStr = PostgreSQLConnectionStringFor(dbName); + await ExecutePostgreSQLNonQuery(connStr, PostgreSQLDdl); + + var provider = CreatePostgreSQLProvider(connStr, "Readings", "Spans"); + await provider.ApplyProvisionAsync(); + + await provider.ApplyChangesAsync(OldClientReadingChangeSet()); + + var applied = await ReadReadingFromPostgreSQL(connStr, 1); + applied.ShouldNotBeNull(); + applied.StartMonth.ShouldBe((byte)3); + applied.EndMonth.ShouldBe((byte)250); + applied.Zone.ShouldBe((short)-5); + applied.CountTime.ShouldBe(new TimeSpan(14, 30, 0)); + applied.Duration.ShouldBe(MicroDuration); + applied.Weight.ShouldBe(12.345m); + applied.Recorded.ShouldBe(MicroRecorded); + } + finally + { + await DropPostgreSQLDatabase(dbName); + } + } + + private static SyncChangeSet OldClientReadingChangeSet() + => new( + new SyncAnchor(Guid.NewGuid(), 1), + SyncAnchor.Null, + new[] + { + RawItem("Readings", ChangeType.Insert, new Dictionary + { + ["Id"] = Raw(SyncItemValueType.Int64, 1L), + ["Name"] = Raw(SyncItemValueType.String, "old client"), + ["StartMonth"] = Raw(SyncItemValueType.Int64, 3L), + ["EndMonth"] = Raw(SyncItemValueType.Int64, 250L), + ["Zone"] = Raw(SyncItemValueType.Int64, -5L), + ["CountTime"] = Raw(SyncItemValueType.String, "14:30:00"), + ["Duration"] = Raw(SyncItemValueType.String, MicroDuration.ToString("c", CultureInfo.InvariantCulture)), + ["Weight"] = Raw(SyncItemValueType.Decimal, 12.345m), + ["Recorded"] = Raw(SyncItemValueType.DateTime, MicroRecorded) + }) + }); + + #endregion +} diff --git a/src/CoreSync.Tests/ColumnTypeMappingTests.cs b/src/CoreSync.Tests/ColumnTypeMappingTests.cs index fe16f87..7224bd5 100644 --- a/src/CoreSync.Tests/ColumnTypeMappingTests.cs +++ b/src/CoreSync.Tests/ColumnTypeMappingTests.cs @@ -1,6 +1,7 @@ using CoreSync.Http; using CoreSync.Sqlite; using CoreSync.SqlServer; +using CoreSync.SqlServerCT; using Microsoft.Data.SqlClient; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; @@ -10,6 +11,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Linq; using System.Text.Json; using System.Threading.Tasks; @@ -21,20 +23,23 @@ namespace CoreSync.Tests; /// NotSupportedException: Type of value ('System.Byte') is not supported for synchronization /// as soon as a synchronized table contained a SQL Server tinyint or time column: /// -/// tinyint is read as and must travel as an -/// , then be narrowed back to a byte when applied; -/// time is read as and must travel as an invariant -/// "c" formatted - the very format both -/// Microsoft.Data.Sqlite and EF Core use to store a - then be parsed back; +/// a tinyint-like column (SQL Server/MySql tinyint) is read as a +/// // and must travel as an +/// , then be narrowed back when applied; +/// a time-like column (SQL Server/MySql time, PostgreSQL time and +/// interval) is read as a and must travel as an invariant "c" +/// formatted - the very format both Microsoft.Data.Sqlite and +/// EF Core use to store a - then be parsed back; /// no new member may appear, or a peer running an /// older version of the library would throw on the payload. /// /// The round trips also carry smallint, decimal, uniqueidentifier and /// datetime2 columns, both null and non-null, so that the normalization cannot regress the -/// types that already worked. +/// types that already worked. The MySql and PostgreSQL halves live in +/// ColumnTypeMappingTests.MySqlPostgreSQL.cs. /// [TestClass] -public class ColumnTypeMappingTests +public partial class ColumnTypeMappingTests { private static string SqlServerConnectionString => Environment.GetEnvironmentVariable("CORE-SYNC_CONNECTION_STRING") ?? "Server=localhost;User Id=sa;Password=CoreSync_Test123!;TrustServerCertificate=True"; @@ -62,6 +67,28 @@ public class Measurement public DateTime Recorded { get; set; } } + /// + /// One nullable column per CLR type the SQLite provider now reads through a typed getter, so that + /// extending typed reads to nullable columns cannot regress a type that used to go through + /// GetValue(). + /// + public class NullableProbe + { + public int Id { get; set; } + public DateTime? Moment { get; set; } + public decimal? Amount { get; set; } + public Guid? Reference { get; set; } + public bool? Flag { get; set; } + public double? Ratio { get; set; } + public float? Single { get; set; } + public int? Counter { get; set; } + public long? Big { get; set; } + public short? Tiny { get; set; } + public byte? Small { get; set; } + public TimeSpan? Elapsed { get; set; } + public string? Label { get; set; } + } + private sealed class MeasurementDbContext : DbContext { private readonly string _connectionString; @@ -69,6 +96,7 @@ private sealed class MeasurementDbContext : DbContext public MeasurementDbContext(string connectionString) => _connectionString = connectionString; public DbSet Measurements => Set(); + public DbSet NullableProbes => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder.UseSqlite(_connectionString); @@ -82,6 +110,14 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(_ => _.Id).ValueGeneratedNever(); entity.Property(_ => _.Weight).HasColumnType("TEXT"); }); + + modelBuilder.Entity(entity => + { + entity.ToTable("NullableProbes"); + entity.HasKey(_ => _.Id); + entity.Property(_ => _.Id).ValueGeneratedNever(); + entity.Property(_ => _.Amount).HasColumnType("TEXT"); + }); } } @@ -97,6 +133,21 @@ [Duration] TIME(7) NULL, [Weight] DECIMAL(10,3) NOT NULL, [ExternalId] UNIQUEIDENTIFIER NOT NULL, [Recorded] DATETIME2 NOT NULL + ); + CREATE TABLE [dbo].[NullableProbes] ( + [Id] INT NOT NULL PRIMARY KEY, + [Moment] DATETIME2 NULL, + [Amount] DECIMAL(18,4) NULL, + [Reference] UNIQUEIDENTIFIER NULL, + [Flag] BIT NULL, + [Ratio] FLOAT NULL, + [Single] REAL NULL, + [Counter] INT NULL, + [Big] BIGINT NULL, + [Tiny] SMALLINT NULL, + [Small] TINYINT NULL, + [Elapsed] TIME(7) NULL, + [Label] NVARCHAR(50) NULL );"; // Declared types are the ones EF Core's SQLite provider generates for the model above. @@ -112,11 +163,42 @@ CREATE TABLE [Measurements] ( [Weight] TEXT NOT NULL, [ExternalId] TEXT NOT NULL, [Recorded] TEXT NOT NULL + ); + CREATE TABLE [NullableProbes] ( + [Id] INTEGER NOT NULL PRIMARY KEY, + [Moment] TEXT NULL, + [Amount] TEXT NULL, + [Reference] TEXT NULL, + [Flag] INTEGER NULL, + [Ratio] REAL NULL, + [Single] REAL NULL, + [Counter] INTEGER NULL, + [Big] INTEGER NULL, + [Tiny] INTEGER NULL, + [Small] INTEGER NULL, + [Elapsed] TEXT NULL, + [Label] TEXT NULL );"; #endregion - #region Helpers + #region Shared values + + private static readonly Guid DownloadedGuid = new("0f8fad5b-d9cb-469f-a165-70867728950e"); + private static readonly Guid UploadedGuid = new("7c9e6679-7425-40de-944b-e07fc1f90ae7"); + + /// 01:02:03.4567890 - seven fractional digits, the full resolution of a TimeSpan tick. + private static TimeSpan TickPreciseDuration => new TimeSpan(0, 1, 2, 3).Add(TimeSpan.FromTicks(4567890)); + + /// 12:34:56.7891234 - the value uploaded back from SQLite. + private static TimeSpan UploadedDuration => new TimeSpan(0, 12, 34, 56).Add(TimeSpan.FromTicks(7891234)); + + private static DateTime DownloadedRecorded => new DateTime(2026, 9, 15, 21, 21, 33).AddTicks(1234567); + private static DateTime UploadedRecorded => new DateTime(2026, 2, 3, 4, 5, 6).AddTicks(7654321); + + #endregion + + #region Helpers - SQL Server private static async Task ExecuteSqlServerNonQuery(string connectionString, string commandText) { @@ -127,6 +209,16 @@ private static async Task ExecuteSqlServerNonQuery(string connectionString, stri await cmd.ExecuteNonQueryAsync(); } + private static async Task ExecuteSqlServerScalar(string connectionString, string commandText) + { + using var conn = new SqlConnection(connectionString); + await conn.OpenAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = commandText; + var res = await cmd.ExecuteScalarAsync(); + return res == DBNull.Value ? null : res; + } + private static async Task CreateSqlServerDatabase(string dbName) { using var conn = new SqlConnection(SqlServerConnectionStringFor("master")); @@ -156,16 +248,7 @@ IF DB_ID('{dbName}') IS NOT NULL await cmd.ExecuteNonQueryAsync(); } - private static async Task CreateSqliteDatabase(string connectionString) - { - using var conn = new SqliteConnection(connectionString); - await conn.OpenAsync(); - using var cmd = conn.CreateCommand(); - cmd.CommandText = SqliteDdl; - await cmd.ExecuteNonQueryAsync(); - } - - private static async Task ReadFromSqlServer(string connectionString, int id) + private static async Task ReadMeasurementFromSqlServer(string connectionString, int id) { using var conn = new SqlConnection(connectionString); await conn.OpenAsync(); @@ -194,25 +277,362 @@ private static async Task CreateSqliteDatabase(string connectionString) }; } - private enum Transport + private static async Task ReadProbeFromSqlServer(string connectionString, int id) + { + using var conn = new SqlConnection(connectionString); + await conn.OpenAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"SELECT [Moment],[Amount],[Reference],[Flag],[Ratio],[Single],[Counter],[Big],[Tiny],[Small],[Elapsed],[Label] +FROM [dbo].[NullableProbes] WHERE [Id] = @id"; + cmd.Parameters.AddWithValue("@id", id); + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) + return null; + + return new NullableProbe + { + Id = id, + Moment = reader.IsDBNull(0) ? null : reader.GetDateTime(0), + Amount = reader.IsDBNull(1) ? null : reader.GetDecimal(1), + Reference = reader.IsDBNull(2) ? null : reader.GetGuid(2), + Flag = reader.IsDBNull(3) ? null : reader.GetBoolean(3), + Ratio = reader.IsDBNull(4) ? null : reader.GetDouble(4), + Single = reader.IsDBNull(5) ? null : reader.GetFloat(5), + Counter = reader.IsDBNull(6) ? null : reader.GetInt32(6), + Big = reader.IsDBNull(7) ? null : reader.GetInt64(7), + Tiny = reader.IsDBNull(8) ? null : reader.GetInt16(8), + Small = reader.IsDBNull(9) ? null : reader.GetByte(9), + Elapsed = reader.IsDBNull(10) ? null : reader.GetTimeSpan(10), + Label = reader.IsDBNull(11) ? null : reader.GetString(11) + }; + } + + #endregion + + #region Helpers - SQLite + + private static async Task ExecuteSqliteNonQuery(string connectionString, string commandText) + { + using var conn = new SqliteConnection(connectionString); + await conn.OpenAsync(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = commandText; + await cmd.ExecuteNonQueryAsync(); + } + + private static string CreateSqliteFile(string testName) + { + var file = Path.Combine(Path.GetTempPath(), $"CoreSyncTypes_{testName}.sqlite"); + SqliteConnection.ClearAllPools(); + if (File.Exists(file)) File.Delete(file); + return file; + } + + private static void DeleteSqliteFile(string file) + { + SqliteConnection.ClearAllPools(); + if (File.Exists(file)) File.Delete(file); + } + + private static SqliteSyncProvider CreateSqliteProvider(string connectionString) + => new(new SqliteSyncConfigurationBuilder(connectionString) + .Table("Measurements") + .Table("NullableProbes") + .Build(), + logger: new ConsoleLogger("LOC")); + + #endregion + + #region Helpers - transports + + internal enum Transport { Direct, HttpJson, HttpBinary } + /// + /// Wraps in the requested transport, so every assertion runs identically + /// against the provider itself, against the JSON HTTP pipeline and against the MessagePack one. + /// + private static (ISyncProviderBase remote, SyncTestServer? server) Wrap(ISyncProvider remote, Transport transport) + { + if (transport == Transport.Direct) + return (remote, null); + + var server = SyncTestServer.Create(remote, useBinaryFormat: transport == Transport.HttpBinary); + return (server.HttpSyncProvider, server); + } + + private static SyncItem Item(string tableName, ChangeType changeType, Dictionary values) + => new(tableName, changeType, values); + + /// + /// Builds a change set the way an older build of the library would have sent it, by assigning + /// and directly instead of + /// letting the constructor normalize them. + /// + private static SyncItem RawItem(string tableName, ChangeType changeType, Dictionary values) + => new() { TableName = tableName, ChangeType = changeType, Values = values }; + + private static SyncItemValue Raw(SyncItemValueType type, object? value) + => new() { Type = type, Value = value }; + + private static T? FindInChain(Exception? ex) where T : Exception + { + while (ex != null) + { + if (ex is T match) + return match; + ex = ex.InnerException; + } + return null; + } + + #endregion + + #region Value normalization (no database) + + /// + /// The types without a dedicated must be normalized to a value whose + /// runtime type matches the declared , so that the JSON and the + /// MessagePack transports (which serialize by its runtime type) + /// deliver the same thing. + /// + [TestMethod] + public void SyncItemValue_NormalizesTypesWithoutADedicatedSyncItemValueType() + { + var tinyInt = new SyncItemValue((byte)250); + tinyInt.Type.ShouldBe(SyncItemValueType.Int32); + tinyInt.Value.ShouldBeOfType().ShouldBe(250); + + var signedByte = new SyncItemValue((sbyte)-3); + signedByte.Type.ShouldBe(SyncItemValueType.Int32); + signedByte.Value.ShouldBeOfType().ShouldBe(-3); + + var unsignedShort = new SyncItemValue((ushort)65535); + unsignedShort.Type.ShouldBe(SyncItemValueType.Int32); + unsignedShort.Value.ShouldBeOfType().ShouldBe(65535); + + var unsignedInt = new SyncItemValue(4294967295u); + unsignedInt.Type.ShouldBe(SyncItemValueType.Int64); + unsignedInt.Value.ShouldBeOfType().ShouldBe(4294967295L); + + var time = new SyncItemValue(new TimeSpan(0, 14, 30, 0)); + time.Type.ShouldBe(SyncItemValueType.String); + time.Value.ShouldBeOfType().ShouldBe("14:30:00"); + + var timeWithFraction = new SyncItemValue(TickPreciseDuration); + timeWithFraction.Value.ShouldBeOfType().ShouldBe("01:02:03.4567890"); + TimeSpan.Parse((string)timeWithFraction.Value!, CultureInfo.InvariantCulture).ShouldBe(TickPreciseDuration); + + var character = new SyncItemValue('U'); + character.Type.ShouldBe(SyncItemValueType.String); + character.Value.ShouldBeOfType().ShouldBe("U"); + + // The types that already worked are untouched. + new SyncItemValue(42).Type.ShouldBe(SyncItemValueType.Int32); + new SyncItemValue((short)7).Type.ShouldBe(SyncItemValueType.Int32); + new SyncItemValue(42L).Type.ShouldBe(SyncItemValueType.Int64); + new SyncItemValue(1.5m).Type.ShouldBe(SyncItemValueType.Decimal); + new SyncItemValue(Guid.Empty).Type.ShouldBe(SyncItemValueType.Guid); + new SyncItemValue(DBNull.Value).Type.ShouldBe(SyncItemValueType.Null); + new SyncItemValue(DBNull.Value).Value.ShouldBeNull(); + new SyncItemValue(null).Type.ShouldBe(SyncItemValueType.Null); + } + + /// + /// The "c" format is signed and carries whole days, so it can express a MySql TIME + /// (-838:59:59 to 838:59:59) and a PostgreSQL interval as faithfully as a SQL Server + /// time. This is the wire-level half of the range question; the provider-level half - a + /// SQL Server time column refusing what it cannot hold - is asserted by + /// . + /// + [TestMethod] + public void SyncItemValue_FormatsTimeSpansOutsideATimeOfDay() + { + var negative = new SyncItemValue(new TimeSpan(0, -1, -2, -3)); + negative.Type.ShouldBe(SyncItemValueType.String); + negative.Value.ShouldBe("-01:02:03"); + TimeSpan.Parse((string)negative.Value!, CultureInfo.InvariantCulture).ShouldBe(new TimeSpan(0, -1, -2, -3)); + + // 100 hours: legal in MySql and PostgreSQL, impossible in a SQL Server time column. + var hundredHours = new SyncItemValue(TimeSpan.FromHours(100)); + hundredHours.Value.ShouldBe("4.04:00:00"); + TimeSpan.Parse((string)hundredHours.Value!, CultureInfo.InvariantCulture).ShouldBe(TimeSpan.FromHours(100)); + + var mostNegativeMySqlTime = new SyncItemValue(new TimeSpan(-838, -59, -59)); + mostNegativeMySqlTime.Value.ShouldBe("-34.22:59:59"); + TimeSpan.Parse((string)mostNegativeMySqlTime.Value!, CultureInfo.InvariantCulture).ShouldBe(new TimeSpan(-838, -59, -59)); + } + + /// + /// A member added for these types would be an unknown enum value to a + /// peer running an older build, and both SyncProviderHttpClient and SyncAgentController + /// answer an unknown member with . The enum must stay as it is. + /// + [TestMethod] + public void SyncItemValueType_HasNoNewMembers() + { + Enum.GetNames(typeof(SyncItemValueType)).ShouldBe( + new[] { "Null", "String", "Int32", "Int64", "Float", "Double", "DateTime", "Boolean", "ByteArray", "Guid", "Decimal" }, + ignoreOrder: false); + } + + /// + /// Types that still have no representation stay unsupported, and say so. + /// is what Npgsql materializes a PostgreSQL timetz as, and what SQL Server returns for a + /// datetimeoffset column. + /// + [TestMethod] + public void SyncItemValue_StillRejectsTypesWithNoRepresentation() + { + Should.Throw(() => new SyncItemValue(DateTimeOffset.UtcNow)) + .Message.ShouldContain("DateTimeOffset"); + + Should.Throw(() => new SyncItemValue(new object())); + } + + /// + /// The JSON transport reads a value back with JsonElement.GetInt32()/GetString() chosen + /// from . A tinyint must therefore be on the wire as a JSON number and + /// a time as a JSON string. + /// + [TestMethod] + public void SyncItemValue_SurvivesJsonRoundTrip() + { + var item = Item("Measurements", ChangeType.Insert, new Dictionary + { + ["StartMonth"] = (byte)250, + ["CountTime"] = TickPreciseDuration, + ["EndMonth"] = null + }); + + var roundTripped = JsonSerializer.Deserialize(JsonSerializer.Serialize(item))!; + + roundTripped.Values["StartMonth"].Type.ShouldBe(SyncItemValueType.Int32); + ((JsonElement)roundTripped.Values["StartMonth"].Value!).GetInt32().ShouldBe(250); + + roundTripped.Values["CountTime"].Type.ShouldBe(SyncItemValueType.String); + ((JsonElement)roundTripped.Values["CountTime"].Value!).GetString().ShouldBe("01:02:03.4567890"); + + roundTripped.Values["EndMonth"].Type.ShouldBe(SyncItemValueType.Null); + roundTripped.Values["EndMonth"].Value.ShouldBeNull(); + } + + /// + /// The binary transport uses MessagePack's typeless resolver, which writes the runtime type of + /// into the payload. Without normalization a tinyint would arrive + /// as a and a time as a , which is exactly what the apply + /// side cannot bind. + /// + [TestMethod] + public async Task SyncItemValue_SurvivesMessagePackRoundTrip() + { + var item = Item("Measurements", ChangeType.Insert, new Dictionary + { + ["StartMonth"] = (byte)250, + ["CountTime"] = TickPreciseDuration, + ["EndMonth"] = null + }); + + using var stream = new MemoryStream(CoreSyncMessagePackSerializer.Serialize(item)); + var roundTripped = await CoreSyncMessagePackSerializer.DeserializeAsync(stream); + + roundTripped.Values["StartMonth"].Type.ShouldBe(SyncItemValueType.Int32); + roundTripped.Values["StartMonth"].Value.ShouldBeOfType().ShouldBe(250); + + roundTripped.Values["CountTime"].Type.ShouldBe(SyncItemValueType.String); + roundTripped.Values["CountTime"].Value.ShouldBeOfType().ShouldBe("01:02:03.4567890"); + + roundTripped.Values["EndMonth"].Type.ShouldBe(SyncItemValueType.Null); + roundTripped.Values["EndMonth"].Value.ShouldBeNull(); + } + + /// + /// Backward compatibility, new server → old client: an older SyncProviderHttpClient decodes a + /// JSON payload with a switch over the eleven members it knows and + /// throws on anything else. This replays that exact switch over a + /// payload produced by the current code, so a tinyint/time column cannot break a client that has not + /// been updated. + /// + [TestMethod] + public void NewPayload_IsDecodableByTheOldClientConversionSwitch() + { + var item = Item("Measurements", ChangeType.Insert, new Dictionary + { + ["Id"] = 1, + ["StartMonth"] = (byte)250, + ["Signed"] = (sbyte)-3, + ["Zone"] = (short)-5, + ["CountTime"] = TickPreciseDuration, + ["Negative"] = new TimeSpan(0, -1, -2, -3), + ["Letter"] = 'U', + ["Weight"] = 12.345m, + ["Recorded"] = DownloadedRecorded, + ["ExternalId"] = DownloadedGuid, + ["EndMonth"] = null + }); + + var roundTripped = JsonSerializer.Deserialize(JsonSerializer.Serialize(item))!; + + foreach (var entry in roundTripped.Values) + { + var decoded = OldClientConvertJsonValueToNetObject((JsonElement?)entry.Value.Value, entry.Value.Type); + + if (entry.Value.Type == SyncItemValueType.Null) + decoded.ShouldBeNull(); + else + decoded.ShouldNotBeNull($"the old client must be able to decode '{entry.Key}'"); + } + + // and the values survive that decoding unchanged + OldClientConvertJsonValueToNetObject((JsonElement?)roundTripped.Values["StartMonth"].Value, SyncItemValueType.Int32).ShouldBe(250); + OldClientConvertJsonValueToNetObject((JsonElement?)roundTripped.Values["CountTime"].Value, SyncItemValueType.String).ShouldBe("01:02:03.4567890"); + OldClientConvertJsonValueToNetObject((JsonElement?)roundTripped.Values["Negative"].Value, SyncItemValueType.String).ShouldBe("-01:02:03"); + OldClientConvertJsonValueToNetObject((JsonElement?)roundTripped.Values["Letter"].Value, SyncItemValueType.String).ShouldBe("U"); + } + + /// + /// A verbatim copy of the ConvertJsonValueToNetObject switch shipped in released versions of + /// CoreSync.Http.Client. Do not extend it: its whole point is to be frozen at what an old peer knows. + /// + private static object? OldClientConvertJsonValueToNetObject(JsonElement? value, SyncItemValueType targetType) + { + if (value == null) + return null; + + return targetType switch + { + SyncItemValueType.Null => null, + SyncItemValueType.String => value.Value.GetString(), + SyncItemValueType.Int32 => value.Value.GetInt32(), + SyncItemValueType.Float => value.Value.GetSingle(), + SyncItemValueType.Double => value.Value.GetDouble(), + SyncItemValueType.DateTime => value.Value.GetDateTime(), + SyncItemValueType.Boolean => value.Value.GetBoolean(), + SyncItemValueType.ByteArray => value.Value.GetBytesFromBase64(), + SyncItemValueType.Guid => value.Value.GetGuid(), + SyncItemValueType.Int64 => value.Value.GetInt64(), + SyncItemValueType.Decimal => value.Value.GetDecimal(), + _ => throw new NotSupportedException(), + }; + } + + #endregion + + #region SQL Server ⇄ SQLite round trips + /// /// Runs a full SQL Server → SQLite → SQL Server round trip over the requested transport. /// - private static async Task RunRoundTrip(string testName, Transport transport) + private static async Task RunSqlServerRoundTrip(string testName, Transport transport, bool useChangeTrackingProvider = false) { var dbName = $"CoreSyncTypes_{testName}"; - var sqliteFile = Path.Combine(Path.GetTempPath(), $"CoreSyncTypes_{testName}.sqlite"); + var sqliteFile = CreateSqliteFile(testName); var sqliteConnStr = $"Data Source={sqliteFile}"; - SqliteConnection.ClearAllPools(); - if (File.Exists(sqliteFile)) File.Delete(sqliteFile); - SyncTestServer? server = null; try @@ -220,38 +640,36 @@ private static async Task RunRoundTrip(string testName, Transport transport) await CreateSqlServerDatabase(dbName); var sqlConnStr = SqlServerConnectionStringFor(dbName); await ExecuteSqlServerNonQuery(sqlConnStr, SqlServerDdl); - await CreateSqliteDatabase(sqliteConnStr); - - var remoteProvider = new SqlSyncProvider( - new SqlSyncConfigurationBuilder(sqlConnStr).Table("Measurements").Build(), - logger: new ConsoleLogger("REM")); + await ExecuteSqliteNonQuery(sqliteConnStr, SqliteDdl); + + ISyncProvider remoteProvider = useChangeTrackingProvider + ? new SqlServerCTProvider( + new SqlServerCTSyncConfigurationBuilder(sqlConnStr).Table("Measurements").Table("NullableProbes").Build(), + logger: new ConsoleLogger("REM-CT")) + : new SqlSyncProvider( + new SqlSyncConfigurationBuilder(sqlConnStr).Table("Measurements").Table("NullableProbes").Build(), + logger: new ConsoleLogger("REM")); await remoteProvider.ApplyProvisionAsync(); - var localProvider = new SqliteSyncProvider( - new SqliteSyncConfigurationBuilder(sqliteConnStr).Table("Measurements").Build(), - logger: new ConsoleLogger("LOC")); + var localProvider = CreateSqliteProvider(sqliteConnStr); await localProvider.ApplyProvisionAsync(); - ISyncProviderBase remote = remoteProvider; - if (transport != Transport.Direct) - { - server = SyncTestServer.Create(remoteProvider, useBinaryFormat: transport == Transport.HttpBinary); - remote = server.HttpSyncProvider; - } - + (var remote, server) = Wrap(remoteProvider, transport); var agent = new SyncAgent(localProvider, remote); // ---------- SQL Server → SQLite ---------- - var downloadedGuid = new Guid("0f8fad5b-d9cb-469f-a165-70867728950e"); - var downloadedRecorded = new DateTime(2026, 9, 15, 21, 21, 33).AddTicks(1234567); - await ExecuteSqlServerNonQuery(sqlConnStr, $@" INSERT INTO [dbo].[Measurements] ([Id],[Name],[StartMonth],[EndMonth],[Zone],[CountTime],[Duration],[Weight],[ExternalId],[Recorded]) VALUES - (1, N'Spring', 3, 250, -5, '14:30:00', '01:02:03.4567890', 12.345, '{downloadedGuid}', '2026-09-15T21:21:33.1234567'), - (2, N'Winter', 0, NULL, 32767, '00:00:00', NULL, 0.001, '{Guid.Empty}', '2026-01-01T00:00:00.0000000');"); + (1, N'Spring', 3, 250, -5, '14:30:00', '01:02:03.4567890', 12.345, '{DownloadedGuid}', '2026-09-15T21:21:33.1234567'), + (2, N'Winter', 0, NULL, 32767, '00:00:00', NULL, 0.001, '{Guid.Empty}', '2026-01-01T00:00:00.0000000'); + INSERT INTO [dbo].[NullableProbes] + ([Id],[Moment],[Amount],[Reference],[Flag],[Ratio],[Single],[Counter],[Big],[Tiny],[Small],[Elapsed],[Label]) + VALUES + (1, '2026-09-15T21:21:33.1234567', 1234.5678, '{DownloadedGuid}', 1, 2.5, 1.5, -2147483648, 9223372036854775807, -32768, 255, '01:02:03.4567890', N'set'), + (2, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);"); await agent.SynchronizeAsync(); @@ -265,10 +683,10 @@ INSERT INTO [dbo].[Measurements] full.EndMonth.ShouldBe((byte)250); full.Zone.ShouldBe((short)-5); full.CountTime.ShouldBe(new TimeSpan(14, 30, 0)); - full.Duration.ShouldBe(new TimeSpan(0, 1, 2, 3).Add(TimeSpan.FromTicks(4567890))); + full.Duration.ShouldBe(TickPreciseDuration); full.Weight.ShouldBe(12.345m); - full.ExternalId.ShouldBe(downloadedGuid); - full.Recorded.ShouldBe(downloadedRecorded); + full.ExternalId.ShouldBe(DownloadedGuid); + full.Recorded.ShouldBe(DownloadedRecorded); var nulls = await db.Measurements.SingleAsync(_ => _.Id == 2); nulls.StartMonth.ShouldBe((byte)0); @@ -277,14 +695,38 @@ INSERT INTO [dbo].[Measurements] nulls.CountTime.ShouldBe(TimeSpan.Zero); nulls.Duration.ShouldBeNull(); nulls.ExternalId.ShouldBe(Guid.Empty); + + var probe = await db.NullableProbes.SingleAsync(_ => _.Id == 1); + probe.Moment.ShouldBe(DownloadedRecorded); + probe.Amount.ShouldBe(1234.5678m); + probe.Reference.ShouldBe(DownloadedGuid); + probe.Flag.ShouldBe(true); + probe.Ratio.ShouldBe(2.5); + probe.Single.ShouldBe(1.5f); + probe.Counter.ShouldBe(int.MinValue); + probe.Big.ShouldBe(long.MaxValue); + probe.Tiny.ShouldBe(short.MinValue); + probe.Small.ShouldBe((byte)255); + probe.Elapsed.ShouldBe(TickPreciseDuration); + probe.Label.ShouldBe("set"); + + var emptyProbe = await db.NullableProbes.SingleAsync(_ => _.Id == 2); + emptyProbe.Moment.ShouldBeNull(); + emptyProbe.Amount.ShouldBeNull(); + emptyProbe.Reference.ShouldBeNull(); + emptyProbe.Flag.ShouldBeNull(); + emptyProbe.Ratio.ShouldBeNull(); + emptyProbe.Single.ShouldBeNull(); + emptyProbe.Counter.ShouldBeNull(); + emptyProbe.Big.ShouldBeNull(); + emptyProbe.Tiny.ShouldBeNull(); + emptyProbe.Small.ShouldBeNull(); + emptyProbe.Elapsed.ShouldBeNull(); + emptyProbe.Label.ShouldBeNull(); } // ---------- SQLite → SQL Server ---------- - var uploadedGuid = new Guid("7c9e6679-7425-40de-944b-e07fc1f90ae7"); - var uploadedRecorded = new DateTime(2026, 2, 3, 4, 5, 6).AddTicks(7654321); - var uploadedDuration = new TimeSpan(0, 12, 34, 56).Add(TimeSpan.FromTicks(7891234)); - { using var db = new MeasurementDbContext(sqliteConnStr); db.Measurements.Add(new Measurement @@ -295,10 +737,10 @@ INSERT INTO [dbo].[Measurements] EndMonth = 1, Zone = -32768, CountTime = new TimeSpan(23, 59, 59), - Duration = uploadedDuration, + Duration = UploadedDuration, Weight = 999.999m, - ExternalId = uploadedGuid, - Recorded = uploadedRecorded + ExternalId = UploadedGuid, + Recorded = UploadedRecorded }); db.Measurements.Add(new Measurement { @@ -313,12 +755,29 @@ INSERT INTO [dbo].[Measurements] ExternalId = Guid.Empty, Recorded = new DateTime(2026, 3, 4, 5, 6, 7) }); + db.NullableProbes.Add(new NullableProbe + { + Id = 100, + Moment = UploadedRecorded, + Amount = -8765.4321m, + Reference = UploadedGuid, + Flag = false, + Ratio = -0.125, + Single = 0.5f, + Counter = 2147483647, + Big = long.MinValue, + Tiny = 32767, + Small = 0, + Elapsed = UploadedDuration, + Label = "uploaded" + }); + db.NullableProbes.Add(new NullableProbe { Id = 101 }); await db.SaveChangesAsync(); } await agent.SynchronizeAsync(); - var uploaded = await ReadFromSqlServer(sqlConnStr, 100); + var uploaded = await ReadMeasurementFromSqlServer(sqlConnStr, 100); uploaded.ShouldNotBeNull(); uploaded.Name.ShouldBe("Uploaded"); uploaded.StartMonth.ShouldBe((byte)255); @@ -326,165 +785,431 @@ INSERT INTO [dbo].[Measurements] uploaded.Zone.ShouldBe((short)-32768); uploaded.CountTime.ShouldBe(new TimeSpan(23, 59, 59)); // time(7) keeps the sub-second part: a "c" formatted string must not lose it on the way in. - uploaded.Duration.ShouldBe(uploadedDuration); + uploaded.Duration.ShouldBe(UploadedDuration); uploaded.Weight.ShouldBe(999.999m); - uploaded.ExternalId.ShouldBe(uploadedGuid); - uploaded.Recorded.ShouldBe(uploadedRecorded); + uploaded.ExternalId.ShouldBe(UploadedGuid); + uploaded.Recorded.ShouldBe(UploadedRecorded); - var uploadedNulls = await ReadFromSqlServer(sqlConnStr, 101); + var uploadedNulls = await ReadMeasurementFromSqlServer(sqlConnStr, 101); uploadedNulls.ShouldNotBeNull(); uploadedNulls.StartMonth.ShouldBe((byte)7); uploadedNulls.EndMonth.ShouldBeNull(); uploadedNulls.CountTime.ShouldBe(TimeSpan.Zero); uploadedNulls.Duration.ShouldBeNull(); + + var uploadedProbe = await ReadProbeFromSqlServer(sqlConnStr, 100); + uploadedProbe.ShouldNotBeNull(); + uploadedProbe.Moment.ShouldBe(UploadedRecorded); + uploadedProbe.Amount.ShouldBe(-8765.4321m); + uploadedProbe.Reference.ShouldBe(UploadedGuid); + uploadedProbe.Flag.ShouldBe(false); + uploadedProbe.Ratio.ShouldBe(-0.125); + uploadedProbe.Single.ShouldBe(0.5f); + uploadedProbe.Counter.ShouldBe(int.MaxValue); + uploadedProbe.Big.ShouldBe(long.MinValue); + uploadedProbe.Tiny.ShouldBe((short)32767); + uploadedProbe.Small.ShouldBe((byte)0); + uploadedProbe.Elapsed.ShouldBe(UploadedDuration); + uploadedProbe.Label.ShouldBe("uploaded"); + + var uploadedEmptyProbe = await ReadProbeFromSqlServer(sqlConnStr, 101); + uploadedEmptyProbe.ShouldNotBeNull(); + uploadedEmptyProbe.Moment.ShouldBeNull(); + uploadedEmptyProbe.Amount.ShouldBeNull(); + uploadedEmptyProbe.Reference.ShouldBeNull(); + uploadedEmptyProbe.Flag.ShouldBeNull(); + uploadedEmptyProbe.Small.ShouldBeNull(); + uploadedEmptyProbe.Elapsed.ShouldBeNull(); } finally { server?.Dispose(); SqlConnection.ClearAllPools(); - SqliteConnection.ClearAllPools(); + DeleteSqliteFile(sqliteFile); await DropSqlServerDatabase(dbName); - if (File.Exists(sqliteFile)) File.Delete(sqliteFile); } } - #endregion + [TestMethod] + public Task SqlServerAndSqlite_RoundTripTinyIntAndTime_Direct() + => RunSqlServerRoundTrip("Direct", Transport.Direct); - #region Value normalization (no database) + [TestMethod] + public Task SqlServerAndSqlite_RoundTripTinyIntAndTime_HttpJson() + => RunSqlServerRoundTrip("HttpJson", Transport.HttpJson); + + [TestMethod] + public Task SqlServerAndSqlite_RoundTripTinyIntAndTime_HttpBinary() + => RunSqlServerRoundTrip("HttpBinary", Transport.HttpBinary); /// - /// The types without a dedicated must be normalized to a value whose - /// runtime type matches the declared , so that the JSON and the - /// MessagePack transports (which serialize by its runtime type) - /// deliver the same thing. + /// The change tracking provider has its own copy of Utils.ConvertToSqlType, so it needs its + /// own proof that a tinyint/time column round-trips. /// [TestMethod] - public void SyncItemValue_NormalizesTypesWithoutADedicatedSyncItemValueType() - { - var tinyInt = new SyncItemValue((byte)250); - tinyInt.Type.ShouldBe(SyncItemValueType.Int32); - tinyInt.Value.ShouldBeOfType().ShouldBe(250); + public Task SqlServerCTAndSqlite_RoundTripTinyIntAndTime_Direct() + => RunSqlServerRoundTrip("CtDirect", Transport.Direct, useChangeTrackingProvider: true); - var signedByte = new SyncItemValue((sbyte)-3); - signedByte.Type.ShouldBe(SyncItemValueType.Int32); - signedByte.Value.ShouldBeOfType().ShouldBe(-3); + [TestMethod] + public Task SqlServerCTAndSqlite_RoundTripTinyIntAndTime_HttpJson() + => RunSqlServerRoundTrip("CtHttpJson", Transport.HttpJson, useChangeTrackingProvider: true); - var unsignedShort = new SyncItemValue((ushort)65535); - unsignedShort.Type.ShouldBe(SyncItemValueType.Int32); - unsignedShort.Value.ShouldBeOfType().ShouldBe(65535); + [TestMethod] + public Task SqlServerCTAndSqlite_RoundTripTinyIntAndTime_HttpBinary() + => RunSqlServerRoundTrip("CtHttpBinary", Transport.HttpBinary, useChangeTrackingProvider: true); - var unsignedInt = new SyncItemValue(4294967295u); - unsignedInt.Type.ShouldBe(SyncItemValueType.Int64); - unsignedInt.Value.ShouldBeOfType().ShouldBe(4294967295L); + #endregion - var time = new SyncItemValue(new TimeSpan(0, 14, 30, 0)); - time.Type.ShouldBe(SyncItemValueType.String); - time.Value.ShouldBeOfType().ShouldBe("14:30:00"); + #region SQL Server time range and precision - var timeWithFraction = new SyncItemValue(new TimeSpan(0, 1, 2, 3).Add(TimeSpan.FromTicks(4567890))); - timeWithFraction.Value.ShouldBeOfType().ShouldBe("01:02:03.4567890"); - TimeSpan.Parse((string)timeWithFraction.Value!, CultureInfo.InvariantCulture) - .ShouldBe(new TimeSpan(0, 1, 2, 3).Add(TimeSpan.FromTicks(4567890))); + private static async Task RunInSqlServerDatabase(string dbName, Func body) + { + try + { + await CreateSqlServerDatabase(dbName); + var connStr = SqlServerConnectionStringFor(dbName); + await ExecuteSqlServerNonQuery(connStr, SqlServerDdl); + await body(connStr); + } + finally + { + SqlConnection.ClearAllPools(); + await DropSqlServerDatabase(dbName); + } + } - var character = new SyncItemValue('U'); - character.Type.ShouldBe(SyncItemValueType.String); - character.Value.ShouldBeOfType().ShouldBe("U"); + /// + /// A SQL Server time column holds a time of day only. MySql and PostgreSQL are wider, so a + /// value arriving from one of them can be unrepresentable here - and truncating it silently would be + /// data loss. The apply must fail with a message that names the offending value. + /// + [TestMethod] + public async Task ApplyChanges_TimeValueOutsideTimeOfDayRange_IsRejectedWithAClearMessage() + { + await RunInSqlServerDatabase("CoreSyncTypes_TimeRange", async connStr => + { + var provider = new SqlSyncProvider( + new SqlSyncConfigurationBuilder(connStr).Table("Measurements").Table("NullableProbes").Build(), + logger: new ConsoleLogger("SUT")); + await provider.ApplyProvisionAsync(); - // The types that already worked are untouched. - new SyncItemValue(42).Type.ShouldBe(SyncItemValueType.Int32); - new SyncItemValue((short)7).Type.ShouldBe(SyncItemValueType.Int32); - new SyncItemValue(42L).Type.ShouldBe(SyncItemValueType.Int64); - new SyncItemValue(1.5m).Type.ShouldBe(SyncItemValueType.Decimal); - new SyncItemValue(Guid.Empty).Type.ShouldBe(SyncItemValueType.Guid); - new SyncItemValue(DBNull.Value).Type.ShouldBe(SyncItemValueType.Null); - new SyncItemValue(DBNull.Value).Value.ShouldBeNull(); - new SyncItemValue(null).Type.ShouldBe(SyncItemValueType.Null); + foreach (var (label, wireValue) in new[] + { + ("100 hours", "4.04:00:00"), + ("the widest MySql TIME", "34.22:59:59"), + ("a negative interval", "-01:02:03") + }) + { + var changeSet = new SyncChangeSet( + new SyncAnchor(Guid.NewGuid(), 1), + SyncAnchor.Null, + new[] + { + RawItem("Measurements", ChangeType.Insert, new Dictionary + { + ["Id"] = Raw(SyncItemValueType.Int32, 1), + ["Name"] = Raw(SyncItemValueType.String, label), + ["StartMonth"] = Raw(SyncItemValueType.Int32, 1), + ["EndMonth"] = Raw(SyncItemValueType.Null, null), + ["Zone"] = Raw(SyncItemValueType.Int32, (short)1), + ["CountTime"] = Raw(SyncItemValueType.String, wireValue), + ["Duration"] = Raw(SyncItemValueType.Null, null), + ["Weight"] = Raw(SyncItemValueType.Decimal, 1m), + ["ExternalId"] = Raw(SyncItemValueType.Guid, Guid.Empty), + ["Recorded"] = Raw(SyncItemValueType.DateTime, new DateTime(2026, 1, 1)) + }) + }); + + var ex = await Should.ThrowAsync(() => provider.ApplyChangesAsync(changeSet)); + var notSupported = FindInChain(ex); + notSupported.ShouldNotBeNull($"{label} must be rejected, not silently truncated"); + notSupported.Message.ShouldContain(wireValue); + notSupported.Message.ShouldContain("time of day"); + + // Nothing was written. + (await ExecuteSqlServerScalar(connStr, "SELECT COUNT(*) FROM [dbo].[Measurements]")).ShouldBe(0); + } + }); } /// - /// A member added for these types would be an unknown enum value to a - /// peer running an older build, and both SyncProviderHttpClient and SyncAgentController - /// answer an unknown member with . The enum must stay as it is. + /// A time of day at the very edge of the range is accepted: the guard must not be off by one tick. /// [TestMethod] - public void SyncItemValueType_HasNoNewMembers() + public async Task ApplyChanges_TimeValueAtTheEdgeOfTheRange_IsAccepted() { - Enum.GetNames(typeof(SyncItemValueType)).ShouldBe( - new[] { "Null", "String", "Int32", "Int64", "Float", "Double", "DateTime", "Boolean", "ByteArray", "Guid", "Decimal" }, - ignoreOrder: false); + await RunInSqlServerDatabase("CoreSyncTypes_TimeEdge", async connStr => + { + var provider = new SqlSyncProvider( + new SqlSyncConfigurationBuilder(connStr).Table("Measurements").Table("NullableProbes").Build(), + logger: new ConsoleLogger("SUT")); + await provider.ApplyProvisionAsync(); + + var lastTick = TimeSpan.FromDays(1).Subtract(TimeSpan.FromTicks(1)); // 23:59:59.9999999 + + await provider.ApplyChangesAsync(new SyncChangeSet( + new SyncAnchor(Guid.NewGuid(), 1), + SyncAnchor.Null, + new[] + { + RawItem("NullableProbes", ChangeType.Insert, new Dictionary + { + ["Id"] = Raw(SyncItemValueType.Int32, 1), + ["Elapsed"] = Raw(SyncItemValueType.String, TimeSpan.Zero.ToString("c", CultureInfo.InvariantCulture)) + }), + RawItem("NullableProbes", ChangeType.Insert, new Dictionary + { + ["Id"] = Raw(SyncItemValueType.Int32, 2), + ["Elapsed"] = Raw(SyncItemValueType.String, lastTick.ToString("c", CultureInfo.InvariantCulture)) + }) + })); + + (await ReadProbeFromSqlServer(connStr, 1))!.Elapsed.ShouldBe(TimeSpan.Zero); + (await ReadProbeFromSqlServer(connStr, 2))!.Elapsed.ShouldBe(lastTick); + }); } /// - /// The JSON transport reads a value back with JsonElement.GetInt32()/GetString() chosen - /// from . A tinyint must therefore be on the wire as a JSON number and - /// a time as a JSON string. + /// A time(0) target cannot hold sub-second precision. Document what actually reaches the + /// column - SQL Server rounds the value to the column's scale rather than truncating it - by + /// asserting the stored result, not the value that was sent. /// [TestMethod] - public void SyncItemValue_SurvivesJsonRoundTrip() + public async Task ApplyChanges_TimeValueWithMorePrecisionThanTheColumn_IsRoundedByTheStore() { - var item = new SyncItem("Measurements", ChangeType.Insert, new Dictionary + await RunInSqlServerDatabase("CoreSyncTypes_TimeScale", async connStr => { - ["StartMonth"] = (byte)250, - ["CountTime"] = new TimeSpan(0, 1, 2, 3).Add(TimeSpan.FromTicks(4567890)), - ["EndMonth"] = null - }); + var provider = new SqlSyncProvider( + new SqlSyncConfigurationBuilder(connStr).Table("Measurements").Table("NullableProbes").Build(), + logger: new ConsoleLogger("SUT")); + await provider.ApplyProvisionAsync(); - var roundTripped = JsonSerializer.Deserialize(JsonSerializer.Serialize(item))!; + // CountTime is TIME(0); Duration is TIME(7) and keeps everything. + async Task Insert(int id, TimeSpan countTime) + { + await provider.ApplyChangesAsync(new SyncChangeSet( + new SyncAnchor(Guid.NewGuid(), 1), + SyncAnchor.Null, + new[] + { + RawItem("Measurements", ChangeType.Insert, new Dictionary + { + ["Id"] = Raw(SyncItemValueType.Int32, id), + ["Name"] = Raw(SyncItemValueType.String, "scale"), + ["StartMonth"] = Raw(SyncItemValueType.Int32, 1), + ["Zone"] = Raw(SyncItemValueType.Int32, (short)1), + ["CountTime"] = Raw(SyncItemValueType.String, countTime.ToString("c", CultureInfo.InvariantCulture)), + ["Duration"] = Raw(SyncItemValueType.String, countTime.ToString("c", CultureInfo.InvariantCulture)), + ["Weight"] = Raw(SyncItemValueType.Decimal, 1m), + ["ExternalId"] = Raw(SyncItemValueType.Guid, Guid.Empty), + ["Recorded"] = Raw(SyncItemValueType.DateTime, new DateTime(2026, 1, 1)) + }) + })); + } - roundTripped.Values["StartMonth"].Type.ShouldBe(SyncItemValueType.Int32); - ((JsonElement)roundTripped.Values["StartMonth"].Value!).GetInt32().ShouldBe(250); + await Insert(1, new TimeSpan(14, 30, 0).Add(TimeSpan.FromMilliseconds(600))); + await Insert(2, new TimeSpan(14, 30, 0).Add(TimeSpan.FromMilliseconds(400))); - roundTripped.Values["CountTime"].Type.ShouldBe(SyncItemValueType.String); - ((JsonElement)roundTripped.Values["CountTime"].Value!).GetString().ShouldBe("01:02:03.4567890"); + var roundedUp = await ReadMeasurementFromSqlServer(connStr, 1); + roundedUp.ShouldNotBeNull(); + roundedUp.CountTime.ShouldBe(new TimeSpan(14, 30, 1), "SQL Server rounds to the column scale"); + roundedUp.Duration.ShouldBe(new TimeSpan(14, 30, 0).Add(TimeSpan.FromMilliseconds(600)), "time(7) keeps it"); - roundTripped.Values["EndMonth"].Type.ShouldBe(SyncItemValueType.Null); - roundTripped.Values["EndMonth"].Value.ShouldBeNull(); + var roundedDown = await ReadMeasurementFromSqlServer(connStr, 2); + roundedDown.ShouldNotBeNull(); + roundedDown.CountTime.ShouldBe(new TimeSpan(14, 30, 0)); + }); } + #endregion + + #region Old peer compatibility against a live store + /// - /// The binary transport uses MessagePack's typeless resolver, which writes the runtime type of - /// into the payload. Without normalization a tinyint would arrive - /// as a and a time as a , which is exactly what the apply - /// side cannot bind. + /// Backward compatibility, old client → new server. This is the payload a released SQLite client + /// actually produces for these columns today: a nullable byte column falls through to + /// GetValue() and arrives as an , a nullable TimeSpan + /// column arrives as the raw text SQLite stored. The upgraded server must keep accepting both. /// [TestMethod] - public async Task SyncItemValue_SurvivesMessagePackRoundTrip() + public async Task OldClientPayload_IsStillAcceptedBySqlServer() + => await RunOldClientPayloadTest("CoreSyncTypes_OldPeer", useChangeTrackingProvider: false); + + [TestMethod] + public async Task OldClientPayload_IsStillAcceptedBySqlServerCT() + => await RunOldClientPayloadTest("CoreSyncTypes_OldPeerCT", useChangeTrackingProvider: true); + + private static async Task RunOldClientPayloadTest(string dbName, bool useChangeTrackingProvider) { - var item = new SyncItem("Measurements", ChangeType.Insert, new Dictionary + await RunInSqlServerDatabase(dbName, async connStr => { - ["StartMonth"] = (byte)250, - ["CountTime"] = new TimeSpan(0, 1, 2, 3).Add(TimeSpan.FromTicks(4567890)), - ["EndMonth"] = null + ISyncProvider provider = useChangeTrackingProvider + ? new SqlServerCTProvider( + new SqlServerCTSyncConfigurationBuilder(connStr).Table("Measurements").Table("NullableProbes").Build(), + logger: new ConsoleLogger("SUT-CT")) + : new SqlSyncProvider( + new SqlSyncConfigurationBuilder(connStr).Table("Measurements").Table("NullableProbes").Build(), + logger: new ConsoleLogger("SUT")); + await provider.ApplyProvisionAsync(); + + await provider.ApplyChangesAsync(new SyncChangeSet( + new SyncAnchor(Guid.NewGuid(), 1), + SyncAnchor.Null, + new[] + { + RawItem("Measurements", ChangeType.Insert, new Dictionary + { + ["Id"] = Raw(SyncItemValueType.Int64, 1L), + ["Name"] = Raw(SyncItemValueType.String, "old client"), + // an old SQLite client read a byte? column with GetValue() -> Int64 + ["StartMonth"] = Raw(SyncItemValueType.Int64, 3L), + ["EndMonth"] = Raw(SyncItemValueType.Int64, 250L), + ["Zone"] = Raw(SyncItemValueType.Int64, -5L), + // ...and a TimeSpan? column as the raw stored text + ["CountTime"] = Raw(SyncItemValueType.String, "14:30:00"), + ["Duration"] = Raw(SyncItemValueType.String, "01:02:03.4567890"), + // decimals used to arrive as text too + ["Weight"] = Raw(SyncItemValueType.String, "12.345"), + ["ExternalId"] = Raw(SyncItemValueType.String, DownloadedGuid.ToString()), + ["Recorded"] = Raw(SyncItemValueType.String, "2026-09-15T21:21:33.1234567") + }) + })); + + var applied = await ReadMeasurementFromSqlServer(connStr, 1); + applied.ShouldNotBeNull(); + applied.StartMonth.ShouldBe((byte)3); + applied.EndMonth.ShouldBe((byte)250); + applied.Zone.ShouldBe((short)-5); + applied.CountTime.ShouldBe(new TimeSpan(14, 30, 0)); + applied.Duration.ShouldBe(TickPreciseDuration); + applied.Weight.ShouldBe(12.345m); + applied.ExternalId.ShouldBe(DownloadedGuid); + applied.Recorded.ShouldBe(DownloadedRecorded); }); + } - using var stream = new MemoryStream(CoreSyncMessagePackSerializer.Serialize(item)); - var roundTripped = await CoreSyncMessagePackSerializer.DeserializeAsync(stream); + #endregion - roundTripped.Values["StartMonth"].Type.ShouldBe(SyncItemValueType.Int32); - roundTripped.Values["StartMonth"].Value.ShouldBeOfType().ShouldBe(250); + #region SQLite typed reads on legacy stores - roundTripped.Values["CountTime"].Type.ShouldBe(SyncItemValueType.String); - roundTripped.Values["CountTime"].Value.ShouldBeOfType().ShouldBe("01:02:03.4567890"); + /// + /// Nullable columns now go through the same typed getters as their non-nullable counterparts. A store + /// written by something else can hold text those getters refuse - SQLite has affinities, not types - + /// and such a store must stay synchronizable: the reader falls back to the raw stored value, which is + /// exactly what it returned before typed reads were extended to nullable columns. + /// + [TestMethod] + public async Task Sqlite_ReadingALegacyValueFallsBackToTheRawStoredValue() + { + var sqliteFile = CreateSqliteFile("LegacyValues"); + var connStr = $"Data Source={sqliteFile}"; - roundTripped.Values["EndMonth"].Type.ShouldBe(SyncItemValueType.Null); - roundTripped.Values["EndMonth"].Value.ShouldBeNull(); - } + try + { + await ExecuteSqliteNonQuery(connStr, SqliteDdl); - #endregion + var provider = CreateSqliteProvider(connStr); + await provider.ApplyProvisionAsync(); - #region Round trips + // A date in a non-invariant format and a boolean spelled out as text. + await ExecuteSqliteNonQuery(connStr, @" + INSERT INTO [NullableProbes] ([Id],[Moment],[Flag],[Label]) + VALUES (1, '15/09/2026 21:21:33', 'yes', 'legacy')"); - [TestMethod] - public Task SqlServerAndSqlite_RoundTripTinyIntAndTime_Direct() - => RunRoundTrip("Direct", Transport.Direct); + var changes = await provider.GetChangesAsync(Guid.NewGuid()); - [TestMethod] - public Task SqlServerAndSqlite_RoundTripTinyIntAndTime_HttpJson() - => RunRoundTrip("HttpJson", Transport.HttpJson); + var item = changes.Items.Single(_ => _.TableName == "NullableProbes"); + + // GetDateTime parses text and throws on a format it doesn't recognize: the reader falls back + // to the raw stored value instead of failing the whole synchronization. + item.Values["Moment"].Type.ShouldBe(SyncItemValueType.String); + item.Values["Moment"].Value.ShouldBe("15/09/2026 21:21:33"); + + // GetBoolean (like the integer and floating point getters) does not parse text: SQLite converts + // non-numeric text to 0, so it reads false without throwing. That is exactly what EF Core + // returns to the application for this row, and what a non-nullable bool column has always + // synchronized, so no fallback applies - sending the raw 'yes' to a server bit column would + // only fail when the change is applied. + item.Values["Flag"].Type.ShouldBe(SyncItemValueType.Boolean); + item.Values["Flag"].Value.ShouldBe(false); + item.Values["Label"].Value.ShouldBe("legacy"); + } + finally + { + DeleteSqliteFile(sqliteFile); + } + } + + /// + /// The companion of the test above: a store holding well-formed values must be read through the typed + /// getters, so a nullable column produces the same as the non-nullable + /// column of the same type - and the same one the server sends down for it. + /// [TestMethod] - public Task SqlServerAndSqlite_RoundTripTinyIntAndTime_HttpBinary() - => RunRoundTrip("HttpBinary", Transport.HttpBinary); + public async Task Sqlite_ReadsNullableColumnsThroughTheirUnderlyingType() + { + var sqliteFile = CreateSqliteFile("NullableTypes"); + var connStr = $"Data Source={sqliteFile}"; + + try + { + await ExecuteSqliteNonQuery(connStr, SqliteDdl); + + var provider = CreateSqliteProvider(connStr); + await provider.ApplyProvisionAsync(); + + using (var db = new MeasurementDbContext(connStr)) + { + db.NullableProbes.Add(new NullableProbe + { + Id = 1, + Moment = UploadedRecorded, + Amount = 1234.5678m, + Reference = UploadedGuid, + Flag = true, + Ratio = 2.5, + Single = 1.5f, + Counter = 7, + Big = long.MaxValue, + Tiny = -32768, + Small = 255, + Elapsed = TickPreciseDuration, + Label = "typed" + }); + await db.SaveChangesAsync(); + } + + var changes = await provider.GetChangesAsync(Guid.NewGuid()); + var values = changes.Items.Single(_ => _.TableName == "NullableProbes").Values; + + values["Moment"].Type.ShouldBe(SyncItemValueType.DateTime); + values["Moment"].Value.ShouldBe(UploadedRecorded); + values["Amount"].Type.ShouldBe(SyncItemValueType.Decimal); + values["Amount"].Value.ShouldBe(1234.5678m); + values["Flag"].Type.ShouldBe(SyncItemValueType.Boolean); + values["Flag"].Value.ShouldBe(true); + values["Ratio"].Type.ShouldBe(SyncItemValueType.Double); + values["Ratio"].Value.ShouldBe(2.5); + values["Single"].Type.ShouldBe(SyncItemValueType.Float); + values["Single"].Value.ShouldBe(1.5f); + values["Counter"].Type.ShouldBe(SyncItemValueType.Int32); + values["Counter"].Value.ShouldBe(7); + values["Big"].Type.ShouldBe(SyncItemValueType.Int64); + values["Big"].Value.ShouldBe(long.MaxValue); + values["Tiny"].Type.ShouldBe(SyncItemValueType.Int32); + values["Small"].Type.ShouldBe(SyncItemValueType.Int32); + values["Small"].Value.ShouldBe(255); + values["Elapsed"].Type.ShouldBe(SyncItemValueType.String); + values["Elapsed"].Value.ShouldBe("01:02:03.4567890"); + // Guid? has no typed getter and keeps falling through to the raw stored value + values["Reference"].Value.ShouldNotBeNull(); + } + finally + { + DeleteSqliteFile(sqliteFile); + } + } #endregion }