diff --git a/src/Crdt.Linq2db/Linq2dbKernel.cs b/src/Crdt.Linq2db/Linq2dbKernel.cs index b1ec9a1..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,42 +12,32 @@ namespace Crdt.Linq2db; public static class Linq2dbKernel { - public static IServiceCollection AddCrdtLinq2db(this IServiceCollection services, - Action configureOptions, - Action configureCrdt) + public static DbContextOptionsBuilder UseLinqToDbCrdt(this DbContextOptionsBuilder builder, IServiceProvider provider) { LinqToDBForEFTools.Initialize(); - - services.AddCrdtData( - (provider, builder) => + return builder.UseLinqToDB(optionsBuilder => + { + var mappingSchema = optionsBuilder.DbContextOptions.GetLinqToDBOptions()?.ConnectionOptions + .MappingSchema; + if (mappingSchema is null) { - configureOptions.Invoke(provider, builder); - builder.UseLinqToDB(optionsBuilder => - { - var mappingSchema = optionsBuilder.DbContextOptions.GetLinqToDBOptions()?.ConnectionOptions - .MappingSchema; - if (mappingSchema is null) - { - mappingSchema = new MappingSchema(); - optionsBuilder.AddMappingSchema(mappingSchema); - } + 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(); + 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)); - }); - }, - configureCrdt - ); - return services; + 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..7aaaaa2 100644 --- a/src/Crdt.Sample/CrdtSampleKernel.cs +++ b/src/Crdt.Sample/CrdtSampleKernel.cs @@ -10,41 +10,39 @@ 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(); - }); + 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 10b3a5b..887e0b5 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() diff --git a/src/Crdt/CrdtConfig.cs b/src/Crdt/CrdtConfig.cs index e3076fd..1ab9e61 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; @@ -16,6 +17,16 @@ public class CrdtConfig public bool EnableProjectedTables { get; set; } = true; 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() { @@ -32,6 +43,8 @@ public IJsonTypeInfoResolver MakeJsonTypeResolver() private void JsonTypeModifier(JsonTypeInfo typeInfo) { + ChangeTypeListBuilder.Freeze(); + ObjectTypeListBuilder.Freeze(); if (typeInfo.Type == typeof(IChange)) { foreach (var type in ChangeTypeListBuilder.Types) @@ -52,10 +65,25 @@ private void JsonTypeModifier(JsonTypeInfo typeInfo) 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; + } + + private void CheckFrozen() + { + if (_frozen) throw new InvalidOperationException($"{nameof(ChangeTypeListBuilder)} 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; @@ -64,26 +92,37 @@ public ChangeTypeListBuilder Add() where TDerived : IChange, IPolyType public class ObjectTypeListBuilder { - internal List Types { get; } = []; + private bool _frozen; - internal List> ModelConfigurations { get; } = []; - public List> ModelConventions { get; } = []; + /// + /// 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; + } - public ObjectTypeListBuilder AddDbModelConvention(Action modelConvention) + private void CheckFrozen() { - ModelConventions.Add(modelConvention); - return this; + if (_frozen) throw new InvalidOperationException($"{nameof(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..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,39 +8,16 @@ 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) + public static IServiceCollection AddCrdtData(this IServiceCollection services, + Action configureCrdt) where TContext: ICrdtDbContext { 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) => - { - 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 deleted file mode 100644 index a2df5ae..0000000 --- a/src/Crdt/Db/CrdtDbContext.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System.Runtime.Serialization; -using System.Text.Json; -using Crdt.Changes; -using Crdt.Core; -using Crdt.Entities; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; - -namespace Crdt.Db; - -public class CrdtDbContext( - DbContextOptions options, - IOptions crdtConfig, - JsonSerializerOptions jsonSerializerOptions) - : DbContext(options) -{ - 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); - } - } - - 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)); - } -} 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 116a3d9..65817f1 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..c2c46a6 --- /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..6a14112 --- /dev/null +++ b/src/Crdt/Db/ICrdtDbContext.cs @@ -0,0 +1,21 @@ +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 => Set(); + DbSet Snapshots => Set(); + 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