Skip to content

Commit

Permalink
allow applications to use their own db context (#5)
Browse files Browse the repository at this point in the history
* get JsonSerializerOptions from CrdtConfig, freeze CrdtConfig once it's used to provide json options.

* extract CrdtDbContext into an interface so the DbContext can be defined in the application

* refactor sample project to define it's own dbcontext

* 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.

* add some obsolete attributes, simplify ICrdtDbContext interface

* simplify linq2db setup

* delete CrdtDbContext.cs rather than marking it obsolete
  • Loading branch information
hahn-kev authored Jul 1, 2024
1 parent 588167c commit 7c9e207
Show file tree
Hide file tree
Showing 16 changed files with 297 additions and 231 deletions.
55 changes: 23 additions & 32 deletions src/Crdt.Linq2db/Linq2dbKernel.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Crdt.Core;
using Crdt.Db;
using LinqToDB;
using LinqToDB.AspNet.Logging;
using LinqToDB.EntityFrameworkCore;
Expand All @@ -11,42 +12,32 @@ namespace Crdt.Linq2db;

public static class Linq2dbKernel
{
public static IServiceCollection AddCrdtLinq2db(this IServiceCollection services,
Action<IServiceProvider, DbContextOptionsBuilder> configureOptions,
Action<CrdtConfig> 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<Commit>(new ColumnAttribute("DateTime",
nameof(Commit.HybridDateTime) + "." + nameof(HybridDateTime.DateTime)))
.HasAttribute<Commit>(new ColumnAttribute(nameof(HybridDateTime.Counter),
nameof(Commit.HybridDateTime) + "." + nameof(HybridDateTime.Counter)))
.Entity<Commit>()
//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<Commit>(new ColumnAttribute("DateTime",
nameof(Commit.HybridDateTime) + "." + nameof(HybridDateTime.DateTime)))
.HasAttribute<Commit>(new ColumnAttribute(nameof(HybridDateTime.Counter),
nameof(Commit.HybridDateTime) + "." + nameof(HybridDateTime.Counter)))
.Entity<Commit>()
//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<ILoggerFactory>();
if (loggerFactory is not null)
optionsBuilder.AddCustomOptions(dataOptions => dataOptions.UseLoggerFactory(loggerFactory));
});
},
configureCrdt
);
return services;
var loggerFactory = provider.GetService<ILoggerFactory>();
if (loggerFactory is not null)
optionsBuilder.AddCustomOptions(dataOptions => dataOptions.UseLoggerFactory(loggerFactory));
});
}
}
66 changes: 32 additions & 34 deletions src/Crdt.Sample/CrdtSampleKernel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<NewWordChange>()
.Add<NewDefinitionChange>()
.Add<NewExampleChange>()
.Add<EditExampleChange>()
.Add<SetWordTextChange>()
.Add<SetWordNoteChange>()
.Add<AddAntonymReferenceChange>()
.Add<SetOrderChange<Definition>>()
.Add<DeleteChange<Word>>()
.Add<DeleteChange<Definition>>()
.Add<DeleteChange<Example>>()
;
config.ObjectTypeListBuilder
.Add<Word>()
.Add<Definition>()
.Add<Example>();
});
services.AddDbContext<SampleDbContext>((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<SampleDbContext>(config =>
{
config.EnableProjectedTables = true;
config.ChangeTypeListBuilder
.Add<NewWordChange>()
.Add<NewDefinitionChange>()
.Add<NewExampleChange>()
.Add<EditExampleChange>()
.Add<SetWordTextChange>()
.Add<SetWordNoteChange>()
.Add<AddAntonymReferenceChange>()
.Add<SetOrderChange<Definition>>()
.Add<DeleteChange<Word>>()
.Add<DeleteChange<Definition>>()
.Add<DeleteChange<Example>>()
;
config.ObjectTypeListBuilder
.Add<Word>()
.Add<Definition>()
.Add<Example>();
});
return services;
}
}
19 changes: 19 additions & 0 deletions src/Crdt.Sample/SampleDbContext.cs
Original file line number Diff line number Diff line change
@@ -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(DbContextOptions<SampleDbContext>options, IOptions<CrdtConfig> crdtConfig): DbContext(options), ICrdtDbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.UseCrdt(crdtConfig.Value);
}

