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
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Convention that serializes all Guid properties as strings instead of binary.
/// </summary>
/// <remarks>
/// <para>
/// This convention applies to both <see cref="Guid"/> and <see cref="Nullable{Guid}"/> properties,
/// ensuring consistent string representation without requiring attributes on each property.
/// </para>
/// <para>
/// Uses <see cref="StringGuidSerializer"/> instead of the built-in GuidSerializer to avoid
/// the "GuidRepresentation is Unspecified" error in newer MongoDB driver versions.
/// </para>
/// </remarks>
internal sealed class GuidStringRepresentationConvention : IMemberMapConvention
{
private static readonly StringGuidSerializer GuidSerializer = new();

/// <summary>
/// Gets the name of this convention.
/// </summary>
public string Name => "GuidStringRepresentation";

/// <summary>
/// Applies the convention to a member map.
/// </summary>
/// <param name="memberMap">The member map to apply the convention to.</param>
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<Guid>(GuidSerializer));
}
}
}
62 changes: 62 additions & 0 deletions src/RecurringThings.MongoDB/Configuration/MongoDbInitializer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
namespace RecurringThings.MongoDB.Configuration;

using System;
using global::MongoDB.Bson.Serialization.Conventions;

/// <summary>
/// Initializes MongoDB conventions when the assembly is loaded.
/// </summary>
/// <remarks>
/// This ensures that conventions are registered before any serialization occurs,
/// regardless of whether <see cref="RecurringThingsBuilderExtensions.UseMongoDb"/> is called.
/// </remarks>
internal static class MongoDbInitializer
{
private static bool _initialized;
private static readonly object _lock = new();

/// <summary>
/// Registers MongoDB conventions for RecurringThings documents.
/// </summary>
/// <remarks>
/// This method is idempotent and can be called multiple times safely.
/// It registers the following conventions:
/// <list type="bullet">
/// <item><description>NamedIdMemberConvention: Maps "Id" property to MongoDB "_id" field</description></item>
/// <item><description>CamelCaseElementNameConvention: Converts property names to camelCase</description></item>
/// <item><description>IgnoreIfNullConvention: Excludes null properties from serialization</description></item>
/// <item><description>GuidStringRepresentationConvention: Serializes Guid properties as strings</description></item>
/// </list>
/// </remarks>
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;
}
}

}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
namespace RecurringThings.MongoDB.Configuration;

using System;
using global::MongoDB.Bson.Serialization.Conventions;
using global::MongoDB.Driver;
using Microsoft.Extensions.DependencyInjection;
using RecurringThings.Configuration;
Expand Down Expand Up @@ -44,12 +43,8 @@ public static RecurringThingsBuilder UseMongoDb(
configure(options);
options.Validate();

// Register camelCase naming convention for RecurringThings documents
var conventionPack = new ConventionPack { new CamelCaseElementNameConvention() };
ConventionRegistry.Register(
"RecurringThingsCamelCase",
conventionPack,
t => t.FullName?.StartsWith("RecurringThings", StringComparison.Ordinal) == true);
// Ensure MongoDB conventions are registered (idempotent)
MongoDbInitializer.EnsureInitialized();

// Mark MongoDB as configured
builder.MongoDbConfigured = true;
Expand Down
37 changes: 37 additions & 0 deletions src/RecurringThings.MongoDB/Configuration/StringGuidSerializer.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// A custom GUID serializer that stores GUIDs as strings in MongoDB.
/// </summary>
/// <remarks>
/// This serializer is used instead of <see cref="GuidSerializer"/> with <see cref="BsonType.String"/>
/// because the standard serializer throws "GuidRepresentation is Unspecified" in newer MongoDB drivers.
/// This implementation directly serializes/deserializes GUIDs as strings without the GuidRepresentation validation.
/// </remarks>
internal sealed class StringGuidSerializer : StructSerializerBase<Guid>
{
/// <inheritdoc/>
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")
};
}

/// <inheritdoc/>
public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, Guid value)
{
context.Writer.WriteString(value.ToString());
}
}
26 changes: 6 additions & 20 deletions src/RecurringThings.MongoDB/Documents/RecurringThingDocument.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +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;

