From 85ef96248358ec211ade9a00bdca0d1d4777b6be Mon Sep 17 00:00:00 2001 From: Miguel Tremblay Date: Wed, 28 Jan 2026 00:28:45 -0500 Subject: [PATCH 1/2] Remove remaining BSON attributes and use conventions - Remove BsonIgnoreIfNull and BsonRepresentation attributes from document - Add IgnoreIfNullConvention to handle null property serialization - Register GuidSerializer with BsonType.String for Guid representation - Keep only [BsonId] attribute (required to identify document ID) Co-Authored-By: Claude Opus 4.5 --- .../RecurringThingsBuilderExtensions.cs | 16 +++++++++++++--- .../Documents/RecurringThingDocument.cs | 18 ++---------------- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/src/RecurringThings.MongoDB/Configuration/RecurringThingsBuilderExtensions.cs b/src/RecurringThings.MongoDB/Configuration/RecurringThingsBuilderExtensions.cs index 11738cf..441a590 100644 --- a/src/RecurringThings.MongoDB/Configuration/RecurringThingsBuilderExtensions.cs +++ b/src/RecurringThings.MongoDB/Configuration/RecurringThingsBuilderExtensions.cs @@ -1,7 +1,10 @@ namespace RecurringThings.MongoDB.Configuration; using System; +using global::MongoDB.Bson; +using global::MongoDB.Bson.Serialization; using global::MongoDB.Bson.Serialization.Conventions; +using global::MongoDB.Bson.Serialization.Serializers; using global::MongoDB.Driver; using Microsoft.Extensions.DependencyInjection; using RecurringThings.Configuration; @@ -44,13 +47,20 @@ public static RecurringThingsBuilder UseMongoDb( configure(options); options.Validate(); - // Register camelCase naming convention for RecurringThings documents - var conventionPack = new ConventionPack { new CamelCaseElementNameConvention() }; + // Register conventions for RecurringThings documents + var conventionPack = new ConventionPack + { + new CamelCaseElementNameConvention(), + new IgnoreIfNullConvention(true) + }; ConventionRegistry.Register( - "RecurringThingsCamelCase", + "RecurringThingsConventions", conventionPack, t => t.FullName?.StartsWith("RecurringThings", StringComparison.Ordinal) == true); + // Register Guid serializer to use string representation + BsonSerializer.TryRegisterSerializer(new GuidSerializer(BsonType.String)); + // Mark MongoDB as configured builder.MongoDbConfigured = true; diff --git a/src/RecurringThings.MongoDB/Documents/RecurringThingDocument.cs b/src/RecurringThings.MongoDB/Documents/RecurringThingDocument.cs index 22f8b44..31917e6 100644 --- a/src/RecurringThings.MongoDB/Documents/RecurringThingDocument.cs +++ b/src/RecurringThings.MongoDB/Documents/RecurringThingDocument.cs @@ -2,7 +2,6 @@ namespace RecurringThings.MongoDB.Documents; using System; using System.Collections.Generic; -using global::MongoDB.Bson; using global::MongoDB.Bson.Serialization.Attributes; using RecurringThings.Domain; using RecurringThings.Options; @@ -20,6 +19,8 @@ namespace RecurringThings.MongoDB.Documents; /// /// /// Fields are mapped to camelCase for MongoDB conventions. +/// Null properties are automatically ignored during serialization. +/// Guid properties are serialized as strings. /// /// public sealed class RecurringThingDocument @@ -28,7 +29,6 @@ public sealed class RecurringThingDocument /// Gets or sets the unique identifier. /// [BsonId] - [BsonRepresentation(BsonType.String)] public Guid Id { get; set; } /// @@ -55,7 +55,6 @@ public sealed class RecurringThingDocument /// /// Present on recurrences and occurrences. Null for exceptions and overrides. /// - [BsonIgnoreIfNull] public string? Type { get; set; } /// @@ -64,7 +63,6 @@ public sealed class RecurringThingDocument /// /// Present on recurrences, occurrences, and overrides. Null for exceptions. /// - [BsonIgnoreIfNull] public DateTime? StartTime { get; set; } /// @@ -74,7 +72,6 @@ public sealed class RecurringThingDocument /// Present on occurrences and overrides. Null for recurrences and exceptions. /// Computed as StartTime + Duration. /// - [BsonIgnoreIfNull] public DateTime? EndTime { get; set; } /// @@ -84,7 +81,6 @@ public sealed class RecurringThingDocument /// Stored as milliseconds for efficient MongoDB operations. /// Present on recurrences, occurrences, and overrides. Null for exceptions. /// - [BsonIgnoreIfNull] public long? DurationMs { get; set; } /// @@ -93,7 +89,6 @@ public sealed class RecurringThingDocument /// /// Present on recurrences and occurrences. Null for exceptions and overrides. /// - [BsonIgnoreIfNull] public string? TimeZone { get; set; } /// @@ -102,7 +97,6 @@ public sealed class RecurringThingDocument /// /// Present only on recurrences. /// - [BsonIgnoreIfNull] public DateTime? RecurrenceEndTime { get; set; } /// @@ -111,7 +105,6 @@ public sealed class RecurringThingDocument /// /// Present only on recurrences. /// - [BsonIgnoreIfNull] public string? RRule { get; set; } /// @@ -126,7 +119,6 @@ public sealed class RecurringThingDocument /// monthly patterns with day <= 28, or patterns where no months are affected). /// /// - [BsonIgnoreIfNull] public string? MonthDayBehavior { get; set; } /// @@ -135,8 +127,6 @@ public sealed class RecurringThingDocument /// /// Present on exceptions and overrides. /// - [BsonRepresentation(BsonType.String)] - [BsonIgnoreIfNull] public Guid? RecurrenceId { get; set; } /// @@ -145,7 +135,6 @@ public sealed class RecurringThingDocument /// /// Present on exceptions and overrides. /// - [BsonIgnoreIfNull] public DateTime? OriginalTimeUtc { get; set; } /// @@ -155,7 +144,6 @@ public sealed class RecurringThingDocument /// Denormalized from the parent recurrence at creation time. /// Present only on overrides. /// - [BsonIgnoreIfNull] public long? OriginalDurationMs { get; set; } /// @@ -165,13 +153,11 @@ public sealed class RecurringThingDocument /// Denormalized from the parent recurrence at creation time. /// Present only on overrides. /// - [BsonIgnoreIfNull] public Dictionary? OriginalExtensions { get; set; } /// /// Gets or sets the user-defined key-value metadata. /// - [BsonIgnoreIfNull] public Dictionary? Extensions { get; set; } } From 45e89ea367b84992905e247c402b516b4052135d Mon Sep 17 00:00:00 2001 From: Miguel Tremblay Date: Wed, 28 Jan 2026 00:43:40 -0500 Subject: [PATCH 2/2] Fix Guid serialization by using custom StringGuidSerializer The built-in GuidSerializer(BsonType.String) throws "GuidRepresentation is Unspecified" in newer MongoDB drivers. This commit: - Adds StringGuidSerializer that serializes GUIDs as strings - Adds GuidStringRepresentationConvention to apply it globally - Adds MongoDbInitializer to ensure conventions register early - Calls EnsureInitialized from all MongoDB component constructors - Removes [BsonId] attribute, using NamedIdMemberConvention instead Co-Authored-By: Claude Opus 4.5 --- .../GuidStringRepresentationConvention.cs | 47 ++++++++++++++ .../Configuration/MongoDbInitializer.cs | 62 +++++++++++++++++++ .../RecurringThingsBuilderExtensions.cs | 19 +----- .../Configuration/StringGuidSerializer.cs | 37 +++++++++++ .../Documents/RecurringThingDocument.cs | 12 ++-- .../Indexing/IndexManager.cs | 2 + .../MongoOccurrenceExceptionRepository.cs | 2 + .../MongoOccurrenceOverrideRepository.cs | 2 + .../Repositories/MongoOccurrenceRepository.cs | 2 + .../Repositories/MongoRecurrenceRepository.cs | 2 + 10 files changed, 164 insertions(+), 23 deletions(-) create mode 100644 src/RecurringThings.MongoDB/Configuration/GuidStringRepresentationConvention.cs create mode 100644 src/RecurringThings.MongoDB/Configuration/MongoDbInitializer.cs create mode 100644 src/RecurringThings.MongoDB/Configuration/StringGuidSerializer.cs diff --git a/src/RecurringThings.MongoDB/Configuration/GuidStringRepresentationConvention.cs b/src/RecurringThings.MongoDB/Configuration/GuidStringRepresentationConvention.cs new file mode 100644 index 0000000..7b7b11a --- /dev/null +++ b/src/RecurringThings.MongoDB/Configuration/GuidStringRepresentationConvention.cs @@ -0,0 +1,47 @@ +namespace RecurringThings.MongoDB.Configuration; + +using System; +using global::MongoDB.Bson.Serialization; +using global::MongoDB.Bson.Serialization.Conventions; +using global::MongoDB.Bson.Serialization.Serializers; + +/// +/// Convention that serializes all Guid properties as strings instead of binary. +/// +/// +/// +/// This convention applies to both and properties, +/// ensuring consistent string representation without requiring attributes on each property. +/// +/// +/// Uses instead of the built-in GuidSerializer to avoid +/// the "GuidRepresentation is Unspecified" error in newer MongoDB driver versions. +/// +/// +internal sealed class GuidStringRepresentationConvention : IMemberMapConvention +{ + private static readonly StringGuidSerializer GuidSerializer = new(); + + /// + /// Gets the name of this convention. + /// + public string Name => "GuidStringRepresentation"; + + /// + /// Applies the convention to a member map. + /// + /// The member map to apply the convention to. + public void Apply(BsonMemberMap memberMap) + { + ArgumentNullException.ThrowIfNull(memberMap); + + if (memberMap.MemberType == typeof(Guid)) + { + memberMap.SetSerializer(GuidSerializer); + } + else if (memberMap.MemberType == typeof(Guid?)) + { + memberMap.SetSerializer(new NullableSerializer(GuidSerializer)); + } + } +} diff --git a/src/RecurringThings.MongoDB/Configuration/MongoDbInitializer.cs b/src/RecurringThings.MongoDB/Configuration/MongoDbInitializer.cs new file mode 100644 index 0000000..1227173 --- /dev/null +++ b/src/RecurringThings.MongoDB/Configuration/MongoDbInitializer.cs @@ -0,0 +1,62 @@ +namespace RecurringThings.MongoDB.Configuration; + +using System; +using global::MongoDB.Bson.Serialization.Conventions; + +/// +/// Initializes MongoDB conventions when the assembly is loaded. +/// +/// +/// This ensures that conventions are registered before any serialization occurs, +/// regardless of whether is called. +/// +internal static class MongoDbInitializer +{ + private static bool _initialized; + private static readonly object _lock = new(); + + /// + /// Registers MongoDB conventions for RecurringThings documents. + /// + /// + /// This method is idempotent and can be called multiple times safely. + /// It registers the following conventions: + /// + /// NamedIdMemberConvention: Maps "Id" property to MongoDB "_id" field + /// CamelCaseElementNameConvention: Converts property names to camelCase + /// IgnoreIfNullConvention: Excludes null properties from serialization + /// GuidStringRepresentationConvention: Serializes Guid properties as strings + /// + /// + internal static void EnsureInitialized() + { + if (_initialized) + { + return; + } + + lock (_lock) + { + if (_initialized) + { + return; + } + + var conventionPack = new ConventionPack + { + new NamedIdMemberConvention("Id"), + new CamelCaseElementNameConvention(), + new IgnoreIfNullConvention(true), + new GuidStringRepresentationConvention() + }; + + ConventionRegistry.Register( + "RecurringThingsConventions", + conventionPack, + t => t.FullName?.StartsWith("RecurringThings", StringComparison.Ordinal) == true); + + _initialized = true; + } + } + +} diff --git a/src/RecurringThings.MongoDB/Configuration/RecurringThingsBuilderExtensions.cs b/src/RecurringThings.MongoDB/Configuration/RecurringThingsBuilderExtensions.cs index 441a590..754a93f 100644 --- a/src/RecurringThings.MongoDB/Configuration/RecurringThingsBuilderExtensions.cs +++ b/src/RecurringThings.MongoDB/Configuration/RecurringThingsBuilderExtensions.cs @@ -1,10 +1,6 @@ namespace RecurringThings.MongoDB.Configuration; using System; -using global::MongoDB.Bson; -using global::MongoDB.Bson.Serialization; -using global::MongoDB.Bson.Serialization.Conventions; -using global::MongoDB.Bson.Serialization.Serializers; using global::MongoDB.Driver; using Microsoft.Extensions.DependencyInjection; using RecurringThings.Configuration; @@ -47,19 +43,8 @@ public static RecurringThingsBuilder UseMongoDb( configure(options); options.Validate(); - // Register conventions for RecurringThings documents - var conventionPack = new ConventionPack - { - new CamelCaseElementNameConvention(), - new IgnoreIfNullConvention(true) - }; - ConventionRegistry.Register( - "RecurringThingsConventions", - conventionPack, - t => t.FullName?.StartsWith("RecurringThings", StringComparison.Ordinal) == true); - - // Register Guid serializer to use string representation - BsonSerializer.TryRegisterSerializer(new GuidSerializer(BsonType.String)); + // Ensure MongoDB conventions are registered (idempotent) + MongoDbInitializer.EnsureInitialized(); // Mark MongoDB as configured builder.MongoDbConfigured = true; diff --git a/src/RecurringThings.MongoDB/Configuration/StringGuidSerializer.cs b/src/RecurringThings.MongoDB/Configuration/StringGuidSerializer.cs new file mode 100644 index 0000000..2473a57 --- /dev/null +++ b/src/RecurringThings.MongoDB/Configuration/StringGuidSerializer.cs @@ -0,0 +1,37 @@ +namespace RecurringThings.MongoDB.Configuration; + +using System; +using global::MongoDB.Bson; +using global::MongoDB.Bson.Serialization; +using global::MongoDB.Bson.Serialization.Serializers; + +/// +/// A custom GUID serializer that stores GUIDs as strings in MongoDB. +/// +/// +/// This serializer is used instead of with +/// because the standard serializer throws "GuidRepresentation is Unspecified" in newer MongoDB drivers. +/// This implementation directly serializes/deserializes GUIDs as strings without the GuidRepresentation validation. +/// +internal sealed class StringGuidSerializer : StructSerializerBase +{ + /// + public override Guid Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) + { + var bsonType = context.Reader.GetCurrentBsonType(); + + return bsonType switch + { + BsonType.String => Guid.Parse(context.Reader.ReadString()), + BsonType.Binary => context.Reader.ReadBinaryData().ToGuid(), + BsonType.Null => throw new FormatException("Cannot deserialize null to Guid"), + _ => throw new FormatException($"Cannot deserialize BsonType {bsonType} to Guid") + }; + } + + /// + public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, Guid value) + { + context.Writer.WriteString(value.ToString()); + } +} diff --git a/src/RecurringThings.MongoDB/Documents/RecurringThingDocument.cs b/src/RecurringThings.MongoDB/Documents/RecurringThingDocument.cs index 31917e6..e45f3d7 100644 --- a/src/RecurringThings.MongoDB/Documents/RecurringThingDocument.cs +++ b/src/RecurringThings.MongoDB/Documents/RecurringThingDocument.cs @@ -2,7 +2,6 @@ namespace RecurringThings.MongoDB.Documents; using System; using System.Collections.Generic; -using global::MongoDB.Bson.Serialization.Attributes; using RecurringThings.Domain; using RecurringThings.Options; @@ -18,17 +17,18 @@ namespace RecurringThings.MongoDB.Documents; /// - "override": A modified occurrence from a recurrence /// /// -/// Fields are mapped to camelCase for MongoDB conventions. -/// Null properties are automatically ignored during serialization. -/// Guid properties are serialized as strings. +/// Serialization is configured via conventions (no attributes required): +/// - The Id property is mapped as the document _id using NamedIdMemberConvention +/// - All Guid properties are serialized as strings using GuidStringRepresentationConvention +/// - Fields are mapped to camelCase using CamelCaseElementNameConvention +/// - Null properties are automatically ignored using IgnoreIfNullConvention /// /// public sealed class RecurringThingDocument { /// - /// Gets or sets the unique identifier. + /// Gets or sets the unique identifier (maps to MongoDB _id field). /// - [BsonId] public Guid Id { get; set; } /// diff --git a/src/RecurringThings.MongoDB/Indexing/IndexManager.cs b/src/RecurringThings.MongoDB/Indexing/IndexManager.cs index 1d7239b..6c89328 100644 --- a/src/RecurringThings.MongoDB/Indexing/IndexManager.cs +++ b/src/RecurringThings.MongoDB/Indexing/IndexManager.cs @@ -5,6 +5,7 @@ namespace RecurringThings.MongoDB.Indexing; using System.Threading; using System.Threading.Tasks; using global::MongoDB.Driver; +using RecurringThings.MongoDB.Configuration; using RecurringThings.MongoDB.Documents; /// @@ -40,6 +41,7 @@ public sealed class IndexManager public IndexManager(IMongoDatabase database, string collectionName = "recurring_things") { ArgumentNullException.ThrowIfNull(database); + MongoDbInitializer.EnsureInitialized(); _collection = database.GetCollection(collectionName); } diff --git a/src/RecurringThings.MongoDB/Repositories/MongoOccurrenceExceptionRepository.cs b/src/RecurringThings.MongoDB/Repositories/MongoOccurrenceExceptionRepository.cs index a4bc3c1..6f9b6ac 100644 --- a/src/RecurringThings.MongoDB/Repositories/MongoOccurrenceExceptionRepository.cs +++ b/src/RecurringThings.MongoDB/Repositories/MongoOccurrenceExceptionRepository.cs @@ -8,6 +8,7 @@ namespace RecurringThings.MongoDB.Repositories; using System.Threading.Tasks; using global::MongoDB.Driver; using RecurringThings.Domain; +using RecurringThings.MongoDB.Configuration; using RecurringThings.MongoDB.Documents; using RecurringThings.Repository; using Transactional.Abstractions; @@ -28,6 +29,7 @@ internal sealed class MongoOccurrenceExceptionRepository : IOccurrenceExceptionR public MongoOccurrenceExceptionRepository(IMongoDatabase database, string collectionName = "recurring_things") { ArgumentNullException.ThrowIfNull(database); + MongoDbInitializer.EnsureInitialized(); _collection = database.GetCollection(collectionName); } diff --git a/src/RecurringThings.MongoDB/Repositories/MongoOccurrenceOverrideRepository.cs b/src/RecurringThings.MongoDB/Repositories/MongoOccurrenceOverrideRepository.cs index 6b479be..a8266d6 100644 --- a/src/RecurringThings.MongoDB/Repositories/MongoOccurrenceOverrideRepository.cs +++ b/src/RecurringThings.MongoDB/Repositories/MongoOccurrenceOverrideRepository.cs @@ -8,6 +8,7 @@ namespace RecurringThings.MongoDB.Repositories; using System.Threading.Tasks; using global::MongoDB.Driver; using RecurringThings.Domain; +using RecurringThings.MongoDB.Configuration; using RecurringThings.MongoDB.Documents; using RecurringThings.Repository; using Transactional.Abstractions; @@ -28,6 +29,7 @@ internal sealed class MongoOccurrenceOverrideRepository : IOccurrenceOverrideRep public MongoOccurrenceOverrideRepository(IMongoDatabase database, string collectionName = "recurring_things") { ArgumentNullException.ThrowIfNull(database); + MongoDbInitializer.EnsureInitialized(); _collection = database.GetCollection(collectionName); } diff --git a/src/RecurringThings.MongoDB/Repositories/MongoOccurrenceRepository.cs b/src/RecurringThings.MongoDB/Repositories/MongoOccurrenceRepository.cs index 061937d..3f9c5c6 100644 --- a/src/RecurringThings.MongoDB/Repositories/MongoOccurrenceRepository.cs +++ b/src/RecurringThings.MongoDB/Repositories/MongoOccurrenceRepository.cs @@ -7,6 +7,7 @@ namespace RecurringThings.MongoDB.Repositories; using System.Threading.Tasks; using global::MongoDB.Driver; using RecurringThings.Domain; +using RecurringThings.MongoDB.Configuration; using RecurringThings.MongoDB.Documents; using RecurringThings.Repository; using Transactional.Abstractions; @@ -27,6 +28,7 @@ internal sealed class MongoOccurrenceRepository : IOccurrenceRepository public MongoOccurrenceRepository(IMongoDatabase database, string collectionName = "recurring_things") { ArgumentNullException.ThrowIfNull(database); + MongoDbInitializer.EnsureInitialized(); _collection = database.GetCollection(collectionName); } diff --git a/src/RecurringThings.MongoDB/Repositories/MongoRecurrenceRepository.cs b/src/RecurringThings.MongoDB/Repositories/MongoRecurrenceRepository.cs index 578daea..7549ee8 100644 --- a/src/RecurringThings.MongoDB/Repositories/MongoRecurrenceRepository.cs +++ b/src/RecurringThings.MongoDB/Repositories/MongoRecurrenceRepository.cs @@ -7,6 +7,7 @@ namespace RecurringThings.MongoDB.Repositories; using System.Threading.Tasks; using global::MongoDB.Driver; using RecurringThings.Domain; +using RecurringThings.MongoDB.Configuration; using RecurringThings.MongoDB.Documents; using RecurringThings.Repository; using Transactional.Abstractions; @@ -27,6 +28,7 @@ internal sealed class MongoRecurrenceRepository : IRecurrenceRepository public MongoRecurrenceRepository(IMongoDatabase database, string collectionName = "recurring_things") { ArgumentNullException.ThrowIfNull(database); + MongoDbInitializer.EnsureInitialized(); _collection = database.GetCollection(collectionName); }