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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/CoreSync.MySql/MySqlSyncTable.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using MySqlConnector;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;

namespace CoreSync.MySql
Expand Down Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions src/CoreSync.PostgreSQL/PostgreSQLColumn.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

/// <summary>
/// True for the PostgreSQL types Npgsql can materialize as a <see cref="TimeSpan"/>.
/// </summary>
/// <remarks>
/// <c>time with time zone</c> (<c>timetz</c>) is deliberately excluded: Npgsql materializes it
/// as a <see cref="DateTimeOffset"/>, which has no <see cref="SyncItemValueType"/> and is not
/// supported for synchronization.
/// <para>
/// An <c>interval</c> 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 <see cref="TimeSpan"/>.
/// </para>
/// </remarks>
public bool IsTimeLike =>
Type.Equals("time without time zone", StringComparison.OrdinalIgnoreCase) ||
Type.Equals("time", StringComparison.OrdinalIgnoreCase) ||
Type.Equals("interval", StringComparison.OrdinalIgnoreCase);
}
}
7 changes: 7 additions & 0 deletions src/CoreSync.PostgreSQL/PostgreSQLSyncProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,13 @@ private static ChangeType DetectChangeType(Dictionary<string, object?> 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<TimeSpan>(columnOrdinal);

if (table.RecordType == null)
return r.GetValue(columnOrdinal);

Expand Down
12 changes: 12 additions & 0 deletions src/CoreSync.PostgreSQL/PostgreSQLSyncTable.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Npgsql;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;

namespace CoreSync.PostgreSQL
Expand Down Expand Up @@ -91,6 +92,17 @@ 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.IsTimeLike)
{
if (value is string timeString &&
TimeSpan.TryParse(timeString, CultureInfo.InvariantCulture, out var timeSpanValue))
{
return timeSpanValue;
}
}

return value;
}

Expand Down
37 changes: 37 additions & 0 deletions src/CoreSync.SqlServer/Utils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,44 @@ 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)
{
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
if (dbType == SqlDbType.TinyInt &&
value.Value is byte == false)
return Convert.ToByte(value.Value, CultureInfo.InvariantCulture);

return value.Value;
}

/// <summary>
/// A SQL Server <c>time</c> column is a time of day: 00:00:00.0000000 to 23:59:59.9999999.
/// Other stores are wider - a MySql <c>TIME</c> spans -838:59:59 to 838:59:59 and a PostgreSQL
/// <c>interval</c> 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.
/// </summary>
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;
}
}
}
37 changes: 37 additions & 0 deletions src/CoreSync.SqlServerCT/Utils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,44 @@ 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)
{
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
if (dbType == SqlDbType.TinyInt &&
value.Value is byte == false)
return Convert.ToByte(value.Value, CultureInfo.InvariantCulture);

return value.Value;
}

/// <summary>
/// A SQL Server <c>time</c> column is a time of day: 00:00:00.0000000 to 23:59:59.9999999.
/// Other stores are wider - a MySql <c>TIME</c> spans -838:59:59 to 838:59:59 and a PostgreSQL
/// <c>interval</c> 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.
/// </summary>
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;
}
}
}
46 changes: 39 additions & 7 deletions src/CoreSync.Sqlite/SqliteSyncProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,7 @@ public async Task<SyncChangeSet> 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()}");
Expand All @@ -519,7 +519,7 @@ public async Task<SyncChangeSet> 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;

Expand All @@ -537,7 +537,7 @@ public async Task<SyncChangeSet> 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()}");
}
Expand Down Expand Up @@ -630,7 +630,7 @@ private static ChangeType DetectChangeType(Dictionary<string, object?> 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;
Expand All @@ -640,7 +640,7 @@ private static ChangeType DetectChangeType(Dictionary<string, object?> 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(_ =>
{
Expand All @@ -654,18 +654,50 @@ private static ChangeType DetectChangeType(Dictionary<string, object?> 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
//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;

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))
return r.GetDateTime(columnOrdinal);
if (propertyType == typeof(TimeSpan))
return r.GetTimeSpan(columnOrdinal);
if (propertyType == typeof(int))
return r.GetInt32(columnOrdinal);
if (propertyType == typeof(bool))
Expand Down
Loading
Loading