Expand All @@ -19,16 +17,18 @@ namespace RecurringThings.MongoDB.Documents;
/// - "override": A modified occurrence from a recurrence
/// </para>
/// <para>
/// Fields are mapped to camelCase for MongoDB conventions.
/// 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
/// </para>
/// </remarks>
public sealed class RecurringThingDocument
{
/// <summary>
/// Gets or sets the unique identifier.
/// Gets or sets the unique identifier (maps to MongoDB _id field).
/// </summary>
[BsonId]
[BsonRepresentation(BsonType.String)]
public Guid Id { get; set; }

/// <summary>
Expand All @@ -55,7 +55,6 @@ public sealed class RecurringThingDocument
/// <remarks>
/// Present on recurrences and occurrences. Null for exceptions and overrides.
/// </remarks>
[BsonIgnoreIfNull]
public string? Type { get; set; }

/// <summary>
Expand All @@ -64,7 +63,6 @@ public sealed class RecurringThingDocument
/// <remarks>
/// Present on recurrences, occurrences, and overrides. Null for exceptions.
/// </remarks>
[BsonIgnoreIfNull]
public DateTime? StartTime { get; set; }

/// <summary>
Expand All @@ -74,7 +72,6 @@ public sealed class RecurringThingDocument
/// Present on occurrences and overrides. Null for recurrences and exceptions.
/// Computed as StartTime + Duration.
/// </remarks>
[BsonIgnoreIfNull]
public DateTime? EndTime { get; set; }

/// <summary>
Expand All @@ -84,7 +81,6 @@ public sealed class RecurringThingDocument
/// Stored as milliseconds for efficient MongoDB operations.
/// Present on recurrences, occurrences, and overrides. Null for exceptions.
/// </remarks>
[BsonIgnoreIfNull]
public long? DurationMs { get; set; }

/// <summary>
Expand All @@ -93,7 +89,6 @@ public sealed class RecurringThingDocument
/// <remarks>
/// Present on recurrences and occurrences. Null for exceptions and overrides.
/// </remarks>
[BsonIgnoreIfNull]
public string? TimeZone { get; set; }

/// <summary>
Expand All @@ -102,7 +97,6 @@ public sealed class RecurringThingDocument
/// <remarks>
/// Present only on recurrences.
/// </remarks>
[BsonIgnoreIfNull]
public DateTime? RecurrenceEndTime { get; set; }

/// <summary>
Expand All @@ -111,7 +105,6 @@ public sealed class RecurringThingDocument
/// <remarks>
/// Present only on recurrences.
/// </remarks>
[BsonIgnoreIfNull]
public string? RRule { get; set; }

/// <summary>
Expand All @@ -126,7 +119,6 @@ public sealed class RecurringThingDocument
/// monthly patterns with day &lt;= 28, or patterns where no months are affected).
/// </para>
/// </remarks>
[BsonIgnoreIfNull]
public string? MonthDayBehavior { get; set; }

/// <summary>
Expand All @@ -135,8 +127,6 @@ public sealed class RecurringThingDocument
/// <remarks>
/// Present on exceptions and overrides.
/// </remarks>
[BsonRepresentation(BsonType.String)]
[BsonIgnoreIfNull]
public Guid? RecurrenceId { get; set; }

/// <summary>
Expand All @@ -145,7 +135,6 @@ public sealed class RecurringThingDocument
/// <remarks>
/// Present on exceptions and overrides.
/// </remarks>
[BsonIgnoreIfNull]
public DateTime? OriginalTimeUtc { get; set; }

/// <summary>
Expand All @@ -155,7 +144,6 @@ public sealed class RecurringThingDocument
/// Denormalized from the parent recurrence at creation time.
/// Present only on overrides.
/// </remarks>
[BsonIgnoreIfNull]
public long? OriginalDurationMs { get; set; }

/// <summary>
Expand All @@ -165,13 +153,11 @@ public sealed class RecurringThingDocument
/// Denormalized from the parent recurrence at creation time.
/// Present only on overrides.
/// </remarks>
[BsonIgnoreIfNull]
public Dictionary<string, string>? OriginalExtensions { get; set; }

/// <summary>
/// Gets or sets the user-defined key-value metadata.
/// </summary>
[BsonIgnoreIfNull]
public Dictionary<string, string>? Extensions { get; set; }
}

Expand Down
2 changes: 2 additions & 0 deletions src/RecurringThings.MongoDB/Indexing/IndexManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>
Expand Down Expand Up @@ -40,6 +41,7 @@ public sealed class IndexManager
public IndexManager(IMongoDatabase database, string collectionName = "recurring_things")
{
ArgumentNullException.ThrowIfNull(database);
MongoDbInitializer.EnsureInitialized();
_collection = database.GetCollection<RecurringThingDocument>(collectionName);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<RecurringThingDocument>(collectionName);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<RecurringThingDocument>(collectionName);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<RecurringThingDocument>(collectionName);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<RecurringThingDocument>(collectionName);
}

Expand Down