diff --git a/src/SIL.Harmony.Sample/Changes/NewDefinitionChange.cs b/src/SIL.Harmony.Sample/Changes/NewDefinitionChange.cs index 7660d10..72a6041 100644 --- a/src/SIL.Harmony.Sample/Changes/NewDefinitionChange.cs +++ b/src/SIL.Harmony.Sample/Changes/NewDefinitionChange.cs @@ -12,7 +12,7 @@ public class NewDefinitionChange(Guid entityId) : CreateChange(entit public required double Order { get; set; } public required Guid WordId { get; init; } - public override async ValueTask NewEntity(Commit commit, ChangeContext context) + public override async ValueTask NewEntity(Commit commit, ChangeContext context) { return new Definition { diff --git a/src/SIL.Harmony.Sample/Changes/NewExampleChange.cs b/src/SIL.Harmony.Sample/Changes/NewExampleChange.cs index c18671c..1279e11 100644 --- a/src/SIL.Harmony.Sample/Changes/NewExampleChange.cs +++ b/src/SIL.Harmony.Sample/Changes/NewExampleChange.cs @@ -34,7 +34,7 @@ private NewExampleChange(Guid entityId) : base(entityId) { } - public override async ValueTask NewEntity(Commit commit, ChangeContext context) + public override async ValueTask NewEntity(Commit commit, ChangeContext context) { return new Example { diff --git a/src/SIL.Harmony.Sample/Changes/NewWordChange.cs b/src/SIL.Harmony.Sample/Changes/NewWordChange.cs index 5762b07..fd01fea 100644 --- a/src/SIL.Harmony.Sample/Changes/NewWordChange.cs +++ b/src/SIL.Harmony.Sample/Changes/NewWordChange.cs @@ -9,7 +9,7 @@ public class NewWordChange(Guid entityId, string text, string? note = null) : Cr public string Text { get; } = text; public string? Note { get; } = note; - public override ValueTask NewEntity(Commit commit, ChangeContext context) + public override ValueTask NewEntity(Commit commit, ChangeContext context) { return new(new Word { Text = Text, Note = Note, Id = EntityId }); } diff --git a/src/SIL.Harmony.Sample/Changes/SetWordTextChange.cs b/src/SIL.Harmony.Sample/Changes/SetWordTextChange.cs index c425db1..681f9ea 100644 --- a/src/SIL.Harmony.Sample/Changes/SetWordTextChange.cs +++ b/src/SIL.Harmony.Sample/Changes/SetWordTextChange.cs @@ -12,7 +12,7 @@ public class SetWordTextChange(Guid entityId, string text) : Change(entity { public string Text { get; } = text; - public override ValueTask NewEntity(Commit commit, ChangeContext context) + public override ValueTask NewEntity(Commit commit, ChangeContext context) { return new(new Word() { diff --git a/src/SIL.Harmony.Sample/CrdtSampleKernel.cs b/src/SIL.Harmony.Sample/CrdtSampleKernel.cs index 188b4b7..7f4c3b6 100644 --- a/src/SIL.Harmony.Sample/CrdtSampleKernel.cs +++ b/src/SIL.Harmony.Sample/CrdtSampleKernel.cs @@ -49,7 +49,7 @@ public static IServiceCollection AddCrdtDataSample(this IServiceCollection servi .Add>() .Add>() ; - config.ObjectTypeListBuilder + config.ObjectTypeListBuilder.DefaultAdapter() .Add() .Add() .Add(); diff --git a/src/SIL.Harmony.Tests/Adapter/CustomObjectAdapterTests.cs b/src/SIL.Harmony.Tests/Adapter/CustomObjectAdapterTests.cs new file mode 100644 index 0000000..c34d994 --- /dev/null +++ b/src/SIL.Harmony.Tests/Adapter/CustomObjectAdapterTests.cs @@ -0,0 +1,200 @@ +using System.Text.Json.Serialization; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using SIL.Harmony.Adapters; +using SIL.Harmony.Changes; +using SIL.Harmony.Db; +using SIL.Harmony.Entities; +using SIL.Harmony.Linq2db; + +namespace SIL.Harmony.Tests.Adapter; + +public class CustomObjectAdapterTests +{ + public class MyDbContext(DbContextOptions options, IOptions crdtConfig) + : DbContext(options), ICrdtDbContext + { + public DbSet MyClasses { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.UseCrdt(crdtConfig.Value); + } + } + + [JsonPolymorphic] + [JsonDerivedType(typeof(MyClass), MyClass.ObjectTypeName)] + [JsonDerivedType(typeof(MyClass2), MyClass2.ObjectTypeName)] + public interface IMyCustomInterface + { + Guid Identifier { get; set; } + long? DeletedTime { get; set; } + string TypeName { get; } + IMyCustomInterface Copy(); + } + + public class MyClass : IMyCustomInterface + { + public const string ObjectTypeName = "MyClassTypeName"; + string IMyCustomInterface.TypeName => ObjectTypeName; + + public IMyCustomInterface Copy() + { + return new MyClass + { + Identifier = Identifier, + DeletedTime = DeletedTime, + MyString = MyString + }; + } + + public Guid Identifier { get; set; } + public long? DeletedTime { get; set; } + public string? MyString { get; set; } + } + + public class MyClass2 : IMyCustomInterface + { + public const string ObjectTypeName = "MyClassTypeName2"; + string IMyCustomInterface.TypeName => ObjectTypeName; + + public IMyCustomInterface Copy() + { + return new MyClass2() + { + Identifier = Identifier, + DeletedTime = DeletedTime, + MyNumber = MyNumber + }; + } + + public Guid Identifier { get; set; } + public long? DeletedTime { get; set; } + public decimal MyNumber { get; set; } + } + + public class MyClassAdapter : ICustomAdapter + { + public static string AdapterTypeName => "MyClassAdapter"; + + public static MyClassAdapter Create(IMyCustomInterface obj) + { + return new MyClassAdapter(obj); + } + + public IMyCustomInterface Obj { get; } + + [JsonConstructor] + public MyClassAdapter(IMyCustomInterface obj) + { + Obj = obj; + } + + [JsonIgnore] + public Guid Id => Obj.Identifier; + + [JsonIgnore] + public DateTimeOffset? DeletedAt + { + get => Obj.DeletedTime.HasValue ? DateTimeOffset.FromUnixTimeSeconds(Obj.DeletedTime.Value) : null; + set => Obj.DeletedTime = value?.ToUnixTimeSeconds(); + } + + public string GetObjectTypeName() => Obj.TypeName; + + [JsonIgnore] + public object DbObject => Obj; + + public Guid[] GetReferences() => []; + + public void RemoveReference(Guid id, Commit commit) + { + } + + public IObjectBase Copy() => new MyClassAdapter(Obj.Copy()); + } + + public class CreateMyClassChange : CreateChange, ISelfNamedType + { + private readonly MyClass _entity; + + public CreateMyClassChange(MyClass entity) : base(entity.Identifier) + { + _entity = entity; + } + + public override ValueTask NewEntity(Commit commit, ChangeContext context) + { + return ValueTask.FromResult(_entity); + } + } + + public class CreateMyClass2Change : CreateChange, ISelfNamedType + { + private readonly MyClass2 _entity; + + public CreateMyClass2Change(MyClass2 entity) : base(entity.Identifier) + { + _entity = entity; + } + + public override ValueTask NewEntity(Commit commit, ChangeContext context) + { + return ValueTask.FromResult(_entity); + } + } + + [Fact] + public async Task CanAdaptACustomObject() + { + var services = new ServiceCollection() + .AddDbContext(builder => builder.UseSqlite("Data Source=test.db")) + .AddCrdtData(config => + { + config.ChangeTypeListBuilder.Add().Add(); + config.ObjectTypeListBuilder + .CustomAdapter() + .Add(builder => builder.HasKey(o => o.Identifier)) + .Add(builder => builder.HasKey(o => o.Identifier)); + }).BuildServiceProvider(); + var myDbContext = services.GetRequiredService(); + await myDbContext.Database.OpenConnectionAsync(); + await myDbContext.Database.EnsureCreatedAsync(); + var dataModel = services.GetRequiredService(); + var objectId = Guid.NewGuid(); + var objectId2 = Guid.NewGuid(); + await dataModel.AddChange(Guid.NewGuid(), + new CreateMyClassChange(new MyClass + { + Identifier = objectId, + MyString = "Hello" + })); + await dataModel.AddChange(Guid.NewGuid(), + new CreateMyClass2Change(new MyClass2 + { + Identifier = objectId2, + MyNumber = 123.45m + })); + + var snapshot = await dataModel.GetLatestSnapshotByObjectId(objectId); + snapshot.Should().NotBeNull(); + snapshot.Entity.Should().NotBeNull(); + var myClass = snapshot.Entity.Is(); + myClass.Should().NotBeNull(); + myClass.Identifier.Should().Be(objectId); + myClass.MyString.Should().Be("Hello"); + myClass.DeletedTime.Should().BeNull(); + + var snapshot2 = await dataModel.GetLatestSnapshotByObjectId(objectId2); + snapshot2.Should().NotBeNull(); + snapshot2.Entity.Should().NotBeNull(); + var myClass2 = snapshot2.Entity.Is(); + myClass2.Should().NotBeNull(); + myClass2.Identifier.Should().Be(objectId2); + myClass2.MyNumber.Should().Be(123.45m); + myClass2.DeletedTime.Should().BeNull(); + + dataModel.GetLatestObjects().Should().NotBeEmpty(); + } +} \ No newline at end of file diff --git a/src/SIL.Harmony.Tests/DataModelPerformanceTests.cs b/src/SIL.Harmony.Tests/DataModelPerformanceTests.cs index 65a4bd6..e65d836 100644 --- a/src/SIL.Harmony.Tests/DataModelPerformanceTests.cs +++ b/src/SIL.Harmony.Tests/DataModelPerformanceTests.cs @@ -11,6 +11,7 @@ using SIL.Harmony.Changes; using SIL.Harmony.Core; using SIL.Harmony.Db; +using SIL.Harmony.Sample.Changes; using SIL.Harmony.Sample.Models; using Xunit.Abstractions; @@ -100,7 +101,7 @@ internal static async Task BulkInsertChanges(DataModelTestBase dataModelTest, in var parentHash = (await dataModelTest.WriteNextChange(dataModelTest.SetWord(Guid.NewGuid(), "entity 1"))).Hash; for (var i = 0; i < count; i++) { - var change = dataModelTest.SetWord(Guid.NewGuid(), $"entity {i}"); + var change = (SetWordTextChange) dataModelTest.SetWord(Guid.NewGuid(), $"entity {i}"); var commitId = Guid.NewGuid(); var commit = new Commit(commitId) { diff --git a/src/SIL.Harmony.Tests/DefinitionTests.cs b/src/SIL.Harmony.Tests/DefinitionTests.cs index 5281548..e21e9c9 100644 --- a/src/SIL.Harmony.Tests/DefinitionTests.cs +++ b/src/SIL.Harmony.Tests/DefinitionTests.cs @@ -14,7 +14,7 @@ public async Task CanAddADefinitionToAWord() await WriteNextChange(NewDefinition(wordId, "a greeting", "verb")); var snapshot = await DataModel.GetProjectSnapshot(); var definitionSnapshot = snapshot.Snapshots.Values.Single(s => s.IsType()); - var definition = (Definition)await DataModel.GetBySnapshotId(definitionSnapshot.Id); + var definition = await DataModel.GetBySnapshotId(definitionSnapshot.Id); definition.Text.Should().Be("a greeting"); definition.WordId.Should().Be(wordId); } diff --git a/src/SIL.Harmony.Tests/ExampleSentenceTests.cs b/src/SIL.Harmony.Tests/ExampleSentenceTests.cs index fa6128c..aba4a6c 100644 --- a/src/SIL.Harmony.Tests/ExampleSentenceTests.cs +++ b/src/SIL.Harmony.Tests/ExampleSentenceTests.cs @@ -21,7 +21,7 @@ public async Task CanAddAnExampleSentenceToAWord() await WriteNextChange(NewExampleSentence(definitionId, "Hello, world!")); var snapshot = await DataModel.GetProjectSnapshot(); var exampleSentenceSnapshot = snapshot.Snapshots.Values.Single(s => s.IsType()); - var exampleSentence = (Example)await DataModel.GetBySnapshotId(exampleSentenceSnapshot.Id); + var exampleSentence = await DataModel.GetBySnapshotId(exampleSentenceSnapshot.Id); exampleSentence.Text.Should().Be("Hello, world!"); exampleSentence.DefinitionId.Should().Be(definitionId); } diff --git a/src/SIL.Harmony.Tests/ModelSnapshotTests.cs b/src/SIL.Harmony.Tests/ModelSnapshotTests.cs index e3fa08e..90bed26 100644 --- a/src/SIL.Harmony.Tests/ModelSnapshotTests.cs +++ b/src/SIL.Harmony.Tests/ModelSnapshotTests.cs @@ -28,8 +28,7 @@ public async Task ModelSnapshotShowsMultipleChanges() var secondChange = await WriteNextChange(SetWord(entityId, "second")); var snapshot = await DataModel.GetProjectSnapshot(); var simpleSnapshot = snapshot.Snapshots.Values.First(); - var entity = await DataModel.GetBySnapshotId(simpleSnapshot.Id); - var entry = entity.Is(); + var entry = await DataModel.GetBySnapshotId(simpleSnapshot.Id); entry.Text.Should().Be("second"); snapshot.LastChange.Should().Be(secondChange.DateTime); } @@ -37,7 +36,7 @@ public async Task ModelSnapshotShowsMultipleChanges() [Theory] [InlineData(10)] [InlineData(100)] - [InlineData(1_000)] + // [InlineData(1_000)] public async Task CanGetSnapshotFromEarlier(int changeCount) { var entityId = Guid.NewGuid(); @@ -81,7 +80,7 @@ await AddCommitsViaSync(Enumerable.Range(0, changeCount) var computedModelSnapshots = await DataModel.GetSnapshotsAt(latestSnapshot.Commit.DateTime); var entitySnapshot = computedModelSnapshots.Should().ContainSingle().Subject.Value; - entitySnapshot.Should().BeEquivalentTo(latestSnapshot, options => options.Excluding(snapshot => snapshot.Id).Excluding(snapshot => snapshot.Commit)); + entitySnapshot.Should().BeEquivalentTo(latestSnapshot, options => options.Excluding(snapshot => snapshot.Id).Excluding(snapshot => snapshot.Commit).Excluding(s => s.Entity.DbObject)); var latestSnapshotEntry = latestSnapshot.Entity.Is(); var entitySnapshotEntry = entitySnapshot.Entity.Is(); entitySnapshotEntry.Text.Should().Be(latestSnapshotEntry.Text); diff --git a/src/SIL.Harmony.Tests/ObjectBaseTestingHelpers.cs b/src/SIL.Harmony.Tests/ObjectBaseTestingHelpers.cs new file mode 100644 index 0000000..a25566c --- /dev/null +++ b/src/SIL.Harmony.Tests/ObjectBaseTestingHelpers.cs @@ -0,0 +1,11 @@ +using SIL.Harmony.Entities; + +namespace SIL.Harmony.Tests; + +public static class ObjectBaseTestingHelpers +{ + public static T Is(this IObjectBase obj) where T : class + { + return (T) obj.DbObject; + } +} \ No newline at end of file diff --git a/src/SIL.Harmony.Tests/SyncTests.cs b/src/SIL.Harmony.Tests/SyncTests.cs index df043c2..4e3f0c0 100644 --- a/src/SIL.Harmony.Tests/SyncTests.cs +++ b/src/SIL.Harmony.Tests/SyncTests.cs @@ -45,11 +45,9 @@ public async Task CanSyncSimpleChange() var client1Snapshot = await _client1.DataModel.GetProjectSnapshot(); var client2Snapshot = await _client2.DataModel.GetProjectSnapshot(); client1Snapshot.LastCommitHash.Should().Be(client2Snapshot.LastCommitHash); - var entity = await _client2.DataModel.GetBySnapshotId(client2Snapshot.Snapshots[entity1Id].Id); - var client2Entity1 = entity.Is(); + var client2Entity1 = await _client2.DataModel.GetBySnapshotId(client2Snapshot.Snapshots[entity1Id].Id); client2Entity1.Text.Should().Be("entity1"); - var entity1 = await _client1.DataModel.GetBySnapshotId(client1Snapshot.Snapshots[entity2Id].Id); - var client1Entity2 = entity1.Is(); + var client1Entity2 = await _client1.DataModel.GetBySnapshotId(client1Snapshot.Snapshots[entity2Id].Id); client1Entity2.Text.Should().Be("entity2"); } @@ -95,15 +93,13 @@ public async Task SyncMultipleClientChanges(int clientCount) serverSnapshot.Snapshots.Should().HaveCount(clientCount + 1); foreach (var entitySnapshot in serverSnapshot.Snapshots.Values) { - var entity1 = await _client1.DataModel.GetBySnapshotId(entitySnapshot.Id); - var serverEntity = entity1.Is(); + var serverEntity = await _client1.DataModel.GetBySnapshotId(entitySnapshot.Id); foreach (var client in clients) { var clientSnapshot = await client.DataModel.GetProjectSnapshot(); var simpleSnapshot = clientSnapshot.Snapshots.Should().ContainKey(entitySnapshot.EntityId).WhoseValue; - var entity2 = await client.DataModel.GetBySnapshotId(simpleSnapshot.Id); - var entity = entity2.Is(); - entity.Should().BeEquivalentTo(serverEntity); + var entity = await client.DataModel.GetBySnapshotId(simpleSnapshot.Id); + entity.Should().BeEquivalentTo(serverEntity); } } } diff --git a/src/SIL.Harmony/Adapters/CustomAdapterProvider.cs b/src/SIL.Harmony/Adapters/CustomAdapterProvider.cs new file mode 100644 index 0000000..53231cc --- /dev/null +++ b/src/SIL.Harmony/Adapters/CustomAdapterProvider.cs @@ -0,0 +1,69 @@ +using System.Text.Json.Serialization.Metadata; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SIL.Harmony.Entities; +using SIL.Harmony.Helpers; + +namespace SIL.Harmony.Adapters; + +public class CustomAdapterProvider : IObjectAdapterProvider + where TCommonInterface : class + where TCustomAdapter : class, ICustomAdapter, IPolyType +{ + private readonly ObjectTypeListBuilder _objectTypeListBuilder; + private readonly List _objectTypes = new(); + private Dictionary> JsonTypes { get; } = []; + Dictionary> IObjectAdapterProvider.JsonTypes => JsonTypes; + + public CustomAdapterProvider(ObjectTypeListBuilder objectTypeListBuilder) + { + _objectTypeListBuilder = objectTypeListBuilder; + JsonTypes.AddDerivedType(typeof(IObjectBase), typeof(TCustomAdapter), TCustomAdapter.TypeName); + } + + public CustomAdapterProvider AddWithCustomPolymorphicMapping(string typeName, + Action>? configureEntry = null + ) where T : class, TCommonInterface + { + JsonTypes.AddDerivedType(typeof(TCommonInterface), typeof(T), typeName); + return Add(configureEntry); + } + + public CustomAdapterProvider Add( + Action>? configureEntry = null + ) where T : class, TCommonInterface + { + _objectTypeListBuilder.CheckFrozen(); + _objectTypes.Add( + new AdapterRegistration(typeof(T), + builder => + { + var entity = builder.Entity(); + configureEntry?.Invoke(entity); + return entity; + }) + ); + return this; + } + + IEnumerable IObjectAdapterProvider.GetRegistrations() + { + return _objectTypes; + } + + IObjectBase IObjectAdapterProvider.Adapt(object obj) + { + return TCustomAdapter.Create((TCommonInterface)obj); + } +} + +// it's possible to implement this without a Common interface +// but it would require the adapter to have 1 property for each object type in order to support deserialization +public interface ICustomAdapter : IObjectBase, IPolyType + where TSelf : class, + ICustomAdapter +{ + public static abstract TSelf Create(TCommonInterface obj); + static string IPolyType.TypeName => TSelf.AdapterTypeName; + public static abstract string AdapterTypeName { get; } +} \ No newline at end of file diff --git a/src/SIL.Harmony/Adapters/DefaultAdapterProvider.cs b/src/SIL.Harmony/Adapters/DefaultAdapterProvider.cs new file mode 100644 index 0000000..53bea0f --- /dev/null +++ b/src/SIL.Harmony/Adapters/DefaultAdapterProvider.cs @@ -0,0 +1,43 @@ +using System.Text.Json.Serialization.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SIL.Harmony.Entities; +using SIL.Harmony.Helpers; + +namespace SIL.Harmony.Adapters; + +public class DefaultAdapterProvider(ObjectTypeListBuilder objectTypeListBuilder) : IObjectAdapterProvider +{ + private readonly List _objectTypes = []; + + IEnumerable IObjectAdapterProvider.GetRegistrations() + { + return _objectTypes.AsReadOnly(); + } + + public DefaultAdapterProvider Add(Action>? configureEntry = null) where T : class, IObjectBase + { + objectTypeListBuilder.CheckFrozen(); + JsonTypes.AddDerivedType(typeof(IObjectBase), typeof(T), T.TypeName); + _objectTypes.Add(new(typeof(T), builder => + { + var entity = builder.Entity(); + configureEntry?.Invoke(entity); + return entity; + })); + return this; + } + + IObjectBase IObjectAdapterProvider.Adapt(object obj) + { + if (obj is IObjectBase objectBase) + { + return objectBase; + } + + throw new ArgumentException( + $"Object is of type {obj.GetType().Name} which does not implement {nameof(IObjectBase)}"); + } + + private Dictionary> JsonTypes { get; } = []; + Dictionary> IObjectAdapterProvider.JsonTypes => JsonTypes; +} \ No newline at end of file diff --git a/src/SIL.Harmony/Adapters/IObjectAdapterProvider.cs b/src/SIL.Harmony/Adapters/IObjectAdapterProvider.cs new file mode 100644 index 0000000..6504c0a --- /dev/null +++ b/src/SIL.Harmony/Adapters/IObjectAdapterProvider.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization.Metadata; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SIL.Harmony.Entities; + +namespace SIL.Harmony.Adapters; + +internal record AdapterRegistration(Type ObjectDbType, Func EntityBuilder); + +internal interface IObjectAdapterProvider +{ + IEnumerable GetRegistrations(); + IObjectBase Adapt(object obj); + + Dictionary> JsonTypes { get; } +} \ No newline at end of file diff --git a/src/SIL.Harmony/Changes/Change.cs b/src/SIL.Harmony/Changes/Change.cs index 837883e..6114b36 100644 --- a/src/SIL.Harmony/Changes/Change.cs +++ b/src/SIL.Harmony/Changes/Change.cs @@ -24,7 +24,7 @@ public interface IChange /// a change that can be applied to an entity, recommend inheriting from or /// /// Object type modified by this change -public abstract class Change : IChange where T : IObjectBase +public abstract class Change : IChange where T : class { protected Change(Guid entityId) { @@ -36,13 +36,26 @@ protected Change(Guid entityId) public Guid EntityId { get; set; } - public abstract ValueTask NewEntity(Commit commit, ChangeContext context); + async ValueTask IChange.NewEntity(Commit commit, ChangeContext context) + { + return context.Adapt(await NewEntity(commit, context)); + } + + public abstract ValueTask NewEntity(Commit commit, ChangeContext context); public abstract ValueTask ApplyChange(T entity, ChangeContext context); public async ValueTask ApplyChange(IObjectBase entity, ChangeContext context) { - if (this is CreateChange) return; // skip attempting to apply changes on CreateChange as it does not support apply changes - if (entity is T entityT) await ApplyChange(entityT, context); + if (this is CreateChange) + return; // skip attempting to apply changes on CreateChange as it does not support apply changes + if (entity.DbObject is T entityT) + { + await ApplyChange(entityT, context); + } + else + { + throw new NotSupportedException($"Type {entity.DbObject.GetType()} is not type {typeof(T)}"); + } } [JsonIgnore] diff --git a/src/SIL.Harmony/Changes/ChangeContext.cs b/src/SIL.Harmony/Changes/ChangeContext.cs index d589374..15c10d7 100644 --- a/src/SIL.Harmony/Changes/ChangeContext.cs +++ b/src/SIL.Harmony/Changes/ChangeContext.cs @@ -1,19 +1,29 @@ using SIL.Harmony.Db; +using SIL.Harmony.Entities; namespace SIL.Harmony.Changes; public class ChangeContext { private readonly SnapshotWorker _worker; + private readonly CrdtConfig _crdtConfig; - internal ChangeContext(Commit commit, SnapshotWorker worker) + internal ChangeContext(Commit commit, SnapshotWorker worker, CrdtConfig crdtConfig) { _worker = worker; + _crdtConfig = crdtConfig; Commit = commit; } public Commit Commit { get; } public async ValueTask GetSnapshot(Guid entityId) => await _worker.GetSnapshot(entityId); + public async ValueTask GetCurrent(Guid entityId) where T : class + { + var snapshot = await GetSnapshot(entityId); + if (snapshot is null) return null; + return (T) snapshot.Entity.DbObject; + } public async ValueTask IsObjectDeleted(Guid entityId) => (await GetSnapshot(entityId))?.EntityIsDeleted ?? true; + internal IObjectBase Adapt(object obj) => _crdtConfig.ObjectTypeListBuilder.AdapterProvider.Adapt(obj); } diff --git a/src/SIL.Harmony/Changes/CreateChange.cs b/src/SIL.Harmony/Changes/CreateChange.cs index ed2ac78..8c90e1d 100644 --- a/src/SIL.Harmony/Changes/CreateChange.cs +++ b/src/SIL.Harmony/Changes/CreateChange.cs @@ -2,8 +2,7 @@ namespace SIL.Harmony.Changes; -public abstract class CreateChange(Guid entityId) : Change(entityId) - where T : IObjectBase +public abstract class CreateChange(Guid entityId) : Change(entityId) where T : class { public override ValueTask ApplyChange(T entity, ChangeContext context) { diff --git a/src/SIL.Harmony/Changes/DeleteChange.cs b/src/SIL.Harmony/Changes/DeleteChange.cs index c945a60..2e55f70 100644 --- a/src/SIL.Harmony/Changes/DeleteChange.cs +++ b/src/SIL.Harmony/Changes/DeleteChange.cs @@ -3,13 +3,13 @@ namespace SIL.Harmony.Changes; public class DeleteChange(Guid entityId) : EditChange(entityId), IPolyType - where T : IPolyType, IObjectBase + where T : class { - public static string TypeName => "delete:" + T.TypeName; + public static string TypeName => "delete:" + typeof(T).Name; public override ValueTask ApplyChange(T entity, ChangeContext context) { - entity.DeletedAt = context.Commit.DateTime; + context.Adapt(entity).DeletedAt = context.Commit.DateTime; return ValueTask.CompletedTask; } } diff --git a/src/SIL.Harmony/Changes/EditChange.cs b/src/SIL.Harmony/Changes/EditChange.cs index 73c6458..5d3d70c 100644 --- a/src/SIL.Harmony/Changes/EditChange.cs +++ b/src/SIL.Harmony/Changes/EditChange.cs @@ -1,11 +1,9 @@ -using SIL.Harmony.Entities; - -namespace SIL.Harmony.Changes; +namespace SIL.Harmony.Changes; public abstract class EditChange(Guid entityId) : Change(entityId) - where T : IObjectBase + where T : class { - public override ValueTask NewEntity(Commit commit, ChangeContext context) + public override ValueTask NewEntity(Commit commit, ChangeContext context) { throw new NotSupportedException( $"type {GetType().Name} does not support NewEntity, because it inherits from {nameof(EditChange)}, this means it must be called with a from an existing entity, not a newly generated one"); diff --git a/src/SIL.Harmony/Changes/SetOrderChange.cs b/src/SIL.Harmony/Changes/SetOrderChange.cs index 091a0ba..18fb9c1 100644 --- a/src/SIL.Harmony/Changes/SetOrderChange.cs +++ b/src/SIL.Harmony/Changes/SetOrderChange.cs @@ -5,10 +5,11 @@ namespace SIL.Harmony.Changes; public interface IOrderableCrdt { public double Order { get; set; } + public Guid Id { get; } } public class SetOrderChange : EditChange, IPolyType - where T : IPolyType, IObjectBase, IOrderableCrdt + where T : class, IPolyType, IObjectBase, IOrderableCrdt { public static IChange Between(Guid entityId, T left, T right) { diff --git a/src/SIL.Harmony/CrdtConfig.cs b/src/SIL.Harmony/CrdtConfig.cs index 849bdcc..1b9837a 100644 --- a/src/SIL.Harmony/CrdtConfig.cs +++ b/src/SIL.Harmony/CrdtConfig.cs @@ -2,6 +2,7 @@ using System.Text.Json.Serialization.Metadata; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SIL.Harmony.Adapters; using SIL.Harmony.Changes; using SIL.Harmony.Db; using SIL.Harmony.Entities; @@ -57,9 +58,10 @@ private void JsonTypeModifier(JsonTypeInfo typeInfo) } } - if (typeInfo.Type == typeof(IObjectBase)) + if (ObjectTypeListBuilder.JsonTypes?.TryGetValue(typeInfo.Type, out var types) == true) { - foreach (var type in ObjectTypeListBuilder.Types) + if (typeInfo.PolymorphismOptions is null) typeInfo.PolymorphismOptions = new(); + foreach (var type in types) { typeInfo.PolymorphismOptions!.DerivedTypes.Add(type); } @@ -103,52 +105,66 @@ public class ObjectTypeListBuilder /// public void Freeze() { + if (_frozen) return; _frozen = true; + JsonTypes = AdapterProvider.JsonTypes; + foreach (var registration in AdapterProvider.GetRegistrations()) + { + ModelConfigurations.Add((builder, config) => + { + if (!config.EnableProjectedTables) return; + var entity = registration.EntityBuilder(builder); + entity.HasOne(typeof(ObjectSnapshot)) + .WithOne() + .HasForeignKey(registration.ObjectDbType, ObjectSnapshot.ShadowRefName) + .OnDelete(DeleteBehavior.SetNull); + }); + } } - private void CheckFrozen() + internal void CheckFrozen() { if (_frozen) throw new InvalidOperationException($"{nameof(ObjectTypeListBuilder)} is frozen"); } - internal List Types { get; } = []; + internal Dictionary>? JsonTypes { get; set; } internal List> ModelConfigurations { get; } = []; - public ObjectTypeListBuilder AddDbModelConfig(Action modelConfiguration) + internal IObjectAdapterProvider AdapterProvider => _adapterProvider ?? throw new InvalidOperationException("No adapter has been added to the builder"); + private IObjectAdapterProvider? _adapterProvider; + + public DefaultAdapterProvider DefaultAdapter() { CheckFrozen(); - ModelConfigurations.Add((builder, _) => modelConfiguration(builder)); - return this; + if (_adapterProvider is not null) throw new InvalidOperationException("adapter has already been added"); + var adapter = new DefaultAdapterProvider(this); + _adapterProvider = adapter; + return adapter; } - - - public ObjectTypeListBuilder Add(Action>? configureDb = null) - where TDerived : class, IObjectBase + + /// + /// add a custom adapter for a common interface + /// this is required as CRDT objects must express their references and have an Id property + /// using a custom adapter allows your model to not take a dependency on Harmony + /// + /// + /// A common interface that all objects in your application implement + /// which System.Text.Json will deserialize your objects to, they must support polymorphic deserialization + /// + /// + /// This adapter will be serialized and stored in the database, + /// it should include the object it is adapting otherwise Harmony will not work + /// + /// + /// when another adapter has already been added or the config has been frozen + public CustomAdapterProvider CustomAdapter() + where TCommonInterface : class where TAdapter : class, ICustomAdapter, IPolyType { 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) => - { - if (!config.EnableProjectedTables) return; - var baseType = typeof(TDerived).BaseType; - if (baseType is not null) - builder.Ignore(baseType); - var entity = builder.Entity(); - entity.HasBaseType((Type)null!); - entity.HasKey(e => e.Id); - entity.Property(e => e.Id); - entity.HasOne() - .WithOne() - .HasForeignKey(ObjectSnapshot.ShadowRefName) - //set null otherwise it will cascade delete, which would happen whenever snapshots are deleted - .OnDelete(DeleteBehavior.SetNull); - - entity.Property(e => e.DeletedAt); - entity.Ignore(e => e.TypeName); - configureDb?.Invoke(entity); - }); - return this; + if (_adapterProvider is not null) throw new InvalidOperationException("adapter has already been added"); + var adapter = new CustomAdapterProvider(this); + _adapterProvider = adapter; + return adapter; } } diff --git a/src/SIL.Harmony/CrdtKernel.cs b/src/SIL.Harmony/CrdtKernel.cs index 6818b72..67671be 100644 --- a/src/SIL.Harmony/CrdtKernel.cs +++ b/src/SIL.Harmony/CrdtKernel.cs @@ -11,7 +11,7 @@ public static class CrdtKernel public static IServiceCollection AddCrdtData(this IServiceCollection services, Action configureCrdt) where TContext: ICrdtDbContext { - services.AddOptions().Configure(configureCrdt); + services.AddOptions().Configure(configureCrdt).PostConfigure(crdtConfig => crdtConfig.ObjectTypeListBuilder.Freeze()); services.AddSingleton(sp => sp.GetRequiredService>().Value.JsonSerializerOptions); services.AddSingleton(TimeProvider.System); services.AddScoped(NewTimeProvider); diff --git a/src/SIL.Harmony/DataModel.cs b/src/SIL.Harmony/DataModel.cs index 89be491..a0e0292 100644 --- a/src/SIL.Harmony/DataModel.cs +++ b/src/SIL.Harmony/DataModel.cs @@ -164,7 +164,7 @@ private async Task UpdateSnapshots(Commit oldestAddedCommit, Commit[] newCommits snapshotLookup = []; } - var snapshotWorker = new SnapshotWorker(snapshotLookup, _crdtRepository); + var snapshotWorker = new SnapshotWorker(snapshotLookup, _crdtRepository, _crdtConfig.Value); await snapshotWorker.UpdateSnapshots(oldestAddedCommit, newCommits); } @@ -199,7 +199,7 @@ public async Task GetLatestSnapshotByObjectId(Guid entityId) return await _crdtRepository.GetCurrentSnapshotByObjectId(entityId) ?? throw new ArgumentException($"unable to find snapshot for entity {entityId}"); } - public async Task GetLatest(Guid objectId) where T : class, IObjectBase + public async Task GetLatest(Guid objectId) where T : class { return await _crdtRepository.GetCurrent(objectId); } @@ -209,19 +209,19 @@ public async Task GetProjectSnapshot(bool includeDeleted = false) return new ModelSnapshot(await _crdtRepository.CurrenSimpleSnapshots(includeDeleted).ToArrayAsync()); } - public IQueryable GetLatestObjects() where T : class, IObjectBase + public IQueryable GetLatestObjects() where T : class { var q = _crdtRepository.GetCurrentObjects(); if (q is IQueryable) { - q = q.OrderBy(o => EF.Property(o, nameof(IOrderableCrdt.Order))).ThenBy(o => o.Id); + q = q.OrderBy(o => EF.Property(o, nameof(IOrderableCrdt.Order))).ThenBy(o => EF.Property(o, nameof(IOrderableCrdt.Id))); } return q; } - public async Task GetBySnapshotId(Guid snapshotId) + public async Task GetBySnapshotId(Guid snapshotId) { - return await _crdtRepository.GetObjectBySnapshotId(snapshotId); + return await _crdtRepository.GetObjectBySnapshotId(snapshotId); } public async Task> GetSnapshotsAt(DateTimeOffset dateTime) @@ -231,7 +231,7 @@ public async Task> GetSnapshotsAt(DateTimeOffse if (pendingCommits.Length != 0) { - snapshots = await SnapshotWorker.ApplyCommitsToSnapshots(snapshots, repository, pendingCommits); + snapshots = await SnapshotWorker.ApplyCommitsToSnapshots(snapshots, repository, pendingCommits, _crdtConfig.Value); } return snapshots; diff --git a/src/SIL.Harmony/Db/CrdtRepository.cs b/src/SIL.Harmony/Db/CrdtRepository.cs index 8358e72..bb94e70 100644 --- a/src/SIL.Harmony/Db/CrdtRepository.cs +++ b/src/SIL.Harmony/Db/CrdtRepository.cs @@ -158,33 +158,31 @@ public async Task GetCommitsAfter(Commit? commit) .LastOrDefaultAsync(s => s.EntityId == objectId && (ignoreChangesAfter == null || s.Commit.DateTime <= ignoreChangesAfter)); } - public async Task GetObjectBySnapshotId(Guid snapshotId) + public async Task GetObjectBySnapshotId(Guid snapshotId) { var entity = await Snapshots .Where(s => s.Id == snapshotId) .Select(s => s.Entity) .SingleOrDefaultAsync() ?? throw new ArgumentException($"unable to find snapshot with id {snapshotId}"); - return entity; + return (T) entity; } - public async Task GetCurrent(Guid objectId) where T: class, IObjectBase + public async Task GetCurrent(Guid objectId) where T: class { var snapshot = await Snapshots .DefaultOrder() .LastOrDefaultAsync(s => s.EntityId == objectId && (ignoreChangesAfter == null || s.Commit.DateTime <= ignoreChangesAfter)); - return snapshot?.Entity.Is(); + return (T?) snapshot?.Entity.DbObject; } - public IQueryable GetCurrentObjects() where T : class, IObjectBase + public IQueryable GetCurrentObjects() where T : class { if (crdtConfig.Value.EnableProjectedTables) { return _dbContext.Set(); } - var typeName = DerivedTypeHelper.GetEntityDiscriminator(); - var queryable = CurrentSnapshots().Where(s => s.TypeName == typeName && !s.EntityIsDeleted); - return queryable.Select(s => (T)s.Entity); + throw new NotSupportedException("GetCurrentObjects is not supported when not using projected tables"); } public async Task GetCurrentSyncState() @@ -230,12 +228,12 @@ private async ValueTask SnapshotAdded(ObjectSnapshot objectSnapshot) if (!crdtConfig.Value.EnableProjectedTables) return; if (objectSnapshot.IsRoot && objectSnapshot.EntityIsDeleted) return; //need to check if an entry exists already, even if this is the root commit it may have already been added to the db - var existingEntry = await GetEntityEntry(objectSnapshot.Entity.GetType(), objectSnapshot.EntityId); + var existingEntry = await GetEntityEntry(objectSnapshot.Entity.DbObject.GetType(), objectSnapshot.EntityId); if (existingEntry is null && objectSnapshot.IsRoot) { //if we don't make a copy first then the entity will be tracked by the context and be modified //by future changes in the same session - _dbContext.Add((object)objectSnapshot.Entity.Copy()) + _dbContext.Add((object)objectSnapshot.Entity.Copy().DbObject) .Property(ObjectSnapshot.ShadowRefName).CurrentValue = objectSnapshot.Id; return; } @@ -247,7 +245,7 @@ private async ValueTask SnapshotAdded(ObjectSnapshot objectSnapshot) return; } - existingEntry.CurrentValues.SetValues(objectSnapshot.Entity); + existingEntry.CurrentValues.SetValues(objectSnapshot.Entity.DbObject); existingEntry.Property(ObjectSnapshot.ShadowRefName).CurrentValue = objectSnapshot.Id; } diff --git a/src/SIL.Harmony/Db/ObjectSnapshot.cs b/src/SIL.Harmony/Db/ObjectSnapshot.cs index e9715ea..53f2e10 100644 --- a/src/SIL.Harmony/Db/ObjectSnapshot.cs +++ b/src/SIL.Harmony/Db/ObjectSnapshot.cs @@ -16,7 +16,7 @@ public record SimpleSnapshot( string CommitHash, bool EntityIsDeleted) { - public bool IsType() where T : IObjectBase => TypeName == DerivedTypeHelper.GetEntityDiscriminator(); + public bool IsType() where T : IObjectBase, IPolyType => TypeName == DerivedTypeHelper.GetEntityDiscriminator(); public SimpleSnapshot(ObjectSnapshot snapshot) : this(snapshot.Id, snapshot.TypeName, @@ -62,7 +62,7 @@ public ObjectSnapshot(IObjectBase entity, Commit commit, bool isRoot) : this() References = entity.GetReferences(); EntityId = entity.Id; EntityIsDeleted = entity.DeletedAt.HasValue; - TypeName = entity.TypeName; + TypeName = entity.GetObjectTypeName(); CommitId = commit.Id; Commit = commit; IsRoot = isRoot; diff --git a/src/SIL.Harmony/Entities/IObjectBase.cs b/src/SIL.Harmony/Entities/IObjectBase.cs index 0d6366d..a941712 100644 --- a/src/SIL.Harmony/Entities/IObjectBase.cs +++ b/src/SIL.Harmony/Entities/IObjectBase.cs @@ -3,31 +3,41 @@ namespace SIL.Harmony.Entities; [JsonPolymorphic] -public interface IObjectBase: IPolyType +public interface IObjectBase { - Guid Id { get; init; } + Guid Id { get; } DateTimeOffset? DeletedAt { get; set; } - public T Is() where T : IObjectBase - { - return (T)this; - } - - public T? As() where T : class, IObjectBase - { - return this as T; - } - + /// + /// provides the references this object has to other objects, when those objects are deleted + /// will be called to remove the reference + /// + /// public Guid[] GetReferences(); + /// + /// remove a reference to another object, in some cases this may cause this object to be deleted + /// + /// id of the deleted object + /// + /// commit where the reference was removed + /// should be used to set the deleted date for this object + /// public void RemoveReference(Guid id, Commit commit); public IObjectBase Copy(); - new string TypeName { get; } - static string IPolyType.TypeName => throw new NotImplementedException(); + /// + /// the name of the object type, this is used to discriminate between different types of objects in the snapshots table + /// + /// a stable type name of this object, should not change over time + public string GetObjectTypeName(); + [JsonIgnore] + public object DbObject { get; } } -public interface IObjectBase : IObjectBase where TThis : IPolyType +public interface IObjectBase : IObjectBase, IPolyType where TThis : IPolyType { - string IObjectBase.TypeName => TThis.TypeName; + string IObjectBase.GetObjectTypeName() => TThis.TypeName; static string IPolyType.TypeName => typeof(TThis).Name; + [JsonIgnore] + object IObjectBase.DbObject => this; } diff --git a/src/SIL.Harmony/Helpers/DerivedTypeHelper.cs b/src/SIL.Harmony/Helpers/DerivedTypeHelper.cs index fff0619..599cf76 100644 --- a/src/SIL.Harmony/Helpers/DerivedTypeHelper.cs +++ b/src/SIL.Harmony/Helpers/DerivedTypeHelper.cs @@ -33,4 +33,22 @@ public static string GetEntityDiscriminator() where T: IPolyType { return T.TypeName; } + + public static void AddDerivedType(this Dictionary> types, Type baseType, Type derivedType, string discriminator) + { + types.TryGetValue(baseType, out var list); + if (list is null) + { + list = new List(); + types.Add(baseType, list); + } + else + { + if (list.Any(dt => dt.DerivedType == derivedType)) + { + throw new InvalidOperationException($"Type {derivedType} already added for type {baseType}"); + } + } + list.Add(new JsonDerivedType(derivedType, discriminator)); + } } diff --git a/src/SIL.Harmony/SnapshotWorker.cs b/src/SIL.Harmony/SnapshotWorker.cs index 60ab1dd..67f5e91 100644 --- a/src/SIL.Harmony/SnapshotWorker.cs +++ b/src/SIL.Harmony/SnapshotWorker.cs @@ -14,35 +14,40 @@ internal class SnapshotWorker { private readonly Dictionary _snapshotLookup; private readonly CrdtRepository _crdtRepository; + private readonly CrdtConfig _crdtConfig; private readonly Dictionary _pendingSnapshots = []; private readonly List _newIntermediateSnapshots = []; - private SnapshotWorker(Dictionary snapshots, CrdtRepository crdtRepository) + private SnapshotWorker(Dictionary snapshots, + Dictionary snapshotLookup, + CrdtRepository crdtRepository, + CrdtConfig crdtConfig) { _pendingSnapshots = snapshots; _crdtRepository = crdtRepository; - _snapshotLookup = []; + _snapshotLookup = snapshotLookup; + _crdtConfig = crdtConfig; } - internal static async Task> ApplyCommitsToSnapshots(Dictionary snapshots, + internal static async Task> ApplyCommitsToSnapshots( + Dictionary snapshots, CrdtRepository crdtRepository, - ICollection commits) + ICollection commits, + CrdtConfig crdtConfig) { //we need to pass in the snapshots because we expect it to be modified, this is intended. //if the constructor makes a copy in the future this will need to be updated - await new SnapshotWorker(snapshots, crdtRepository).ApplyCommitChanges(commits, false, null); + await new SnapshotWorker(snapshots, [], crdtRepository, crdtConfig).ApplyCommitChanges(commits, false, null); return snapshots; } - /// - /// - /// /// a dictionary of entity id to latest snapshot id /// - internal SnapshotWorker(Dictionary snapshotLookup, CrdtRepository crdtRepository) + /// + internal SnapshotWorker(Dictionary snapshotLookup, + CrdtRepository crdtRepository, + CrdtConfig crdtConfig): this([], snapshotLookup, crdtRepository, crdtConfig) { - _snapshotLookup = snapshotLookup; - _crdtRepository = crdtRepository; } public async Task UpdateSnapshots(Commit oldestAddedCommit, Commit[] newCommits) @@ -74,7 +79,7 @@ private async ValueTask ApplyCommitChanges(IEnumerable commits, bool upd { IObjectBase entity; var prevSnapshot = await GetSnapshot(commitChange.EntityId); - var changeContext = new ChangeContext(commit, this); + var changeContext = new ChangeContext(commit, this, _crdtConfig); bool wasDeleted; if (prevSnapshot is not null) {