From 9c8f9b63628063cf4308a425507dd511629560f6 Mon Sep 17 00:00:00 2001 From: MrDevRobot Date: Thu, 3 Sep 2026 21:55:00 +0200 Subject: [PATCH 1/2] fix: preserve DateTimeOffset offset across BSON round-trip BsonValue.FromDateTimeOffset / BsonSpanWriter.WriteDateTimeOffset stored a DateTimeOffset as a plain BSON DateTime (UTC millisecond timestamp only), so the offset was silently dropped on every write and read back as +00:00. Adds a distinct BsonType.DateTimeOffset wire tag (10 bytes: the existing 8-byte UTC timestamp plus a 2-byte offset-in-minutes trailer) so the offset now survives the round-trip, while every place that branches on BsonType.DateTime (skip-length, BsonValue accessors/equality/comparison, BLQL predicate/index-key building, projection, schema generation, the source-generated entity readers) gets a matching arm - generally reusing the same instant-based logic, so index ordering and range queries are unaffected. Backward compatible: a DateTimeOffset field written before this change is still tagged BsonType.DateTime on disk (offset already unrecoverable, not something a reader can fix retroactively) and keeps decoding exactly as it does today (Offset=0) - only newly-written values gain a correct offset. Fixes #140 Co-Authored-By: Claude Sonnet 5 --- src/BLite.Bson/BsonSpanReader.cs | 40 ++++++++++- src/BLite.Bson/BsonSpanWriter.cs | 18 ++++- src/BLite.Bson/BsonType.cs | 11 ++++ src/BLite.Bson/BsonValue.cs | 52 +++++++++++++-- .../Collections/BsonSchemaGenerator.cs | 3 +- .../Collections/DocumentCollection.cs | 4 +- src/BLite.Core/DynamicCollection.cs | 8 ++- src/BLite.Core/Query/Blql/BlqlFilter.cs | 3 +- .../Query/Blql/BsonValueComparer.cs | 14 +++- .../Query/BsonExpressionEvaluator.cs | 11 +++- .../Query/BsonProjectionCompiler.cs | 4 ++ .../Storage/StorageEngine.Collections.cs | 1 + .../Storage/StorageEngine.TimeSeries.cs | 2 +- src/BLite.Core/Text/TextNormalizer.cs | 1 + src/BLite.SourceGenerators/CodeGenerator.cs | 7 +- .../BLite.Tests/BsonSpanReaderWriterTests.cs | 66 +++++++++++++++++++ tests/BLite.Tests/BsonValueTests.cs | 2 +- 17 files changed, 224 insertions(+), 23 deletions(-) diff --git a/src/BLite.Bson/BsonSpanReader.cs b/src/BLite.Bson/BsonSpanReader.cs index aab4d08d..559d22c7 100644 --- a/src/BLite.Bson/BsonSpanReader.cs +++ b/src/BLite.Bson/BsonSpanReader.cs @@ -209,6 +209,17 @@ public int ReadInt32() return value; } + /// Not a standalone BSON element type - used only for the offset-in-minutes trailer in . + private short ReadInt16() + { + if (Remaining < 2) + throw new InvalidOperationException("Not enough bytes to read Int16"); + + var value = BinaryPrimitives.ReadInt16LittleEndian(_buffer.Slice(_position, 2)); + _position += 2; + return value; + } + public long ReadInt64() { if (Remaining < 8) @@ -323,7 +334,11 @@ public DateTime ReadDateTime() } /// - /// Reads a BSON DateTime as DateTimeOffset (UTC milliseconds since Unix epoch) + /// Reads a legacy BSON DateTime field (8-byte UTC millisecond timestamp) as a DateTimeOffset. + /// There is no offset on the wire in this format, so the result always has Offset=0 - use + /// when the wire type is known, so a value written + /// by WriteDateTimeOffset (tagged ) comes back with + /// its original offset instead. /// public DateTimeOffset ReadDateTimeOffset() { @@ -331,6 +346,26 @@ public DateTimeOffset ReadDateTimeOffset() return DateTimeOffset.FromUnixTimeMilliseconds(milliseconds); } + /// + /// Reads a DateTimeOffset field, coercing a legacy wire value + /// (8 bytes, no offset - always predates this type, see ) + /// the same way does, or decoding the full 10-byte + /// format (UTC milliseconds + signed offset-in-minutes) + /// when the offset was actually preserved on write. + /// + public DateTimeOffset ReadDateTimeOffset(BsonType bsonType) + { + if (bsonType != BsonType.DateTimeOffset) + { + return ReadDateTimeOffset(); + } + + var milliseconds = ReadInt64(); + var offsetMinutes = ReadInt16(); + var instant = DateTimeOffset.FromUnixTimeMilliseconds(milliseconds); + return instant.ToOffset(TimeSpan.FromMinutes(offsetMinutes)); + } + /// /// Reads a TimeSpan from BSON Int64 (ticks) /// @@ -433,6 +468,9 @@ public void SkipValue(BsonType type) case BsonType.Timestamp: _position += 8; break; + case BsonType.DateTimeOffset: + _position += 10; // 8-byte UTC millisecond timestamp + 2-byte offset-in-minutes + break; case BsonType.Decimal128: _position += 16; break; diff --git a/src/BLite.Bson/BsonSpanWriter.cs b/src/BLite.Bson/BsonSpanWriter.cs index 72b6c9c3..e76bf5ae 100644 --- a/src/BLite.Bson/BsonSpanWriter.cs +++ b/src/BLite.Bson/BsonSpanWriter.cs @@ -255,12 +255,22 @@ public void WriteDateTime(string name, DateTime value) _position += 8; } + /// + /// Writes a UTC instant plus its original offset (10 bytes: 8-byte UTC millisecond timestamp, + /// same layout as , followed by a 2-byte signed offset in minutes). + /// Tagged , not , so + /// and typed readers know an extra 2 bytes follow and + /// can reconstruct the original wall-clock value instead of always normalising to UTC. + /// public void WriteDateTimeOffset(string name, DateTimeOffset value) { - WriteElementHeader(BsonType.DateTime, name); + WriteElementHeader(BsonType.DateTimeOffset, name); var milliseconds = value.ToUnixTimeMilliseconds(); BinaryPrimitives.WriteInt64LittleEndian(_buffer.Slice(_position, 8), milliseconds); _position += 8; + var offsetMinutes = (short)value.Offset.TotalMinutes; + BinaryPrimitives.WriteInt16LittleEndian(_buffer.Slice(_position, 2), offsetMinutes); + _position += 2; } public void WriteTimeSpan(string name, TimeSpan value) @@ -448,12 +458,16 @@ public void WriteArrayDateTime(int index, DateTime value) _position += 8; } + /// Array counterpart of - see its remarks. public void WriteArrayDateTimeOffset(int index, DateTimeOffset value) { - WriteArrayElementHeader(BsonType.DateTime, index); + WriteArrayElementHeader(BsonType.DateTimeOffset, index); var milliseconds = value.ToUnixTimeMilliseconds(); BinaryPrimitives.WriteInt64LittleEndian(_buffer.Slice(_position, 8), milliseconds); _position += 8; + var offsetMinutes = (short)value.Offset.TotalMinutes; + BinaryPrimitives.WriteInt16LittleEndian(_buffer.Slice(_position, 2), offsetMinutes); + _position += 2; } public void WriteArrayTimeSpan(int index, TimeSpan value) diff --git a/src/BLite.Bson/BsonType.cs b/src/BLite.Bson/BsonType.cs index 729199a6..fa64c8e6 100644 --- a/src/BLite.Bson/BsonType.cs +++ b/src/BLite.Bson/BsonType.cs @@ -25,6 +25,17 @@ public enum BsonType : byte Timestamp = 0x11, Int64 = 0x12, Decimal128 = 0x13, + + /// + /// BLite extension, not part of the BSON spec (0x14 is unassigned there). Distinct from + /// because it carries a UTC-offset alongside the instant - see + /// / BsonSpanWriter.WriteDateTimeOffset. + /// A value written before this type existed is still tagged + /// on disk (offset already lost at write time, not recoverable) - + /// readers must keep handling that tag for fields too. + /// + DateTimeOffset = 0x14, + MinKey = 0xFF, MaxKey = 0x7F } diff --git a/src/BLite.Bson/BsonValue.cs b/src/BLite.Bson/BsonValue.cs index ce0be431..9b635f5e 100644 --- a/src/BLite.Bson/BsonValue.cs +++ b/src/BLite.Bson/BsonValue.cs @@ -43,7 +43,15 @@ private BsonValue(BsonType type, double numericValue = 0, object? refValue = nul public static BsonValue FromBoolean(bool value) => new(BsonType.Boolean, value ? 1 : 0); public static BsonValue FromObjectId(ObjectId value) => new(BsonType.ObjectId, refValue: value); public static BsonValue FromDateTime(DateTime value) => new(BsonType.DateTime, BitConverter.Int64BitsToDouble(new DateTimeOffset(value.ToUniversalTime()).ToUnixTimeMilliseconds())); - public static BsonValue FromDateTimeOffset(DateTimeOffset value) => new(BsonType.DateTime, BitConverter.Int64BitsToDouble(value.ToUnixTimeMilliseconds())); + + /// + /// Tagged , not , so the + /// offset survives a round-trip through / instead of + /// being silently normalised to UTC. The offset itself rides in _refValue (boxed + /// minutes) since _numericValue already holds the packed instant. + /// + public static BsonValue FromDateTimeOffset(DateTimeOffset value) => + new(BsonType.DateTimeOffset, BitConverter.Int64BitsToDouble(value.ToUnixTimeMilliseconds()), (short)value.Offset.TotalMinutes); public static BsonValue FromGuid(Guid value) => new(BsonType.String, refValue: value.ToString()); public static BsonValue FromBinary(byte[] value) => new(BsonType.Binary, refValue: value ?? throw new ArgumentNullException(nameof(value))); public static BsonValue FromDocument(BsonDocument value) => new(BsonType.Document, refValue: value ?? throw new ArgumentNullException(nameof(value))); @@ -110,13 +118,29 @@ private BsonValue(BsonType type, double numericValue = 0, object? refValue = nul ? oid : throw new InvalidOperationException($"BsonValue is {_type}, not ObjectId"); - public DateTime AsDateTime => _type == BsonType.DateTime - ? DateTimeOffset.FromUnixTimeMilliseconds(BitConverter.DoubleToInt64Bits(_numericValue)).UtcDateTime - : throw new InvalidOperationException($"BsonValue is {_type}, not DateTime"); + public DateTime AsDateTime => _type switch + { + BsonType.DateTime or BsonType.DateTimeOffset => + DateTimeOffset.FromUnixTimeMilliseconds(BitConverter.DoubleToInt64Bits(_numericValue)).UtcDateTime, + _ => throw new InvalidOperationException($"BsonValue is {_type}, not DateTime") + }; - public DateTimeOffset AsDateTimeOffset => _type == BsonType.DateTime - ? DateTimeOffset.FromUnixTimeMilliseconds(BitConverter.DoubleToInt64Bits(_numericValue)) - : throw new InvalidOperationException($"BsonValue is {_type}, not DateTime"); + /// + /// For (a value written before offsets were tracked, or a plain + /// field) the offset is unknown and comes back as 0 (UTC). For + /// the original offset (boxed in _refValue) is + /// restored via , which re-expresses the same instant - + /// it does not shift it. + /// + public DateTimeOffset AsDateTimeOffset => _type switch + { + BsonType.DateTimeOffset => + DateTimeOffset.FromUnixTimeMilliseconds(BitConverter.DoubleToInt64Bits(_numericValue)) + .ToOffset(TimeSpan.FromMinutes((short)_refValue!)), + BsonType.DateTime => + DateTimeOffset.FromUnixTimeMilliseconds(BitConverter.DoubleToInt64Bits(_numericValue)), + _ => throw new InvalidOperationException($"BsonValue is {_type}, not DateTime") + }; public byte[] AsBinary => _type == BsonType.Binary && _refValue is byte[] b ? b @@ -149,6 +173,7 @@ private BsonValue(BsonType type, double numericValue = 0, object? refValue = nul public bool IsDouble => Type == BsonType.Double; public bool IsBoolean => Type == BsonType.Boolean; public bool IsDateTime => Type == BsonType.DateTime; + public bool IsDateTimeOffset => Type == BsonType.DateTimeOffset; public bool IsDecimal => Type == BsonType.Decimal128; public bool IsBinary => Type == BsonType.Binary; public bool IsObjectId => Type == BsonType.ObjectId; @@ -196,6 +221,9 @@ public void WriteTo(ref BsonSpanWriter writer, string fieldName) var dt = DateTimeOffset.FromUnixTimeMilliseconds(BitConverter.DoubleToInt64Bits(_numericValue)).UtcDateTime; writer.WriteDateTime(fieldName, dt); break; + case BsonType.DateTimeOffset: + writer.WriteDateTimeOffset(fieldName, AsDateTimeOffset); + break; case BsonType.Binary: writer.WriteBinary(fieldName, (byte[])_refValue!); break; @@ -256,6 +284,9 @@ public void WriteToArray(ref BsonSpanWriter writer, int index) case BsonType.DateTime: writer.WriteArrayDateTime(index, DateTimeOffset.FromUnixTimeMilliseconds(BitConverter.DoubleToInt64Bits(_numericValue)).UtcDateTime); break; + case BsonType.DateTimeOffset: + writer.WriteArrayDateTimeOffset(index, AsDateTimeOffset); + break; case BsonType.Null: writer.WriteArrayNull(index); break; @@ -293,6 +324,7 @@ public static BsonValue ReadFrom(ref BsonSpanReader reader, BsonType type) BsonType.Boolean => FromBoolean(reader.ReadBoolean()), BsonType.ObjectId => FromObjectId(reader.ReadObjectId()), BsonType.DateTime => FromDateTimeOffset(reader.ReadDateTimeOffset()), + BsonType.DateTimeOffset => FromDateTimeOffset(reader.ReadDateTimeOffset(BsonType.DateTimeOffset)), BsonType.Null => Null, BsonType.Binary => FromBinary(reader.ReadBinary(out _).ToArray()), BsonType.Array => ReadArray(ref reader), @@ -335,6 +367,11 @@ public bool Equals(BsonValue other) { BsonType.Int32 or BsonType.Int64 or BsonType.Double or BsonType.Boolean or BsonType.DateTime => _numericValue == other._numericValue, + // Unlike DateTimeOffset.Equals (instant-only), the offset is treated as significant here: + // it's the whole reason this type exists, so two values landing on the same instant but + // written with a different offset are NOT the same BsonValue. Keeps this consistent with + // GetHashCode, which already combines both _numericValue and _refValue (the boxed offset). + BsonType.DateTimeOffset => _numericValue == other._numericValue && (short)_refValue! == (short)other._refValue!, BsonType.String => string.Equals((string?)_refValue, (string?)other._refValue, StringComparison.Ordinal), BsonType.ObjectId => Equals(_refValue, other._refValue), BsonType.Null => true, @@ -361,6 +398,7 @@ public override string ToString() BsonType.Boolean => (_numericValue != 0).ToString(), BsonType.ObjectId => _refValue?.ToString() ?? "(null)", BsonType.DateTime => AsDateTime.ToString("O"), + BsonType.DateTimeOffset => AsDateTimeOffset.ToString("O"), BsonType.Null => "null", _ => $"({_type})" }; diff --git a/src/BLite.Core/Collections/BsonSchemaGenerator.cs b/src/BLite.Core/Collections/BsonSchemaGenerator.cs index 07c969a7..b4efc13c 100644 --- a/src/BLite.Core/Collections/BsonSchemaGenerator.cs +++ b/src/BLite.Core/Collections/BsonSchemaGenerator.cs @@ -115,7 +115,8 @@ private static (BsonType type, BsonSchema? nested, BsonType? itemType) GetBsonTy if (type == typeof(bool)) return (BsonType.Boolean, null, null); if (type == typeof(double)) return (BsonType.Double, null, null); if (type == typeof(decimal)) return (BsonType.Decimal128, null, null); - if (type == typeof(DateTime) || type == typeof(DateTimeOffset)) return (BsonType.DateTime, null, null); + if (type == typeof(DateTime)) return (BsonType.DateTime, null, null); + if (type == typeof(DateTimeOffset)) return (BsonType.DateTimeOffset, null, null); if (type == typeof(Guid)) return (BsonType.Binary, null, null); // Guid is usually Binary subtype if (type == typeof(byte[])) return (BsonType.Binary, null, null); diff --git a/src/BLite.Core/Collections/DocumentCollection.cs b/src/BLite.Core/Collections/DocumentCollection.cs index f02d6ff2..7d0f6c3f 100644 --- a/src/BLite.Core/Collections/DocumentCollection.cs +++ b/src/BLite.Core/Collections/DocumentCollection.cs @@ -408,7 +408,7 @@ private long ExtractTimestampTicks(byte[] bsonBytes, string fieldName) var value = BsonValue.ReadFrom(ref reader, seekType); return value.Type switch { - BsonType.DateTime => value.AsDateTime.Ticks, + BsonType.DateTime or BsonType.DateTimeOffset => value.AsDateTime.Ticks, BsonType.Int64 => value.AsInt64, _ => 0 }; @@ -429,7 +429,7 @@ private long ExtractTimestampTicks(byte[] bsonBytes, string fieldName) var val = BsonValue.ReadFrom(ref reader, type); return val.Type switch { - BsonType.DateTime => val.AsDateTime.Ticks, + BsonType.DateTime or BsonType.DateTimeOffset => val.AsDateTime.Ticks, BsonType.Int64 => val.AsInt64, _ => 0 }; diff --git a/src/BLite.Core/DynamicCollection.cs b/src/BLite.Core/DynamicCollection.cs index 5711a0e3..81a07a6d 100644 --- a/src/BLite.Core/DynamicCollection.cs +++ b/src/BLite.Core/DynamicCollection.cs @@ -423,7 +423,7 @@ private async Task ApplyRetentionPolicyCoreAsync(CancellationToken ct) { ticks = tsVal.Type switch { - BsonType.DateTime => tsVal.AsDateTime.Ticks, + BsonType.DateTime or BsonType.DateTimeOffset => tsVal.AsDateTime.Ticks, BsonType.Int64 => tsVal.AsInt64, _ => 0 }; @@ -2001,8 +2001,10 @@ private void SnapshotFsiForTransaction(ITransaction transaction, uint pageId) BsonType.ObjectId => new IndexKey(value.AsObjectId), BsonType.Double => new IndexKey(BitConverter.GetBytes(value.AsDouble)), // DateTime is stored as BitConverter.Int64BitsToDouble(unixMs); key on the raw long - // to get the same ordering used by ToIndexObject in BlqlFilter. - BsonType.DateTime => new IndexKey(value.AsDateTimeOffset.ToUnixTimeMilliseconds()), + // to get the same ordering used by ToIndexObject in BlqlFilter. Deliberately keyed by + // instant, not offset, for DateTimeOffset too - the offset must not perturb range-scan + // ordering, only how the value is displayed once read back. + BsonType.DateTime or BsonType.DateTimeOffset => new IndexKey(value.AsDateTimeOffset.ToUnixTimeMilliseconds()), _ => null // Can't index this type as BTree key }; } diff --git a/src/BLite.Core/Query/Blql/BlqlFilter.cs b/src/BLite.Core/Query/Blql/BlqlFilter.cs index e13a99c1..672b1f10 100644 --- a/src/BLite.Core/Query/Blql/BlqlFilter.cs +++ b/src/BLite.Core/Query/Blql/BlqlFilter.cs @@ -384,7 +384,8 @@ internal override bool TryGetIndexCandidate(out IndexScanCandidate candidate) BsonType.ObjectId => v.AsObjectId, // DateTime is stored as BitConverter.Int64BitsToDouble(unixMs); extract as long so // CreateIndexKeyFromObject can create a comparable IndexKey(long) for BTree range scans. - BsonType.DateTime => v.AsDateTimeOffset.ToUnixTimeMilliseconds(), + // Keyed by instant for DateTimeOffset too, matching DynamicCollection.ToIndexObject. + BsonType.DateTime or BsonType.DateTimeOffset => v.AsDateTimeOffset.ToUnixTimeMilliseconds(), _ => null }; } diff --git a/src/BLite.Core/Query/Blql/BsonValueComparer.cs b/src/BLite.Core/Query/Blql/BsonValueComparer.cs index d0153503..f2814420 100644 --- a/src/BLite.Core/Query/Blql/BsonValueComparer.cs +++ b/src/BLite.Core/Query/Blql/BsonValueComparer.cs @@ -26,6 +26,14 @@ public static int Compare(BsonValue a, BsonValue b) if (IsNumeric(a.Type) && IsNumeric(b.Type)) return ToDouble(a).CompareTo(ToDouble(b)); + // DateTime/DateTimeOffset: compare by instant across the two tags too, same as the numeric + // family above. A collection can hold both during the transition after upgrading BLite - a + // DateTimeOffset value written before this type existed stays tagged DateTime forever - so + // this must not fall through to TypeOrder below, which would sort DateTimeOffset-tagged rows + // as unrelated to DateTime-tagged ones instead of interleaving them by actual time. + if (IsDateLike(a.Type) && IsDateLike(b.Type)) + return a.AsDateTimeOffset.CompareTo(b.AsDateTimeOffset); + // Same type comparisons if (a.Type == b.Type) { @@ -33,7 +41,7 @@ public static int Compare(BsonValue a, BsonValue b) { BsonType.String => string.Compare(a.AsString, b.AsString, StringComparison.Ordinal), BsonType.Boolean => a.AsBoolean.CompareTo(b.AsBoolean), - BsonType.DateTime => a.AsDateTimeOffset.CompareTo(b.AsDateTimeOffset), + BsonType.DateTime or BsonType.DateTimeOffset => a.AsDateTimeOffset.CompareTo(b.AsDateTimeOffset), BsonType.ObjectId => CompareObjectIds(a.AsObjectId, b.AsObjectId), BsonType.Binary => CompareBytes(a.AsBinary, b.AsBinary), _ => 0 @@ -47,6 +55,9 @@ public static int Compare(BsonValue a, BsonValue b) private static bool IsNumeric(BsonType t) => t is BsonType.Int32 or BsonType.Int64 or BsonType.Double or BsonType.Decimal128; + private static bool IsDateLike(BsonType t) => + t is BsonType.DateTime or BsonType.DateTimeOffset; + private static double ToDouble(BsonValue v) => v.Type switch { BsonType.Int32 => v.AsInt32, @@ -67,6 +78,7 @@ private static bool IsNumeric(BsonType t) => BsonType.String => 3, BsonType.ObjectId => 4, BsonType.DateTime => 5, + BsonType.DateTimeOffset => 5, BsonType.Binary => 6, BsonType.Document => 7, BsonType.Array => 8, diff --git a/src/BLite.Core/Query/BsonExpressionEvaluator.cs b/src/BLite.Core/Query/BsonExpressionEvaluator.cs index ae3a88e5..30a3e1fc 100644 --- a/src/BLite.Core/Query/BsonExpressionEvaluator.cs +++ b/src/BLite.Core/Query/BsonExpressionEvaluator.cs @@ -596,6 +596,7 @@ private static BsonReaderPredicate CreateIsNullOrEmptyPredicate(string fieldName BsonType.Boolean => reader.ReadBoolean(), BsonType.ObjectId => reader.ReadObjectId(), BsonType.DateTime => reader.ReadDateTime(), + BsonType.DateTimeOffset => reader.ReadDateTimeOffset(BsonType.DateTimeOffset), BsonType.Null => null, _ => null }; @@ -652,6 +653,7 @@ internal static BsonReaderPredicate CreateInPredicateDirect( BsonType.Boolean => reader.ReadBoolean(), BsonType.ObjectId => reader.ReadObjectId(), BsonType.DateTime => reader.ReadDateTime(), + BsonType.DateTimeOffset => reader.ReadDateTimeOffset(BsonType.DateTimeOffset), BsonType.Null => null, _ => null }; @@ -1087,9 +1089,14 @@ private static bool Compare(ref BsonSpanReader reader, BsonType type, object? ta }; } } - else if (type == BsonType.DateTime) + else if (type == BsonType.DateTime || type == BsonType.DateTimeOffset) { - var val = reader.ReadDateTime(); + // Both tags land here: the comparisons below normalise everything to UTC ticks, so the + // offset (only relevant to how a DateTimeOffset value is displayed, not compared) doesn't + // need special handling once decoded - only the read itself must pick the matching layout. + var val = type == BsonType.DateTimeOffset + ? reader.ReadDateTimeOffset(BsonType.DateTimeOffset).UtcDateTime + : reader.ReadDateTime(); if (target is DateTime targetDt) { // Normalise both sides to UTC ticks for a reliable comparison. diff --git a/src/BLite.Core/Query/BsonProjectionCompiler.cs b/src/BLite.Core/Query/BsonProjectionCompiler.cs index b4e5f79e..db0cf07b 100644 --- a/src/BLite.Core/Query/BsonProjectionCompiler.cs +++ b/src/BLite.Core/Query/BsonProjectionCompiler.cs @@ -147,6 +147,9 @@ internal static class BsonProjectionCompiler BsonType.ObjectId => reader.ReadObjectId(), BsonType.Boolean => reader.ReadBoolean(), BsonType.DateTime => reader.ReadDateTime(), + // Own case, not folded into DateTime above: the wire layouts differ (10 + // bytes vs 8), reading the wrong one desyncs every field read after it. + BsonType.DateTimeOffset => (object?)reader.ReadDateTimeOffset(BsonType.DateTimeOffset), BsonType.Int32 => reader.ReadInt32(), BsonType.Int64 => reader.ReadInt64(), BsonType.Decimal128 => reader.ReadDecimal128(), @@ -178,6 +181,7 @@ internal static class BsonProjectionCompiler case BsonType.ObjectId: values[idx] = reader.ReadObjectId(); break; case BsonType.Boolean: values[idx] = reader.ReadBoolean(); break; case BsonType.DateTime: values[idx] = reader.ReadDateTime(); break; + case BsonType.DateTimeOffset: values[idx] = reader.ReadDateTimeOffset(BsonType.DateTimeOffset); break; case BsonType.Int32: values[idx] = reader.ReadInt32(); break; case BsonType.Int64: values[idx] = reader.ReadInt64(); break; case BsonType.Decimal128: values[idx] = reader.ReadDecimal128(); break; diff --git a/src/BLite.Core/Storage/StorageEngine.Collections.cs b/src/BLite.Core/Storage/StorageEngine.Collections.cs index a0b0a3e0..6834d957 100644 --- a/src/BLite.Core/Storage/StorageEngine.Collections.cs +++ b/src/BLite.Core/Storage/StorageEngine.Collections.cs @@ -67,6 +67,7 @@ public string BuildText(BsonDocument document) BsonType.Array => string.Join(" ", value.AsArray.Select(ValueToString)), BsonType.ObjectId => value.AsObjectId.ToString(), BsonType.DateTime => value.AsDateTime.ToString("O"), + BsonType.DateTimeOffset => value.AsDateTimeOffset.ToString("O"), _ => null } ?? string.Empty; } diff --git a/src/BLite.Core/Storage/StorageEngine.TimeSeries.cs b/src/BLite.Core/Storage/StorageEngine.TimeSeries.cs index 1c4360cb..dc2dbbe8 100644 --- a/src/BLite.Core/Storage/StorageEngine.TimeSeries.cs +++ b/src/BLite.Core/Storage/StorageEngine.TimeSeries.cs @@ -23,7 +23,7 @@ public DocumentLocation InsertTimeSeries(string collectionName, BsonDocument doc long timestamp = 0; if (meta.TtlFieldName != null && document.TryGetValue(meta.TtlFieldName, out var val)) { - if (val.Type == BsonType.DateTime) + if (val.Type == BsonType.DateTime || val.Type == BsonType.DateTimeOffset) timestamp = val.AsDateTime.Ticks; else if (val.Type == BsonType.Int64) timestamp = val.AsInt64; diff --git a/src/BLite.Core/Text/TextNormalizer.cs b/src/BLite.Core/Text/TextNormalizer.cs index 487cc443..b21678a2 100644 --- a/src/BLite.Core/Text/TextNormalizer.cs +++ b/src/BLite.Core/Text/TextNormalizer.cs @@ -110,6 +110,7 @@ public static string BuildEmbeddingText(BsonDocument document, VectorSourceConfi BsonType.Array => string.Join(" ", value.AsArray.Select(BsonValueToString)), BsonType.ObjectId => value.AsObjectId.ToString(), BsonType.DateTime => value.AsDateTime.ToString("O"), + BsonType.DateTimeOffset => value.AsDateTimeOffset.ToString("O"), _ => string.Empty }; } diff --git a/src/BLite.SourceGenerators/CodeGenerator.cs b/src/BLite.SourceGenerators/CodeGenerator.cs index 9bd004fd..71c2043c 100644 --- a/src/BLite.SourceGenerators/CodeGenerator.cs +++ b/src/BLite.SourceGenerators/CodeGenerator.cs @@ -1173,7 +1173,12 @@ private static string GetBaseMapperClass(PropertyInfo? keyProp, EntityInfo entit } private static bool IsCoercedReadMethod(string? readMethod) - => readMethod is "ReadInt32Coerced" or "ReadInt64Coerced" or "ReadDoubleCoerced"; + // ReadDateTimeOffset(BsonType) isn't "coerced" in the numeric-widening sense of the others, + // but needs the wire type for the same reason: a DateTimeOffset property predating this type + // may still be stored under the legacy BsonType.DateTime tag (offset already lost at write + // time, not recoverable) and must decode with that tag's 8-byte layout instead of the new + // 10-byte one - see BsonType.DateTimeOffset. + => readMethod is "ReadInt32Coerced" or "ReadInt64Coerced" or "ReadDoubleCoerced" or "ReadDateTimeOffset"; private static bool IsValueType(string typeName) { diff --git a/tests/BLite.Tests/BsonSpanReaderWriterTests.cs b/tests/BLite.Tests/BsonSpanReaderWriterTests.cs index aceb83e1..d2003f0e 100644 --- a/tests/BLite.Tests/BsonSpanReaderWriterTests.cs +++ b/tests/BLite.Tests/BsonSpanReaderWriterTests.cs @@ -154,6 +154,72 @@ public void WriteAndRead_DateTime() Assert.Equal(expectedTime, readTime); } + [Fact] + public void WriteAndRead_DateTimeOffset_PreservesOffset() + { + Span buffer = stackalloc byte[256]; + var writer = new BsonSpanWriter(buffer, _keyMap); + + // A non-UTC offset (e.g. CEST, UTC+2): the instant this represents is 18:00 UTC, and the + // wall-clock value callers expect back is 20:00+02:00, not 18:00+00:00. + var original = new DateTimeOffset(2026, 6, 15, 20, 0, 0, TimeSpan.FromHours(2)); + + var sizePos = writer.BeginDocument(); + writer.WriteDateTimeOffset("timestamp", original); + writer.EndDocument(sizePos); + + var documentBytes = buffer[..writer.Position]; + var reader = new BsonSpanReader(documentBytes, _keys); + + reader.ReadDocumentSize(); + var type = reader.ReadBsonType(); + reader.ReadElementHeader(); + + // The wire tag is BsonType.DateTimeOffset - a value written by WriteDateTimeOffset never + // uses the plain BsonType.DateTime tag, so a reader can always tell whether the offset was + // actually preserved on write, without knowing the C# property type in advance. + Assert.Equal(BsonType.DateTimeOffset, type); + + var readBack = reader.ReadDateTimeOffset(type); + + // Both the instant and the original offset must survive the round-trip. + Assert.Equal(original.ToUniversalTime(), readBack.ToUniversalTime()); + Assert.Equal(original.Offset, readBack.Offset); + Assert.Equal(original, readBack); + } + + [Fact] + public void ReadDateTimeOffset_LegacyDateTimeTag_StillDecodesAsOffsetZero() + { + // Back-compat characterisation: a DateTimeOffset field written before BsonType.DateTimeOffset + // existed is on disk under the plain BsonType.DateTime tag (8 bytes, no offset - the offset + // was already lost forever at write time, not something a reader can recover). Reading that + // legacy tag through the new ReadDateTimeOffset(BsonType) overload must decode it exactly as + // it always has - Offset=0 - rather than misreading it as the new 10-byte layout. + Span buffer = stackalloc byte[256]; + var writer = new BsonSpanWriter(buffer, _keyMap); + + var legacyUtc = new DateTime(2026, 6, 15, 18, 0, 0, DateTimeKind.Utc); + + var sizePos = writer.BeginDocument(); + writer.WriteDateTime("timestamp", legacyUtc); + writer.EndDocument(sizePos); + + var documentBytes = buffer[..writer.Position]; + var reader = new BsonSpanReader(documentBytes, _keys); + + reader.ReadDocumentSize(); + var type = reader.ReadBsonType(); + reader.ReadElementHeader(); + + Assert.Equal(BsonType.DateTime, type); + + var readBack = reader.ReadDateTimeOffset(type); + + Assert.Equal(legacyUtc, readBack.UtcDateTime); + Assert.Equal(TimeSpan.Zero, readBack.Offset); + } + [Fact] public void WriteAndRead_NumericTypes() { diff --git a/tests/BLite.Tests/BsonValueTests.cs b/tests/BLite.Tests/BsonValueTests.cs index d2576e92..62b8b983 100644 --- a/tests/BLite.Tests/BsonValueTests.cs +++ b/tests/BLite.Tests/BsonValueTests.cs @@ -202,7 +202,7 @@ public void FromDateTimeOffset_RoundTrips_ThroughUnixMs() { var dto = new DateTimeOffset(2025, 6, 15, 8, 30, 0, TimeSpan.Zero); var v = BsonValue.FromDateTimeOffset(dto); - Assert.Equal(BsonType.DateTime, v.Type); + Assert.Equal(BsonType.DateTimeOffset, v.Type); Assert.Equal(dto, v.AsDateTimeOffset); } From d2a6f305a01d8255c65f9fa34a190c75e9e86626 Mon Sep 17 00:00:00 2001 From: MrDevRobot Date: Thu, 3 Sep 2026 22:27:49 +0200 Subject: [PATCH 2/2] fix: keep legacy DateTime wire values tagged DateTime in BsonValue.ReadFrom BsonValue.ReadFrom decoded a wire BsonType.DateTime value via FromDateTimeOffset, which now tags BsonType.DateTimeOffset. That silently flipped the in-memory type for every plain DateTime field (and every legacy DateTimeOffset value still under the old tag), so a later WriteTo would re-emit it in the new 10-byte layout without ever having recovered an offset - an unintended, silent wire-format upgrade on read-then-write. Decode that arm via FromDateTime instead, matching the wire tag actually read and reproducing the original 8-byte format on WriteTo. Found by Copilot's review on #141. Co-Authored-By: Claude Sonnet 5 --- src/BLite.Bson/BsonValue.cs | 7 +++- .../BLite.Tests/BsonSpanReaderWriterTests.cs | 41 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/BLite.Bson/BsonValue.cs b/src/BLite.Bson/BsonValue.cs index 9b635f5e..6d5a73b0 100644 --- a/src/BLite.Bson/BsonValue.cs +++ b/src/BLite.Bson/BsonValue.cs @@ -323,7 +323,12 @@ public static BsonValue ReadFrom(ref BsonSpanReader reader, BsonType type) BsonType.String => FromString(reader.ReadString()), BsonType.Boolean => FromBoolean(reader.ReadBoolean()), BsonType.ObjectId => FromObjectId(reader.ReadObjectId()), - BsonType.DateTime => FromDateTimeOffset(reader.ReadDateTimeOffset()), + // Must stay tagged BsonType.DateTime, matching the wire tag just read - not + // FromDateTimeOffset, which now tags BsonType.DateTimeOffset. Using it here would flip + // the in-memory type for every plain DateTime field (and every legacy DateTimeOffset + // value still under the old tag), so a later WriteTo would silently re-emit it in the + // new 10-byte layout even though nothing about its offset was actually recovered. + BsonType.DateTime => FromDateTime(reader.ReadDateTime()), BsonType.DateTimeOffset => FromDateTimeOffset(reader.ReadDateTimeOffset(BsonType.DateTimeOffset)), BsonType.Null => Null, BsonType.Binary => FromBinary(reader.ReadBinary(out _).ToArray()), diff --git a/tests/BLite.Tests/BsonSpanReaderWriterTests.cs b/tests/BLite.Tests/BsonSpanReaderWriterTests.cs index d2003f0e..5f083b44 100644 --- a/tests/BLite.Tests/BsonSpanReaderWriterTests.cs +++ b/tests/BLite.Tests/BsonSpanReaderWriterTests.cs @@ -220,6 +220,47 @@ public void ReadDateTimeOffset_LegacyDateTimeTag_StillDecodesAsOffsetZero() Assert.Equal(TimeSpan.Zero, readBack.Offset); } + [Fact] + public void BsonValue_ReadFrom_LegacyDateTimeTag_StaysTaggedDateTime() + { + // Regression for a review finding on the DateTimeOffset fix: BsonValue.ReadFrom must not + // reuse FromDateTimeOffset (now tagged BsonType.DateTimeOffset) to decode a wire-level plain + // BsonType.DateTime value - doing so would flip the in-memory type for every DateTime field + // and cause a later WriteTo to silently upgrade it to the new 10-byte layout on disk, even + // though nothing about an offset was actually recovered. + Span buffer = stackalloc byte[256]; + var writer = new BsonSpanWriter(buffer, _keyMap); + + var legacyUtc = new DateTime(2026, 6, 15, 18, 0, 0, DateTimeKind.Utc); + + var sizePos = writer.BeginDocument(); + writer.WriteDateTime("timestamp", legacyUtc); + writer.EndDocument(sizePos); + + var documentBytes = buffer[..writer.Position]; + var reader = new BsonSpanReader(documentBytes, _keys); + + reader.ReadDocumentSize(); + var type = reader.ReadBsonType(); + reader.ReadElementHeader(); + + var value = BsonValue.ReadFrom(ref reader, type); + + Assert.Equal(BsonType.DateTime, value.Type); + Assert.False(value.IsDateTimeOffset); + + // Writing it back must reproduce the original 8-byte BsonType.DateTime wire format, not + // silently upgrade to the new 10-byte BsonType.DateTimeOffset layout. + Span rewriteBuffer = stackalloc byte[256]; + var rewriter = new BsonSpanWriter(rewriteBuffer, _keyMap); + var rewriteSizePos = rewriter.BeginDocument(); + value.WriteTo(ref rewriter, "timestamp"); + rewriter.EndDocument(rewriteSizePos); + + var rewrittenBytes = rewriteBuffer[..rewriter.Position]; + Assert.True(documentBytes.SequenceEqual(rewrittenBytes)); + } + [Fact] public void WriteAndRead_NumericTypes() {