From 9ab704cad8ab0273c454df6006454f894eb02f75 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Sun, 9 Jun 2024 11:49:06 -0600 Subject: [PATCH 1/7] get JsonSerializerOptions from CrdtConfig, freeze CrdtConfig once it's used to provide json options. --- src/Crdt/CrdtConfig.cs | 48 +++++++++++++++++++++++++++++++++++------- src/Crdt/CrdtKernel.cs | 11 +--------- 2 files changed, 41 insertions(+), 18 deletions(-) diff --git a/src/Crdt/CrdtConfig.cs b/src/Crdt/CrdtConfig.cs index a253984..ffb4388 100644 --- a/src/Crdt/CrdtConfig.cs +++ b/src/Crdt/CrdtConfig.cs @@ -1,4 +1,5 @@ -using System.Text.Json.Serialization.Metadata; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using Crdt.Changes; using Crdt.Db; using Crdt.Entities; @@ -12,6 +13,16 @@ public class CrdtConfig public bool EnableProjectedTables { get; set; } = false; public ChangeTypeListBuilder ChangeTypeListBuilder { get; } = new(); public ObjectTypeListBuilder ObjectTypeListBuilder { get; } = new(); + public JsonSerializerOptions JsonSerializerOptions => _lazyJsonSerializerOptions.Value; + private readonly Lazy _lazyJsonSerializerOptions; + + public CrdtConfig() + { + _lazyJsonSerializerOptions = new Lazy(() => new JsonSerializerOptions(JsonSerializerDefaults.General) + { + TypeInfoResolver = MakeJsonTypeResolver() + }); + } public Action MakeJsonTypeModifier() { @@ -28,6 +39,8 @@ public IJsonTypeInfoResolver MakeJsonTypeResolver() private void JsonTypeModifier(JsonTypeInfo typeInfo) { + ChangeTypeListBuilder.Freeze(); + ObjectTypeListBuilder.Freeze(); if (typeInfo.Type == typeof(IChange)) { foreach (var type in ChangeTypeListBuilder.Types) @@ -48,10 +61,22 @@ private void JsonTypeModifier(JsonTypeInfo typeInfo) public class ChangeTypeListBuilder { + private bool _frozen; + + public void Freeze() + { + _frozen = true; + } + + private void CheckFrozen() + { + if (_frozen) throw new InvalidOperationException("ObjectTypeListBuilder is frozen"); + } internal List Types { get; } = []; public ChangeTypeListBuilder Add() where TDerived : IChange, IPolyType { + CheckFrozen(); if (Types.Any(t => t.DerivedType == typeof(TDerived))) return this; Types.Add(new JsonDerivedType(typeof(TDerived), TDerived.TypeName)); return this; @@ -60,26 +85,33 @@ public ChangeTypeListBuilder Add() where TDerived : IChange, IPolyType public class ObjectTypeListBuilder { - internal List Types { get; } = []; - - internal List> ModelConfigurations { get; } = []; - public List> ModelConventions { get; } = []; + private bool _frozen; + public void Freeze() + { + _frozen = true; + } - public ObjectTypeListBuilder AddDbModelConvention(Action modelConvention) + private void CheckFrozen() { - ModelConventions.Add(modelConvention); - return this; + if (_frozen) throw new InvalidOperationException("ObjectTypeListBuilder is frozen"); } + internal List Types { get; } = []; + + internal List> ModelConfigurations { get; } = []; + public ObjectTypeListBuilder AddDbModelConfig(Action modelConfiguration) { + CheckFrozen(); ModelConfigurations.Add((builder, _) => modelConfiguration(builder)); return this; } + public ObjectTypeListBuilder Add(Action>? configureDb = null) where TDerived : class, IObjectBase { + CheckFrozen(); if (Types.Any(t => t.DerivedType == typeof(TDerived))) throw new InvalidOperationException($"Type {typeof(TDerived)} already added"); Types.Add(new JsonDerivedType(typeof(TDerived), TDerived.TypeName)); ModelConfigurations.Add((builder, config) => diff --git a/src/Crdt/CrdtKernel.cs b/src/Crdt/CrdtKernel.cs index 41cf97f..7a50cae 100644 --- a/src/Crdt/CrdtKernel.cs +++ b/src/Crdt/CrdtKernel.cs @@ -23,16 +23,7 @@ public static IServiceCollection AddCrdtData(this IServiceCollection services, Action configureCrdt) { services.AddOptions().Configure(configureCrdt); - services.AddSingleton(sp => new JsonSerializerOptions(JsonSerializerDefaults.General) - { - TypeInfoResolver = new DefaultJsonTypeInfoResolver - { - Modifiers = - { - sp.GetRequiredService>().Value.MakeJsonTypeModifier() - } - } - }); + services.AddSingleton(sp => sp.GetRequiredService>().Value.JsonSerializerOptions); services.AddSingleton(TimeProvider.System); services.AddScoped(NewTimeProvider); services.AddDbContext((provider, builder) => From 8234fa14177c45ea482995240eb8272aef0d019a Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Sun, 9 Jun 2024 11:50:15 -0600 Subject: [PATCH 2/7] extract CrdtDbContext into an interface so the DbContext can be defined in the application --- src/Crdt/CrdtKernel.cs | 31 ++--- src/Crdt/Db/CrdtDbContext.cs | 110 +----------------- src/Crdt/Db/CrdtDbContextOptionsExtensions.cs | 21 ++++ src/Crdt/Db/CrdtRepository.cs | 2 +- src/Crdt/Db/DbSetExtensions.cs | 29 +++++ .../Db/EntityConfig/ChangeEntityConfig.cs | 28 +++++ .../Db/EntityConfig/CommitEntityConfig.cs | 34 ++++++ .../Db/EntityConfig/SnapshotEntityConfig.cs | 32 +++++ src/Crdt/Db/ICrdtDbContext.cs | 22 ++++ 9 files changed, 189 insertions(+), 120 deletions(-) create mode 100644 src/Crdt/Db/CrdtDbContextOptionsExtensions.cs create mode 100644 src/Crdt/Db/DbSetExtensions.cs create mode 100644 src/Crdt/Db/EntityConfig/ChangeEntityConfig.cs create mode 100644 src/Crdt/Db/EntityConfig/CommitEntityConfig.cs create mode 100644 src/Crdt/Db/EntityConfig/SnapshotEntityConfig.cs create mode 100644 src/Crdt/Db/ICrdtDbContext.cs diff --git a/src/Crdt/CrdtKernel.cs b/src/Crdt/CrdtKernel.cs index 7a50cae..1faf21d 100644 --- a/src/Crdt/CrdtKernel.cs +++ b/src/Crdt/CrdtKernel.cs @@ -11,30 +11,31 @@ namespace Crdt; public static class CrdtKernel { - public static IServiceCollection AddCrdtData(this IServiceCollection services, - Action configureOptions, - Action configureCrdt) - { - return AddCrdtData(services, (_, builder) => configureOptions(builder), configureCrdt); - } public static IServiceCollection AddCrdtData(this IServiceCollection services, Action configureOptions, Action configureCrdt) + { + services.AddDbContext((provider, builder) => + { + configureOptions(provider, builder); + builder + .EnableDetailedErrors() + .EnableSensitiveDataLogging(); + }); + return AddCrdtData(services, configureCrdt); + } + + public static IServiceCollection AddCrdtData(this IServiceCollection services, + Action configureCrdt) where TContext : class, ICrdtDbContext { services.AddOptions().Configure(configureCrdt); services.AddSingleton(sp => sp.GetRequiredService>().Value.JsonSerializerOptions); services.AddSingleton(TimeProvider.System); services.AddScoped(NewTimeProvider); - services.AddDbContext((provider, builder) => - { - configureOptions(provider, builder); - builder - .AddInterceptors(provider.GetServices().ToArray()) - .EnableDetailedErrors() - .EnableSensitiveDataLogging(); - }, - ServiceLifetime.Scoped); + //must use factory, otherwise one context will be created for this registration, and one for the application. + //we want to have one context per application + services.AddScoped(p => p.GetRequiredService()); services.AddScoped(); //must use factory method because DataModel constructor is internal services.AddScoped(provider => new DataModel( diff --git a/src/Crdt/Db/CrdtDbContext.cs b/src/Crdt/Db/CrdtDbContext.cs index a2df5ae..910924a 100644 --- a/src/Crdt/Db/CrdtDbContext.cs +++ b/src/Crdt/Db/CrdtDbContext.cs @@ -1,8 +1,6 @@ -using System.Runtime.Serialization; using System.Text.Json; using Crdt.Changes; using Crdt.Core; -using Crdt.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -10,111 +8,15 @@ namespace Crdt.Db; public class CrdtDbContext( DbContextOptions options, - IOptions crdtConfig, - JsonSerializerOptions jsonSerializerOptions) - : DbContext(options) + IOptions crdtConfig) + : DbContext(options), ICrdtDbContext { - protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) - { - foreach (var modelConvention in crdtConfig.Value.ObjectTypeListBuilder.ModelConventions) - { - modelConvention.Invoke(configurationBuilder); - } - } - protected override void OnModelCreating(ModelBuilder builder) { - var commitEntity = builder.Entity(); - commitEntity.HasKey(c => c.Id); - commitEntity.ComplexProperty(c => c.HybridDateTime, - hybridEntity => - { - hybridEntity.Property(h => h.DateTime) - .HasConversion( - d => d.UtcDateTime, - //need to use ticks here because the DateTime is stored as UTC, but the db records it as unspecified - d => new DateTimeOffset(d.Ticks, TimeSpan.Zero)) - .HasColumnName("DateTime"); - hybridEntity.Property(h => h.Counter).HasColumnName("Counter"); - }); - commitEntity.Property(c => c.Metadata) - .HasColumnType("jsonb") - .HasConversion( - m => JsonSerializer.Serialize(m, (JsonSerializerOptions?)null), - json => JsonSerializer.Deserialize(json, (JsonSerializerOptions?)null) ?? new() - ); - commitEntity.HasMany(c => c.ChangeEntities) - .WithOne() - .HasForeignKey(c => c.CommitId); - var snapshotObject = builder.Entity(); - snapshotObject.HasKey(s => s.Id); - snapshotObject.HasIndex(s => new { s.CommitId, s.EntityId }).IsUnique(); - snapshotObject - .HasOne(s => s.Commit) - .WithMany(c => c.Snapshots) - .HasForeignKey(s => s.CommitId); - snapshotObject.Property(s => s.Entity) - .HasColumnType("jsonb") - .HasConversion( - entry => JsonSerializer.Serialize(entry, jsonSerializerOptions), - json => DeserializeObject(json) - ); - var changeEntity = builder.Entity>(); - changeEntity.HasKey(c => new {c.CommitId, c.Index}); - changeEntity.Property(c => c.Change) - .HasColumnType("jsonb") - .HasConversion( - change => JsonSerializer.Serialize(change, jsonSerializerOptions), - json => DeserializeChange(json) - ); - - foreach (var modelConfiguration in crdtConfig.Value.ObjectTypeListBuilder.ModelConfigurations) - { - modelConfiguration(builder, crdtConfig.Value); - } + builder.UseCrdt(crdtConfig.Value); } - private IChange DeserializeChange(string json) - { - return JsonSerializer.Deserialize(json, jsonSerializerOptions) ?? - throw new SerializationException("Could not deserialize Change: " + json); - } - - private IObjectBase DeserializeObject(string json) - { - return JsonSerializer.Deserialize(json, jsonSerializerOptions) ?? - throw new SerializationException("Could not deserialize Entry: " + json); - } - - public DbSet Commits { get; set; } = null!; - public DbSet> ChangeEntities { get; set; } = null!; - public DbSet Snapshots { get; set; } = null!; -} - -//todo, I would like to move these extensions into QueryHelperTests but that's in Core and ObjectSnapshot is not part of core -public static class DbSetExtensions -{ - public static IQueryable DefaultOrder(this IQueryable queryable) - { - return queryable - .OrderBy(c => c.Commit.HybridDateTime.DateTime) - .ThenBy(c => c.Commit.HybridDateTime.Counter) - .ThenBy(c => c.Commit.Id); - } - - public static IQueryable DefaultOrderDescending(this IQueryable queryable) - { - return queryable - .OrderByDescending(c => c.Commit.HybridDateTime.DateTime) - .ThenByDescending(c => c.Commit.HybridDateTime.Counter) - .ThenByDescending(c => c.Commit.Id); - } - - public static IQueryable WhereAfter(this IQueryable queryable, Commit after) - { - return queryable.Where( - s => after.HybridDateTime.DateTime < s.Commit.HybridDateTime.DateTime - || (after.HybridDateTime.DateTime == s.Commit.HybridDateTime.DateTime && after.HybridDateTime.Counter < s.Commit.HybridDateTime.Counter) - || (after.HybridDateTime.DateTime == s.Commit.HybridDateTime.DateTime && after.HybridDateTime.Counter == s.Commit.HybridDateTime.Counter && after.Id < s.Commit.Id)); - } + public DbSet Commits => Set(); + public DbSet> ChangeEntities => Set>(); + public DbSet Snapshots => Set(); } diff --git a/src/Crdt/Db/CrdtDbContextOptionsExtensions.cs b/src/Crdt/Db/CrdtDbContextOptionsExtensions.cs new file mode 100644 index 0000000..c04f843 --- /dev/null +++ b/src/Crdt/Db/CrdtDbContextOptionsExtensions.cs @@ -0,0 +1,21 @@ +using System.Text.Json; +using Crdt.Db.EntityConfig; +using Microsoft.EntityFrameworkCore; + +namespace Crdt.Db; + +public static class CrdtDbContextModelExtensions +{ + public static ModelBuilder UseCrdt(this ModelBuilder modelBuilder, + CrdtConfig crdtConfig) + { + modelBuilder.ApplyConfigurationsFromAssembly(typeof(CommitEntityConfig).Assembly) + .ApplyConfiguration(new SnapshotEntityConfig(crdtConfig.JsonSerializerOptions)) + .ApplyConfiguration(new ChangeEntityConfig(crdtConfig.JsonSerializerOptions)); + foreach (var modelConfiguration in crdtConfig.ObjectTypeListBuilder.ModelConfigurations) + { + modelConfiguration(modelBuilder, crdtConfig); + } + return modelBuilder; + } +} \ No newline at end of file diff --git a/src/Crdt/Db/CrdtRepository.cs b/src/Crdt/Db/CrdtRepository.cs index 0eb3487..fb5b804 100644 --- a/src/Crdt/Db/CrdtRepository.cs +++ b/src/Crdt/Db/CrdtRepository.cs @@ -10,7 +10,7 @@ namespace Crdt.Db; -internal class CrdtRepository(CrdtDbContext _dbContext, IOptions crdtConfig, DateTimeOffset? ignoreChangesAfter = null) +internal class CrdtRepository(ICrdtDbContext _dbContext, IOptions crdtConfig, DateTimeOffset? ignoreChangesAfter = null) { public Task BeginTransactionAsync() { diff --git a/src/Crdt/Db/DbSetExtensions.cs b/src/Crdt/Db/DbSetExtensions.cs new file mode 100644 index 0000000..4e8c59d --- /dev/null +++ b/src/Crdt/Db/DbSetExtensions.cs @@ -0,0 +1,29 @@ +namespace Crdt.Db; + +//todo, I would like to move these extensions into QueryHelperTests but that's in Core and ObjectSnapshot is not part of core +public static class DbSetExtensions +{ + public static IQueryable DefaultOrder(this IQueryable queryable) + { + return queryable + .OrderBy(c => c.Commit.HybridDateTime.DateTime) + .ThenBy(c => c.Commit.HybridDateTime.Counter) + .ThenBy(c => c.Commit.Id); + } + + public static IQueryable DefaultOrderDescending(this IQueryable queryable) + { + return queryable + .OrderByDescending(c => c.Commit.HybridDateTime.DateTime) + .ThenByDescending(c => c.Commit.HybridDateTime.Counter) + .ThenByDescending(c => c.Commit.Id); + } + + public static IQueryable WhereAfter(this IQueryable queryable, Commit after) + { + return queryable.Where( + s => after.HybridDateTime.DateTime < s.Commit.HybridDateTime.DateTime + || (after.HybridDateTime.DateTime == s.Commit.HybridDateTime.DateTime && after.HybridDateTime.Counter < s.Commit.HybridDateTime.Counter) + || (after.HybridDateTime.DateTime == s.Commit.HybridDateTime.DateTime && after.HybridDateTime.Counter == s.Commit.HybridDateTime.Counter && after.Id < s.Commit.Id)); + } +} \ No newline at end of file diff --git a/src/Crdt/Db/EntityConfig/ChangeEntityConfig.cs b/src/Crdt/Db/EntityConfig/ChangeEntityConfig.cs new file mode 100644 index 0000000..c3d09c6 --- /dev/null +++ b/src/Crdt/Db/EntityConfig/ChangeEntityConfig.cs @@ -0,0 +1,28 @@ +using System.Runtime.Serialization; +using System.Text.Json; +using Crdt.Changes; +using Crdt.Core; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Crdt.Db.EntityConfig; + +public class ChangeEntityConfig(JsonSerializerOptions jsonSerializerOptions) : IEntityTypeConfiguration> +{ + public void Configure(EntityTypeBuilder> builder) + { + builder.HasKey(c => new { c.CommitId, c.Index }); + builder.Property(c => c.Change) + .HasColumnType("jsonb") + .HasConversion( + change => JsonSerializer.Serialize(change, jsonSerializerOptions), + json => DeserializeChange(json) + ); + } + + private IChange DeserializeChange(string json) + { + return JsonSerializer.Deserialize(json, jsonSerializerOptions) ?? + throw new SerializationException("Could not deserialize Change: " + json); + } +} \ No newline at end of file diff --git a/src/Crdt/Db/EntityConfig/CommitEntityConfig.cs b/src/Crdt/Db/EntityConfig/CommitEntityConfig.cs new file mode 100644 index 0000000..db5a5b1 --- /dev/null +++ b/src/Crdt/Db/EntityConfig/CommitEntityConfig.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using Crdt.Core; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Crdt.Db.EntityConfig; + +public class CommitEntityConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(c => c.Id); + builder.ComplexProperty(c => c.HybridDateTime, + hybridEntity => + { + hybridEntity.Property(h => h.DateTime) + .HasConversion( + d => d.UtcDateTime, + //need to use ticks here because the DateTime is stored as UTC, but the db records it as unspecified + d => new DateTimeOffset(d.Ticks, TimeSpan.Zero)) + .HasColumnName("DateTime"); + hybridEntity.Property(h => h.Counter).HasColumnName("Counter"); + }); + builder.Property(c => c.Metadata) + .HasColumnType("jsonb") + .HasConversion( + m => JsonSerializer.Serialize(m, (JsonSerializerOptions?)null), + json => JsonSerializer.Deserialize(json, (JsonSerializerOptions?)null) ?? new() + ); + builder.HasMany(c => c.ChangeEntities) + .WithOne() + .HasForeignKey(c => c.CommitId); + } +} \ No newline at end of file diff --git a/src/Crdt/Db/EntityConfig/SnapshotEntityConfig.cs b/src/Crdt/Db/EntityConfig/SnapshotEntityConfig.cs new file mode 100644 index 0000000..4bba8eb --- /dev/null +++ b/src/Crdt/Db/EntityConfig/SnapshotEntityConfig.cs @@ -0,0 +1,32 @@ +using System.Runtime.Serialization; +using System.Text.Json; +using Crdt.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Crdt.Db.EntityConfig; + +public class SnapshotEntityConfig(JsonSerializerOptions jsonSerializerOptions) : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(s => s.Id); + builder.HasIndex(s => new { s.CommitId, s.EntityId }).IsUnique(); + builder + .HasOne(s => s.Commit) + .WithMany(c => c.Snapshots) + .HasForeignKey(s => s.CommitId); + builder.Property(s => s.Entity) + .HasColumnType("jsonb") + .HasConversion( + entry => JsonSerializer.Serialize(entry, jsonSerializerOptions), + json => DeserializeObject(json) + ); + } + + private IObjectBase DeserializeObject(string json) + { + return JsonSerializer.Deserialize(json, jsonSerializerOptions) ?? + throw new SerializationException("Could not deserialize Entry: " + json); + } +} \ No newline at end of file diff --git a/src/Crdt/Db/ICrdtDbContext.cs b/src/Crdt/Db/ICrdtDbContext.cs new file mode 100644 index 0000000..03dd298 --- /dev/null +++ b/src/Crdt/Db/ICrdtDbContext.cs @@ -0,0 +1,22 @@ +using Crdt.Changes; +using Crdt.Core; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Crdt.Db; + +public interface ICrdtDbContext +{ + DbSet Commits { get; } + DbSet> ChangeEntities { get; } + DbSet Snapshots { get; } + Task SaveChangesAsync(CancellationToken cancellationToken = default); + ValueTask FindAsync(Type entityType, params object?[]? keyValues); + DbSet Set() where TEntity : class; + DatabaseFacade Database { get; } + EntityEntry Entry(TEntity entity) where TEntity : class; + EntityEntry Entry(object entity); + EntityEntry Add(object entity); + EntityEntry Remove(object entity); +} \ No newline at end of file From 9f0a705d2245fcd13384f04fefe56ec241850173 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Sun, 9 Jun 2024 12:02:59 -0600 Subject: [PATCH 3/7] refactor sample project to define it's own dbcontext --- src/Crdt.Linq2db/Linq2dbKernel.cs | 52 ++++++++++++---------- src/Crdt.Sample/CrdtSampleKernel.cs | 68 ++++++++++++++--------------- src/Crdt.Sample/SampleDbContext.cs | 19 ++++++++ src/Crdt.Tests/DataModelTestBase.cs | 4 +- src/Crdt.Tests/ModuleInit.cs | 2 +- src/Crdt.Tests/RepositoryTests.cs | 4 +- 6 files changed, 87 insertions(+), 62 deletions(-) create mode 100644 src/Crdt.Sample/SampleDbContext.cs diff --git a/src/Crdt.Linq2db/Linq2dbKernel.cs b/src/Crdt.Linq2db/Linq2dbKernel.cs index b1ec9a1..deb5a3e 100644 --- a/src/Crdt.Linq2db/Linq2dbKernel.cs +++ b/src/Crdt.Linq2db/Linq2dbKernel.cs @@ -21,32 +21,38 @@ public static IServiceCollection AddCrdtLinq2db(this IServiceCollection services (provider, builder) => { configureOptions.Invoke(provider, builder); - builder.UseLinqToDB(optionsBuilder => - { - var mappingSchema = optionsBuilder.DbContextOptions.GetLinqToDBOptions()?.ConnectionOptions - .MappingSchema; - if (mappingSchema is null) - { - mappingSchema = new MappingSchema(); - optionsBuilder.AddMappingSchema(mappingSchema); - } - - new FluentMappingBuilder(mappingSchema).HasAttribute(new ColumnAttribute("DateTime", - nameof(Commit.HybridDateTime) + "." + nameof(HybridDateTime.DateTime))) - .HasAttribute(new ColumnAttribute(nameof(HybridDateTime.Counter), - nameof(Commit.HybridDateTime) + "." + nameof(HybridDateTime.Counter))) - .Entity() - //need to use ticks here because the DateTime is stored as UTC, but the db records it as unspecified - .Property(commit => commit.HybridDateTime.DateTime).HasConversionFunc(dt => dt.UtcDateTime, dt => new DateTimeOffset(dt.Ticks, TimeSpan.Zero)) - .Build(); - - var loggerFactory = provider.GetService(); - if (loggerFactory is not null) - optionsBuilder.AddCustomOptions(dataOptions => dataOptions.UseLoggerFactory(loggerFactory)); - }); + builder.UseLinqToDbCrdt(provider); }, configureCrdt ); return services; } + + public static DbContextOptionsBuilder UseLinqToDbCrdt(this DbContextOptionsBuilder builder, IServiceProvider provider) + { + return builder.UseLinqToDB(optionsBuilder => + { + var mappingSchema = optionsBuilder.DbContextOptions.GetLinqToDBOptions()?.ConnectionOptions + .MappingSchema; + if (mappingSchema is null) + { + mappingSchema = new MappingSchema(); + optionsBuilder.AddMappingSchema(mappingSchema); + } + + new FluentMappingBuilder(mappingSchema).HasAttribute(new ColumnAttribute("DateTime", + nameof(Commit.HybridDateTime) + "." + nameof(HybridDateTime.DateTime))) + .HasAttribute(new ColumnAttribute(nameof(HybridDateTime.Counter), + nameof(Commit.HybridDateTime) + "." + nameof(HybridDateTime.Counter))) + .Entity() + //need to use ticks here because the DateTime is stored as UTC, but the db records it as unspecified + .Property(commit => commit.HybridDateTime.DateTime).HasConversionFunc(dt => dt.UtcDateTime, + dt => new DateTimeOffset(dt.Ticks, TimeSpan.Zero)) + .Build(); + + var loggerFactory = provider.GetService(); + if (loggerFactory is not null) + optionsBuilder.AddCustomOptions(dataOptions => dataOptions.UseLoggerFactory(loggerFactory)); + }); + } } diff --git a/src/Crdt.Sample/CrdtSampleKernel.cs b/src/Crdt.Sample/CrdtSampleKernel.cs index 8b851fa..2fc3476 100644 --- a/src/Crdt.Sample/CrdtSampleKernel.cs +++ b/src/Crdt.Sample/CrdtSampleKernel.cs @@ -3,6 +3,7 @@ using Crdt.Linq2db; using Crdt.Sample.Changes; using Crdt.Sample.Models; +using LinqToDB.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -10,41 +11,40 @@ namespace Crdt.Sample; public static class CrdtSampleKernel { - public static IServiceCollection AddCrdtDataSample(this IServiceCollection services, - string dbPath, - bool enableProjectedTables = true) + public static IServiceCollection AddCrdtDataSample(this IServiceCollection services, string dbPath) { - services.AddCrdtLinq2db( - (_, builder) => - { - builder.UseSqlite($"Data Source={dbPath}"); - builder.EnableDetailedErrors(); - builder.EnableSensitiveDataLogging(); - #if DEBUG - builder.LogTo(s => Debug.WriteLine(s)); - #endif - }, - config => - { - config.EnableProjectedTables = enableProjectedTables; - config.ChangeTypeListBuilder - .Add() - .Add() - .Add() - .Add() - .Add() - .Add() - .Add() - .Add>() - .Add>() - .Add>() - .Add>() - ; - config.ObjectTypeListBuilder - .Add() - .Add() - .Add(); - }); + LinqToDBForEFTools.Initialize(); + services.AddDbContext((provider, builder) => + { + builder.UseLinqToDbCrdt(provider); + builder.UseSqlite($"Data Source={dbPath}"); + builder.EnableDetailedErrors(); + builder.EnableSensitiveDataLogging(); +#if DEBUG + builder.LogTo(s => Debug.WriteLine(s)); +#endif + }); + services.AddCrdtData(config => + { + config.EnableProjectedTables = true; + config.ChangeTypeListBuilder + .Add() + .Add() + .Add() + .Add() + .Add() + .Add() + .Add() + .Add>() + .Add>() + .Add>() + .Add>() + ; + config.ObjectTypeListBuilder + .Add() + .Add() + .Add(); + }); return services; } } \ No newline at end of file diff --git a/src/Crdt.Sample/SampleDbContext.cs b/src/Crdt.Sample/SampleDbContext.cs new file mode 100644 index 0000000..ae6676e --- /dev/null +++ b/src/Crdt.Sample/SampleDbContext.cs @@ -0,0 +1,19 @@ +using Crdt.Changes; +using Crdt.Core; +using Crdt.Db; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Crdt.Sample; + +public class SampleDbContext(DbContextOptionsoptions, IOptions crdtConfig): DbContext(options), ICrdtDbContext +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.UseCrdt(crdtConfig.Value); + } + + public DbSet Commits => Set(); + public DbSet> ChangeEntities => Set>(); + public DbSet Snapshots => Set(); +} \ No newline at end of file diff --git a/src/Crdt.Tests/DataModelTestBase.cs b/src/Crdt.Tests/DataModelTestBase.cs index d1eaa53..f6e1843 100644 --- a/src/Crdt.Tests/DataModelTestBase.cs +++ b/src/Crdt.Tests/DataModelTestBase.cs @@ -16,7 +16,7 @@ public class DataModelTestBase : IAsyncLifetime protected readonly ServiceProvider _services; protected readonly Guid _localClientId = Guid.NewGuid(); public readonly DataModel DataModel; - public readonly CrdtDbContext DbContext; + public readonly SampleDbContext DbContext; protected readonly MockTimeProvider MockTimeProvider = new(); public DataModelTestBase() @@ -25,7 +25,7 @@ public DataModelTestBase() .AddCrdtDataSample(":memory:") .Replace(ServiceDescriptor.Singleton(MockTimeProvider)) .BuildServiceProvider(); - DbContext = _services.GetRequiredService(); + DbContext = _services.GetRequiredService(); DbContext.Database.OpenConnection(); DbContext.Database.EnsureCreated(); DataModel = _services.GetRequiredService(); diff --git a/src/Crdt.Tests/ModuleInit.cs b/src/Crdt.Tests/ModuleInit.cs index 70c8168..a0c668c 100644 --- a/src/Crdt.Tests/ModuleInit.cs +++ b/src/Crdt.Tests/ModuleInit.cs @@ -16,7 +16,7 @@ public static void Initialize() var services = new ServiceCollection() .AddCrdtDataSample(":memory:") .BuildServiceProvider(); - var model = services.GetRequiredService().Model; + var model = services.GetRequiredService().Model; VerifyEntityFramework.Initialize(model); VerifierSettings.AddExtraSettings(s => { diff --git a/src/Crdt.Tests/RepositoryTests.cs b/src/Crdt.Tests/RepositoryTests.cs index 1e7b869..9d60b79 100644 --- a/src/Crdt.Tests/RepositoryTests.cs +++ b/src/Crdt.Tests/RepositoryTests.cs @@ -12,7 +12,7 @@ public class RepositoryTests : IAsyncLifetime { private readonly ServiceProvider _services; private CrdtRepository _repository; - private CrdtDbContext _crdtDbContext; + private SampleDbContext _crdtDbContext; public RepositoryTests() { @@ -21,7 +21,7 @@ public RepositoryTests() .BuildServiceProvider(); _repository = _services.GetRequiredService(); - _crdtDbContext = _services.GetRequiredService(); + _crdtDbContext = _services.GetRequiredService(); } public async Task InitializeAsync() From 246633c1de708a227153a92fdb695807f7730e29 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 12 Jun 2024 15:10:46 -0600 Subject: [PATCH 4/7] add some comments about why we freeze the builders, and interpolate some values, including the type name when trying to add to a frozen builder. --- src/Crdt/CrdtConfig.cs | 11 +++++++++-- src/Crdt/Db/EntityConfig/SnapshotEntityConfig.cs | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/Crdt/CrdtConfig.cs b/src/Crdt/CrdtConfig.cs index ffb4388..fe24203 100644 --- a/src/Crdt/CrdtConfig.cs +++ b/src/Crdt/CrdtConfig.cs @@ -63,6 +63,9 @@ public class ChangeTypeListBuilder { private bool _frozen; + /// + /// we call freeze when the builder is used to create a json serializer options, as it is not possible to add new types after that. + /// public void Freeze() { _frozen = true; @@ -70,7 +73,7 @@ public void Freeze() private void CheckFrozen() { - if (_frozen) throw new InvalidOperationException("ObjectTypeListBuilder is frozen"); + if (_frozen) throw new InvalidOperationException($"{nameof(ChangeTypeListBuilder)} is frozen"); } internal List Types { get; } = []; @@ -86,6 +89,10 @@ public ChangeTypeListBuilder Add() where TDerived : IChange, IPolyType public class ObjectTypeListBuilder { private bool _frozen; + + /// + /// we call freeze when the builder is used to create a json serializer options, as it is not possible to add new types after that. + /// public void Freeze() { _frozen = true; @@ -93,7 +100,7 @@ public void Freeze() private void CheckFrozen() { - if (_frozen) throw new InvalidOperationException("ObjectTypeListBuilder is frozen"); + if (_frozen) throw new InvalidOperationException($"{nameof(ObjectTypeListBuilder)} is frozen"); } internal List Types { get; } = []; diff --git a/src/Crdt/Db/EntityConfig/SnapshotEntityConfig.cs b/src/Crdt/Db/EntityConfig/SnapshotEntityConfig.cs index 4bba8eb..c2c46a6 100644 --- a/src/Crdt/Db/EntityConfig/SnapshotEntityConfig.cs +++ b/src/Crdt/Db/EntityConfig/SnapshotEntityConfig.cs @@ -27,6 +27,6 @@ public void Configure(EntityTypeBuilder builder) private IObjectBase DeserializeObject(string json) { return JsonSerializer.Deserialize(json, jsonSerializerOptions) ?? - throw new SerializationException("Could not deserialize Entry: " + json); + throw new SerializationException($"Could not deserialize Entry: {json}"); } } \ No newline at end of file From 8d12137d23fadf03726758e524a8008004747738 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 1 Jul 2024 09:25:45 +0700 Subject: [PATCH 5/7] add some obsolete attributes, simplify ICrdtDbContext interface --- src/Crdt/CrdtKernel.cs | 6 ++++-- src/Crdt/Db/CrdtDbContext.cs | 8 +------- src/Crdt/Db/ICrdtDbContext.cs | 5 ++--- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/Crdt/CrdtKernel.cs b/src/Crdt/CrdtKernel.cs index 1faf21d..3d2af73 100644 --- a/src/Crdt/CrdtKernel.cs +++ b/src/Crdt/CrdtKernel.cs @@ -12,6 +12,8 @@ namespace Crdt; public static class CrdtKernel { + + [Obsolete($"use {nameof(AddCrdtData)} passing in the DbContext type instead instead")] public static IServiceCollection AddCrdtData(this IServiceCollection services, Action configureOptions, Action configureCrdt) @@ -25,9 +27,9 @@ public static IServiceCollection AddCrdtData(this IServiceCollection services, }); return AddCrdtData(services, configureCrdt); } - + public static IServiceCollection AddCrdtData(this IServiceCollection services, - Action configureCrdt) where TContext : class, ICrdtDbContext + Action configureCrdt) where TContext: ICrdtDbContext { services.AddOptions().Configure(configureCrdt); services.AddSingleton(sp => sp.GetRequiredService>().Value.JsonSerializerOptions); diff --git a/src/Crdt/Db/CrdtDbContext.cs b/src/Crdt/Db/CrdtDbContext.cs index 910924a..497068e 100644 --- a/src/Crdt/Db/CrdtDbContext.cs +++ b/src/Crdt/Db/CrdtDbContext.cs @@ -1,11 +1,9 @@ -using System.Text.Json; -using Crdt.Changes; -using Crdt.Core; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; namespace Crdt.Db; +[Obsolete($"use {nameof(ICrdtDbContext)} instead")] public class CrdtDbContext( DbContextOptions options, IOptions crdtConfig) @@ -15,8 +13,4 @@ protected override void OnModelCreating(ModelBuilder builder) { builder.UseCrdt(crdtConfig.Value); } - - public DbSet Commits => Set(); - public DbSet> ChangeEntities => Set>(); - public DbSet Snapshots => Set(); } diff --git a/src/Crdt/Db/ICrdtDbContext.cs b/src/Crdt/Db/ICrdtDbContext.cs index 03dd298..6a14112 100644 --- a/src/Crdt/Db/ICrdtDbContext.cs +++ b/src/Crdt/Db/ICrdtDbContext.cs @@ -8,9 +8,8 @@ namespace Crdt.Db; public interface ICrdtDbContext { - DbSet Commits { get; } - DbSet> ChangeEntities { get; } - DbSet Snapshots { get; } + DbSet Commits => Set(); + DbSet Snapshots => Set(); Task SaveChangesAsync(CancellationToken cancellationToken = default); ValueTask FindAsync(Type entityType, params object?[]? keyValues); DbSet Set() where TEntity : class; From 2b6a46f13e918249cf6c370eccfaf9bf8350bb1b Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 1 Jul 2024 09:43:57 +0700 Subject: [PATCH 6/7] simplify linq2db setup --- src/Crdt.Linq2db/Linq2dbKernel.cs | 19 ++----------------- src/Crdt.Sample/CrdtSampleKernel.cs | 2 -- 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/src/Crdt.Linq2db/Linq2dbKernel.cs b/src/Crdt.Linq2db/Linq2dbKernel.cs index deb5a3e..a0ff93d 100644 --- a/src/Crdt.Linq2db/Linq2dbKernel.cs +++ b/src/Crdt.Linq2db/Linq2dbKernel.cs @@ -1,4 +1,5 @@ using Crdt.Core; +using Crdt.Db; using LinqToDB; using LinqToDB.AspNet.Logging; using LinqToDB.EntityFrameworkCore; @@ -11,25 +12,9 @@ namespace Crdt.Linq2db; public static class Linq2dbKernel { - public static IServiceCollection AddCrdtLinq2db(this IServiceCollection services, - Action configureOptions, - Action configureCrdt) - { - LinqToDBForEFTools.Initialize(); - - services.AddCrdtData( - (provider, builder) => - { - configureOptions.Invoke(provider, builder); - builder.UseLinqToDbCrdt(provider); - }, - configureCrdt - ); - return services; - } - public static DbContextOptionsBuilder UseLinqToDbCrdt(this DbContextOptionsBuilder builder, IServiceProvider provider) { + LinqToDBForEFTools.Initialize(); return builder.UseLinqToDB(optionsBuilder => { var mappingSchema = optionsBuilder.DbContextOptions.GetLinqToDBOptions()?.ConnectionOptions diff --git a/src/Crdt.Sample/CrdtSampleKernel.cs b/src/Crdt.Sample/CrdtSampleKernel.cs index 2fc3476..7aaaaa2 100644 --- a/src/Crdt.Sample/CrdtSampleKernel.cs +++ b/src/Crdt.Sample/CrdtSampleKernel.cs @@ -3,7 +3,6 @@ using Crdt.Linq2db; using Crdt.Sample.Changes; using Crdt.Sample.Models; -using LinqToDB.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -13,7 +12,6 @@ public static class CrdtSampleKernel { public static IServiceCollection AddCrdtDataSample(this IServiceCollection services, string dbPath) { - LinqToDBForEFTools.Initialize(); services.AddDbContext((provider, builder) => { builder.UseLinqToDbCrdt(provider); From 10a2f3cd8400827e81fca5662f1dead594359dd8 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 1 Jul 2024 09:45:04 +0700 Subject: [PATCH 7/7] delete CrdtDbContext.cs rather than marking it obsolete --- src/Crdt/CrdtKernel.cs | 20 -------------------- src/Crdt/Db/CrdtDbContext.cs | 16 ---------------- 2 files changed, 36 deletions(-) delete mode 100644 src/Crdt/Db/CrdtDbContext.cs diff --git a/src/Crdt/CrdtKernel.cs b/src/Crdt/CrdtKernel.cs index 3d2af73..0a5bdea 100644 --- a/src/Crdt/CrdtKernel.cs +++ b/src/Crdt/CrdtKernel.cs @@ -1,9 +1,6 @@ using System.Text.Json; -using System.Text.Json.Serialization.Metadata; using Crdt.Core; using Crdt.Db; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -11,23 +8,6 @@ namespace Crdt; public static class CrdtKernel { - - - [Obsolete($"use {nameof(AddCrdtData)} passing in the DbContext type instead instead")] - public static IServiceCollection AddCrdtData(this IServiceCollection services, - Action configureOptions, - Action configureCrdt) - { - services.AddDbContext((provider, builder) => - { - configureOptions(provider, builder); - builder - .EnableDetailedErrors() - .EnableSensitiveDataLogging(); - }); - return AddCrdtData(services, configureCrdt); - } - public static IServiceCollection AddCrdtData(this IServiceCollection services, Action configureCrdt) where TContext: ICrdtDbContext { diff --git a/src/Crdt/Db/CrdtDbContext.cs b/src/Crdt/Db/CrdtDbContext.cs deleted file mode 100644 index 497068e..0000000 --- a/src/Crdt/Db/CrdtDbContext.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; - -namespace Crdt.Db; - -[Obsolete($"use {nameof(ICrdtDbContext)} instead")] -public class CrdtDbContext( - DbContextOptions options, - IOptions crdtConfig) - : DbContext(options), ICrdtDbContext -{ - protected override void OnModelCreating(ModelBuilder builder) - { - builder.UseCrdt(crdtConfig.Value); - } -}