public DbSet<Commit> Commits => Set<Commit>();
public DbSet<ChangeEntity<IChange>> ChangeEntities => Set<ChangeEntity<IChange>>();
public DbSet<ObjectSnapshot> Snapshots => Set<ObjectSnapshot>();
}
4 changes: 2 additions & 2 deletions src/Crdt.Tests/DataModelTestBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -25,7 +25,7 @@ public DataModelTestBase()
.AddCrdtDataSample(":memory:")
.Replace(ServiceDescriptor.Singleton<IHybridDateTimeProvider>(MockTimeProvider))
.BuildServiceProvider();
DbContext = _services.GetRequiredService<CrdtDbContext>();
DbContext = _services.GetRequiredService<SampleDbContext>();
DbContext.Database.OpenConnection();
DbContext.Database.EnsureCreated();
DataModel = _services.GetRequiredService<DataModel>();
Expand Down
2 changes: 1 addition & 1 deletion src/Crdt.Tests/ModuleInit.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public static void Initialize()
var services = new ServiceCollection()
.AddCrdtDataSample(":memory:")
.BuildServiceProvider();
var model = services.GetRequiredService<CrdtDbContext>().Model;
var model = services.GetRequiredService<SampleDbContext>().Model;
VerifyEntityFramework.Initialize(model);
VerifierSettings.AddExtraSettings(s =>
{
Expand Down
4 changes: 2 additions & 2 deletions src/Crdt.Tests/RepositoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public class RepositoryTests : IAsyncLifetime
{
private readonly ServiceProvider _services;
private CrdtRepository _repository;
private CrdtDbContext _crdtDbContext;
private SampleDbContext _crdtDbContext;

public RepositoryTests()
{
Expand All @@ -21,7 +21,7 @@ public RepositoryTests()
.BuildServiceProvider();

_repository = _services.GetRequiredService<CrdtRepository>();
_crdtDbContext = _services.GetRequiredService<CrdtDbContext>();
_crdtDbContext = _services.GetRequiredService<SampleDbContext>();
}

public async Task InitializeAsync()
Expand Down
53 changes: 46 additions & 7 deletions src/Crdt/CrdtConfig.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<JsonSerializerOptions> _lazyJsonSerializerOptions;

public CrdtConfig()
{
_lazyJsonSerializerOptions = new Lazy<JsonSerializerOptions>(() => new JsonSerializerOptions(JsonSerializerDefaults.General)
{
TypeInfoResolver = MakeJsonTypeResolver()
});
}

public Action<JsonTypeInfo> MakeJsonTypeModifier()
{
Expand All @@ -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)
Expand All @@ -52,10 +65,25 @@ private void JsonTypeModifier(JsonTypeInfo typeInfo)

public class ChangeTypeListBuilder
{
private bool _frozen;

/// <summary>
/// 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.
/// </summary>
public void Freeze()
{
_frozen = true;
}

private void CheckFrozen()
{
if (_frozen) throw new InvalidOperationException($"{nameof(ChangeTypeListBuilder)} is frozen");
}
internal List<JsonDerivedType> Types { get; } = [];

public ChangeTypeListBuilder Add<TDerived>() 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;
Expand All @@ -64,26 +92,37 @@ public ChangeTypeListBuilder Add<TDerived>() where TDerived : IChange, IPolyType

public class ObjectTypeListBuilder
{
internal List<JsonDerivedType> Types { get; } = [];
private bool _frozen;

internal List<Action<ModelBuilder, CrdtConfig>> ModelConfigurations { get; } = [];
public List<Action<ModelConfigurationBuilder>> ModelConventions { get; } = [];
/// <summary>
/// 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.
/// </summary>
public void Freeze()
{
_frozen = true;
}

public ObjectTypeListBuilder AddDbModelConvention(Action<ModelConfigurationBuilder> modelConvention)
private void CheckFrozen()
{
ModelConventions.Add(modelConvention);
return this;
if (_frozen) throw new InvalidOperationException($"{nameof(ObjectTypeListBuilder)} is frozen");
}

internal List<JsonDerivedType> Types { get; } = [];

internal List<Action<ModelBuilder, CrdtConfig>> ModelConfigurations { get; } = [];

public ObjectTypeListBuilder AddDbModelConfig(Action<ModelBuilder> modelConfiguration)
{
CheckFrozen();
ModelConfigurations.Add((builder, _) => modelConfiguration(builder));
return this;
}


public ObjectTypeListBuilder Add<TDerived>(Action<EntityTypeBuilder<TDerived>>? 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) =>
Expand Down
38 changes: 6 additions & 32 deletions src/Crdt/CrdtKernel.cs
Original file line number Diff line number Diff line change
@@ -1,49 +1,23 @@
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;

namespace Crdt;

public static class CrdtKernel
{
public static IServiceCollection AddCrdtData(this IServiceCollection services,
Action<DbContextOptionsBuilder> configureOptions,
Action<CrdtConfig> configureCrdt)
{
return AddCrdtData(services, (_, builder) => configureOptions(builder), configureCrdt);
}

public static IServiceCollection AddCrdtData(this IServiceCollection services,
Action<IServiceProvider, DbContextOptionsBuilder> configureOptions,
Action<CrdtConfig> configureCrdt)
public static IServiceCollection AddCrdtData<TContext>(this IServiceCollection services,
Action<CrdtConfig> configureCrdt) where TContext: ICrdtDbContext
{
services.AddOptions<CrdtConfig>().Configure(configureCrdt);
services.AddSingleton(sp => new JsonSerializerOptions(JsonSerializerDefaults.General)
{
TypeInfoResolver = new DefaultJsonTypeInfoResolver
{
Modifiers =
{
sp.GetRequiredService<IOptions<CrdtConfig>>().Value.MakeJsonTypeModifier()
}
}
});
services.AddSingleton(sp => sp.GetRequiredService<IOptions<CrdtConfig>>().Value.JsonSerializerOptions);
services.AddSingleton(TimeProvider.System);
services.AddScoped<IHybridDateTimeProvider>(NewTimeProvider);
services.AddDbContext<CrdtDbContext>((provider, builder) =>
{
configureOptions(provider, builder);
builder
.AddInterceptors(provider.GetServices<IInterceptor>().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<ICrdtDbContext>(p => p.GetRequiredService<TContext>());
services.AddScoped<CrdtRepository>();
//must use factory method because DataModel constructor is internal
services.AddScoped<DataModel>(provider => new DataModel(
Expand Down
Loading

0 comments on commit 7c9e207

Please sign in to comment.