From d4994f55b01d41171e5a3ca9ab9084d26e753053 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 7 Oct 2024 17:16:59 +0700 Subject: [PATCH 01/14] rework how objects are registered to use the default adapter which requires each object to implement IObjectBase --- src/SIL.Harmony.Sample/CrdtSampleKernel.cs | 2 +- src/SIL.Harmony/Adapters/DefaultAdapter.cs | 36 ++++++++++++++ src/SIL.Harmony/Adapters/IObjectAdapter.cs | 16 ++++++ src/SIL.Harmony/CrdtConfig.cs | 57 ++++++++++++---------- src/SIL.Harmony/CrdtKernel.cs | 2 +- 5 files changed, 84 insertions(+), 29 deletions(-) create mode 100644 src/SIL.Harmony/Adapters/DefaultAdapter.cs create mode 100644 src/SIL.Harmony/Adapters/IObjectAdapter.cs 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/Adapters/DefaultAdapter.cs b/src/SIL.Harmony/Adapters/DefaultAdapter.cs new file mode 100644 index 0000000..86a32ac --- /dev/null +++ b/src/SIL.Harmony/Adapters/DefaultAdapter.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SIL.Harmony.Entities; + +namespace SIL.Harmony.Adapters; + +public class DefaultAdapter : IObjectAdapter +{ + private readonly List _objectTypes = new(); + + IEnumerable IObjectAdapter.GetRegistrations() + { + return _objectTypes.AsReadOnly(); + } + + public DefaultAdapter Add(Action>? configureEntry = null) where T : class, IObjectBase + { + _objectTypes.Add(new(typeof(T), T.TypeName, builder => + { + var entity = builder.Entity(); + configureEntry?.Invoke(entity); + return entity; + }, this)); + return this; + } + + IObjectBase IObjectAdapter.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)}"); + } +} \ No newline at end of file diff --git a/src/SIL.Harmony/Adapters/IObjectAdapter.cs b/src/SIL.Harmony/Adapters/IObjectAdapter.cs new file mode 100644 index 0000000..12f2c82 --- /dev/null +++ b/src/SIL.Harmony/Adapters/IObjectAdapter.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SIL.Harmony.Entities; + +namespace SIL.Harmony.Adapters; + +public record AdapterRegistration( + Type ObjectType, + string ObjectName, + Func EntityBuilder, + IObjectAdapter Adapter); +public interface IObjectAdapter +{ + IEnumerable GetRegistrations(); + IObjectBase Adapt(object obj); +} \ No newline at end of file diff --git a/src/SIL.Harmony/CrdtConfig.cs b/src/SIL.Harmony/CrdtConfig.cs index 849bdcc..ed851ba 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; @@ -103,7 +104,23 @@ public class ObjectTypeListBuilder /// public void Freeze() { + if (_frozen) return; _frozen = true; + foreach (var registration in Adapter.GetRegistrations()) + { + if (Types.Any(t => t.DerivedType == registration.ObjectType)) + throw new InvalidOperationException($"Type {registration.ObjectType} already added"); + Types.Add(new JsonDerivedType(registration.ObjectType, registration.ObjectName)); + ModelConfigurations.Add((builder, config) => + { + if (!config.EnableProjectedTables) return; + var entity = registration.EntityBuilder(builder); + entity.HasOne(typeof(ObjectSnapshot)) + .WithOne() + .HasForeignKey(registration.ObjectType, ObjectSnapshot.ShadowRefName) + .OnDelete(DeleteBehavior.SetNull); + }); + } } private void CheckFrozen() @@ -121,34 +138,20 @@ public ObjectTypeListBuilder AddDbModelConfig(Action modelConfigur ModelConfigurations.Add((builder, _) => modelConfiguration(builder)); return this; } + internal IObjectAdapter Adapter => _adapter ?? throw new InvalidOperationException("No adapter has been added to the builder"); + private IObjectAdapter? _adapter; - - public ObjectTypeListBuilder Add(Action>? configureDb = null) - where TDerived : class, IObjectBase + public DefaultAdapter DefaultAdapter() { - 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; + var adapter = new DefaultAdapter(); + _adapter = adapter; + return adapter; + } + + public CustomAdapter CustomAdapter() + { + var adapter = new CustomAdapter(); + _adapter = 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); From 936a86d896a47c3b14fd66ccb65ca4fff2d5aabc Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 7 Oct 2024 17:19:40 +0700 Subject: [PATCH 02/14] add overload to Change so NewEntity returns a T --- src/SIL.Harmony.Sample/Changes/NewDefinitionChange.cs | 2 +- src/SIL.Harmony.Sample/Changes/NewExampleChange.cs | 2 +- src/SIL.Harmony.Sample/Changes/NewWordChange.cs | 2 +- src/SIL.Harmony.Sample/Changes/SetWordTextChange.cs | 2 +- src/SIL.Harmony/Changes/Change.cs | 9 +++++++-- src/SIL.Harmony/Changes/ChangeContext.cs | 6 +++++- src/SIL.Harmony/Changes/DeleteChange.cs | 2 +- src/SIL.Harmony/Changes/EditChange.cs | 2 +- src/SIL.Harmony/Changes/SetOrderChange.cs | 2 +- 9 files changed, 19 insertions(+), 10 deletions(-) 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/Changes/Change.cs b/src/SIL.Harmony/Changes/Change.cs index 837883e..5726cb5 100644 --- a/src/SIL.Harmony/Changes/Change.cs +++ b/src/SIL.Harmony/Changes/Change.cs @@ -36,7 +36,12 @@ 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 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) @@ -47,4 +52,4 @@ public async ValueTask ApplyChange(IObjectBase entity, ChangeContext context) [JsonIgnore] public Type EntityType => typeof(T); -} +} \ No newline at end of file diff --git a/src/SIL.Harmony/Changes/ChangeContext.cs b/src/SIL.Harmony/Changes/ChangeContext.cs index d589374..42c97c4 100644 --- a/src/SIL.Harmony/Changes/ChangeContext.cs +++ b/src/SIL.Harmony/Changes/ChangeContext.cs @@ -1,14 +1,17 @@ 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; } @@ -16,4 +19,5 @@ internal ChangeContext(Commit commit, SnapshotWorker worker) public async ValueTask GetSnapshot(Guid entityId) => await _worker.GetSnapshot(entityId); public async ValueTask IsObjectDeleted(Guid entityId) => (await GetSnapshot(entityId))?.EntityIsDeleted ?? true; + public IObjectBase Adapt(object obj) => _crdtConfig.ObjectTypeListBuilder.Adapter.Adapt(obj); } diff --git a/src/SIL.Harmony/Changes/DeleteChange.cs b/src/SIL.Harmony/Changes/DeleteChange.cs index c945a60..0127355 100644 --- a/src/SIL.Harmony/Changes/DeleteChange.cs +++ b/src/SIL.Harmony/Changes/DeleteChange.cs @@ -3,7 +3,7 @@ namespace SIL.Harmony.Changes; public class DeleteChange(Guid entityId) : EditChange(entityId), IPolyType - where T : IPolyType, IObjectBase + where T : class, IPolyType, IObjectBase { public static string TypeName => "delete:" + T.TypeName; diff --git a/src/SIL.Harmony/Changes/EditChange.cs b/src/SIL.Harmony/Changes/EditChange.cs index 73c6458..73b5165 100644 --- a/src/SIL.Harmony/Changes/EditChange.cs +++ b/src/SIL.Harmony/Changes/EditChange.cs @@ -5,7 +5,7 @@ namespace SIL.Harmony.Changes; public abstract class EditChange(Guid entityId) : Change(entityId) where T : IObjectBase { - 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..c53602e 100644 --- a/src/SIL.Harmony/Changes/SetOrderChange.cs +++ b/src/SIL.Harmony/Changes/SetOrderChange.cs @@ -8,7 +8,7 @@ public interface IOrderableCrdt } 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) { From 7a6521e8b5763b03dbc9e76cc7f6c6002f053434 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 7 Oct 2024 17:20:41 +0700 Subject: [PATCH 03/14] first pass at a custom adapter, does not work with serialization yet --- .../Adapter/CustomObjectAdapterTests.cs | 89 +++++++++++++++++ src/SIL.Harmony.Tests/ModelSnapshotTests.cs | 2 +- src/SIL.Harmony/Adapters/CustomAdapter.cs | 98 +++++++++++++++++++ src/SIL.Harmony/Changes/Change.cs | 7 +- src/SIL.Harmony/Changes/CreateChange.cs | 3 +- src/SIL.Harmony/Changes/EditChange.cs | 2 +- src/SIL.Harmony/DataModel.cs | 4 +- src/SIL.Harmony/Db/CrdtRepository.cs | 6 +- src/SIL.Harmony/Entities/IObjectBase.cs | 8 +- src/SIL.Harmony/SnapshotWorker.cs | 29 +++--- 10 files changed, 222 insertions(+), 26 deletions(-) create mode 100644 src/SIL.Harmony.Tests/Adapter/CustomObjectAdapterTests.cs create mode 100644 src/SIL.Harmony/Adapters/CustomAdapter.cs diff --git a/src/SIL.Harmony.Tests/Adapter/CustomObjectAdapterTests.cs b/src/SIL.Harmony.Tests/Adapter/CustomObjectAdapterTests.cs new file mode 100644 index 0000000..cc820fe --- /dev/null +++ b/src/SIL.Harmony.Tests/Adapter/CustomObjectAdapterTests.cs @@ -0,0 +1,89 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +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); + } + } + + public class MyClass + { + public Guid Identifier { get; set; } + public long? DeletedTime { get; set; } + public string? MyString { get; set; } + } + + 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); + } + } + + [Fact] + public async Task CanAdaptACustomObject() + { + var services = new ServiceCollection() + .AddDbContext(builder => builder.UseSqlite("Data Source=:memory:")) + .AddCrdtData(config => + { + config.ChangeTypeListBuilder.Add(); + config.ObjectTypeListBuilder + .CustomAdapter() + .Add( + "MyClassTypeName", + o => o.Identifier, + o => o.DeletedTime.HasValue ? DateTimeOffset.FromUnixTimeSeconds(o.DeletedTime.Value) : null, + (o, deletedAt) => o.DeletedTime = deletedAt?.ToUnixTimeSeconds(), + o => [], + (o, id, commit) => { }, + o => new MyClass + { + Identifier = o.Identifier, + DeletedTime = o.DeletedTime, + MyString = o.MyString + }, + 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(); + await dataModel.AddChange(Guid.NewGuid(), new CreateMyClassChange(new MyClass + { + Identifier = objectId, + MyString = "Hello" + })); + 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(); + } +} \ No newline at end of file diff --git a/src/SIL.Harmony.Tests/ModelSnapshotTests.cs b/src/SIL.Harmony.Tests/ModelSnapshotTests.cs index e3fa08e..74c5b93 100644 --- a/src/SIL.Harmony.Tests/ModelSnapshotTests.cs +++ b/src/SIL.Harmony.Tests/ModelSnapshotTests.cs @@ -37,7 +37,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(); diff --git a/src/SIL.Harmony/Adapters/CustomAdapter.cs b/src/SIL.Harmony/Adapters/CustomAdapter.cs new file mode 100644 index 0000000..d00a4e8 --- /dev/null +++ b/src/SIL.Harmony/Adapters/CustomAdapter.cs @@ -0,0 +1,98 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SIL.Harmony.Entities; + +namespace SIL.Harmony.Adapters; + +public class CustomAdapter : IObjectAdapter +{ + public record CustomAdapterRegistration( + Type Type, + string TypeName, + Func EntityBuilder, + IObjectAdapter Adapter, + Func GetId, + Func GetDeletedAt, + Action SetDeletedAt, + Func GetReferences, + Action RemoveReference, + Func Copy) : AdapterRegistration(Type, TypeName, EntityBuilder, Adapter); + + private readonly Dictionary _objectTypes = new(); + + public CustomAdapter Add(string typeName, + Func getId, + Func getDeletedAt, + Action setDeletedAt, + Func getReferences, + Action removeReference, + Func copy, + Action>? configureEntry = null + ) where T : class + { + _objectTypes.Add(typeof(T), + new CustomAdapterRegistration(typeof(T), + typeName, + builder => + { + var entity = builder.Entity(); + configureEntry?.Invoke(entity); + return entity; + }, + this, + o => getId.Invoke((T)o), + o => getDeletedAt.Invoke((T)o), + (o, deletedAt) => setDeletedAt.Invoke((T)o, deletedAt), + o => getReferences.Invoke((T)o), + (o, id, commit) => removeReference.Invoke((T)o, id, commit), + o => copy.Invoke((T)o) + )); + return this; + } + + public IEnumerable GetRegistrations() + { + return _objectTypes.Values; + } + + private class CustomIObjectAdapter(CustomAdapterRegistration registration, object obj) : IObjectBase + { + public Type ObjectType => registration.ObjectType; + public object DbObject => obj; + public T Is() + { + return (T)obj; + } + + public Guid Id => registration.GetId(obj); + + public DateTimeOffset? DeletedAt + { + get => registration.GetDeletedAt(obj); + set => registration.SetDeletedAt(obj, value); + } + + public Guid[] GetReferences() + { + return registration.GetReferences(obj); + } + + public void RemoveReference(Guid id, Commit commit) + { + registration.RemoveReference(obj, id, commit); + } + + public IObjectBase Copy() + { + return registration.Adapter.Adapt(registration.Copy(obj)); + } + + public string TypeName => registration.TypeName; + } + + public IObjectBase Adapt(object obj) + { + var registration = _objectTypes[obj.GetType()]; + return new CustomIObjectAdapter(registration, obj); + } +} \ No newline at end of file diff --git a/src/SIL.Harmony/Changes/Change.cs b/src/SIL.Harmony/Changes/Change.cs index 5726cb5..5fc72d1 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) { @@ -38,7 +38,7 @@ protected Change(Guid entityId) async ValueTask IChange.NewEntity(Commit commit, ChangeContext context) { - return await NewEntity(commit, context); + return context.Adapt(await NewEntity(commit, context)); } public abstract ValueTask NewEntity(Commit commit, ChangeContext context); @@ -46,7 +46,8 @@ async ValueTask IChange.NewEntity(Commit commit, ChangeContext cont 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 (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); } 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/EditChange.cs b/src/SIL.Harmony/Changes/EditChange.cs index 73b5165..6996c27 100644 --- a/src/SIL.Harmony/Changes/EditChange.cs +++ b/src/SIL.Harmony/Changes/EditChange.cs @@ -3,7 +3,7 @@ namespace SIL.Harmony.Changes; public abstract class EditChange(Guid entityId) : Change(entityId) - where T : IObjectBase + where T : class, IObjectBase { public override ValueTask NewEntity(Commit commit, ChangeContext context) { diff --git a/src/SIL.Harmony/DataModel.cs b/src/SIL.Harmony/DataModel.cs index 89be491..38ce90d 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); } @@ -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..0e1f16e 100644 --- a/src/SIL.Harmony/Db/CrdtRepository.cs +++ b/src/SIL.Harmony/Db/CrdtRepository.cs @@ -230,12 +230,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.ObjectType, 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 +247,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/Entities/IObjectBase.cs b/src/SIL.Harmony/Entities/IObjectBase.cs index 0d6366d..5d436b9 100644 --- a/src/SIL.Harmony/Entities/IObjectBase.cs +++ b/src/SIL.Harmony/Entities/IObjectBase.cs @@ -5,10 +5,10 @@ namespace SIL.Harmony.Entities; [JsonPolymorphic] public interface IObjectBase: IPolyType { - Guid Id { get; init; } + Guid Id { get; } DateTimeOffset? DeletedAt { get; set; } - public T Is() where T : IObjectBase + public T Is() { return (T)this; } @@ -23,11 +23,15 @@ public T Is() where T : IObjectBase public IObjectBase Copy(); new string TypeName { get; } + Type ObjectType { get; } + object DbObject { get; } static string IPolyType.TypeName => throw new NotImplementedException(); } public interface IObjectBase : IObjectBase where TThis : IPolyType { string IObjectBase.TypeName => TThis.TypeName; + Type IObjectBase.ObjectType => typeof(TThis); static string IPolyType.TypeName => typeof(TThis).Name; + object IObjectBase.DbObject => this; } 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) { From f8ff321847a4b37fc3cdec62bd31e34d39a71423 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Tue, 8 Oct 2024 11:01:11 +0700 Subject: [PATCH 04/14] make IObjectBase not extend IPolyType --- src/SIL.Harmony/DataModel.cs | 2 +- src/SIL.Harmony/Db/CrdtRepository.cs | 2 +- src/SIL.Harmony/Db/ObjectSnapshot.cs | 2 +- src/SIL.Harmony/Entities/IObjectBase.cs | 8 +++++--- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/SIL.Harmony/DataModel.cs b/src/SIL.Harmony/DataModel.cs index 38ce90d..6df6c48 100644 --- a/src/SIL.Harmony/DataModel.cs +++ b/src/SIL.Harmony/DataModel.cs @@ -209,7 +209,7 @@ 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, IObjectBase, IPolyType { var q = _crdtRepository.GetCurrentObjects(); if (q is IQueryable) diff --git a/src/SIL.Harmony/Db/CrdtRepository.cs b/src/SIL.Harmony/Db/CrdtRepository.cs index 0e1f16e..54f04e4 100644 --- a/src/SIL.Harmony/Db/CrdtRepository.cs +++ b/src/SIL.Harmony/Db/CrdtRepository.cs @@ -176,7 +176,7 @@ public async Task GetObjectBySnapshotId(Guid snapshotId) return snapshot?.Entity.Is(); } - public IQueryable GetCurrentObjects() where T : class, IObjectBase + public IQueryable GetCurrentObjects() where T : class, IObjectBase, IPolyType { if (crdtConfig.Value.EnableProjectedTables) { diff --git a/src/SIL.Harmony/Db/ObjectSnapshot.cs b/src/SIL.Harmony/Db/ObjectSnapshot.cs index e9715ea..6262eaf 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, diff --git a/src/SIL.Harmony/Entities/IObjectBase.cs b/src/SIL.Harmony/Entities/IObjectBase.cs index 5d436b9..f514c48 100644 --- a/src/SIL.Harmony/Entities/IObjectBase.cs +++ b/src/SIL.Harmony/Entities/IObjectBase.cs @@ -3,7 +3,7 @@ namespace SIL.Harmony.Entities; [JsonPolymorphic] -public interface IObjectBase: IPolyType +public interface IObjectBase { Guid Id { get; } DateTimeOffset? DeletedAt { get; set; } @@ -24,14 +24,16 @@ public T Is() public IObjectBase Copy(); new string TypeName { get; } Type ObjectType { get; } + [JsonIgnore] object DbObject { get; } - static string IPolyType.TypeName => throw new NotImplementedException(); + // static string IPolyType.TypeName => throw new NotImplementedException(); } -public interface IObjectBase : IObjectBase where TThis : IPolyType +public interface IObjectBase : IObjectBase, IPolyType where TThis : IPolyType { string IObjectBase.TypeName => TThis.TypeName; Type IObjectBase.ObjectType => typeof(TThis); static string IPolyType.TypeName => typeof(TThis).Name; + [JsonIgnore] object IObjectBase.DbObject => this; } From 6bf5aacb790f6fa70f4d8a6facbe84e1d1ade795 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 9 Oct 2024 10:54:05 +0700 Subject: [PATCH 05/14] rework custom adapter to require a common interface on all objects in the datamodel --- .../Adapter/CustomObjectAdapterTests.cs | 165 +++++++++++++++--- src/SIL.Harmony.Tests/ModelSnapshotTests.cs | 5 +- src/SIL.Harmony/Adapters/CustomAdapter.cs | 98 ----------- .../Adapters/CustomAdapterProvider.cs | 67 +++++++ src/SIL.Harmony/Adapters/DefaultAdapter.cs | 11 +- src/SIL.Harmony/Adapters/IObjectAdapter.cs | 10 +- src/SIL.Harmony/CrdtConfig.cs | 18 +- src/SIL.Harmony/Db/ObjectSnapshot.cs | 2 +- src/SIL.Harmony/Entities/IObjectBase.cs | 8 +- src/SIL.Harmony/Helpers/DerivedTypeHelper.cs | 18 ++ 10 files changed, 254 insertions(+), 148 deletions(-) delete mode 100644 src/SIL.Harmony/Adapters/CustomAdapter.cs create mode 100644 src/SIL.Harmony/Adapters/CustomAdapterProvider.cs diff --git a/src/SIL.Harmony.Tests/Adapter/CustomObjectAdapterTests.cs b/src/SIL.Harmony.Tests/Adapter/CustomObjectAdapterTests.cs index cc820fe..1148322 100644 --- a/src/SIL.Harmony.Tests/Adapter/CustomObjectAdapterTests.cs +++ b/src/SIL.Harmony.Tests/Adapter/CustomObjectAdapterTests.cs @@ -1,6 +1,8 @@ +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; @@ -10,22 +12,113 @@ namespace SIL.Harmony.Tests.Adapter; public class CustomObjectAdapterTests { - public class MyDbContext(DbContextOptions options, IOptions crdtConfig) : DbContext(options), ICrdtDbContext + 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); } } - public class MyClass + [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(); + } + + [JsonIgnore] + public string ObjectTypeName => Obj.TypeName; + + [JsonIgnore] + public Type ObjectType => typeof(MyClass); + + [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; @@ -41,42 +134,53 @@ public override ValueTask NewEntity(Commit commit, ChangeContext contex } } + 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=:memory:")) + .AddDbContext(builder => builder.UseSqlite("Data Source=test.db")) .AddCrdtData(config => { - config.ChangeTypeListBuilder.Add(); + config.ChangeTypeListBuilder.Add().Add(); config.ObjectTypeListBuilder - .CustomAdapter() - .Add( - "MyClassTypeName", - o => o.Identifier, - o => o.DeletedTime.HasValue ? DateTimeOffset.FromUnixTimeSeconds(o.DeletedTime.Value) : null, - (o, deletedAt) => o.DeletedTime = deletedAt?.ToUnixTimeSeconds(), - o => [], - (o, id, commit) => { }, - o => new MyClass - { - Identifier = o.Identifier, - DeletedTime = o.DeletedTime, - MyString = o.MyString - }, - builder => builder.HasKey(o => o.Identifier) - ); + .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(); - await dataModel.AddChange(Guid.NewGuid(), new CreateMyClassChange(new MyClass - { - Identifier = objectId, - MyString = "Hello" - })); + 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(); @@ -85,5 +189,16 @@ public async Task CanAdaptACustomObject() 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/ModelSnapshotTests.cs b/src/SIL.Harmony.Tests/ModelSnapshotTests.cs index 74c5b93..8700460 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 = (Word) await DataModel.GetBySnapshotId(simpleSnapshot.Id); entry.Text.Should().Be("second"); snapshot.LastChange.Should().Be(secondChange.DateTime); } @@ -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/Adapters/CustomAdapter.cs b/src/SIL.Harmony/Adapters/CustomAdapter.cs deleted file mode 100644 index d00a4e8..0000000 --- a/src/SIL.Harmony/Adapters/CustomAdapter.cs +++ /dev/null @@ -1,98 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; -using SIL.Harmony.Entities; - -namespace SIL.Harmony.Adapters; - -public class CustomAdapter : IObjectAdapter -{ - public record CustomAdapterRegistration( - Type Type, - string TypeName, - Func EntityBuilder, - IObjectAdapter Adapter, - Func GetId, - Func GetDeletedAt, - Action SetDeletedAt, - Func GetReferences, - Action RemoveReference, - Func Copy) : AdapterRegistration(Type, TypeName, EntityBuilder, Adapter); - - private readonly Dictionary _objectTypes = new(); - - public CustomAdapter Add(string typeName, - Func getId, - Func getDeletedAt, - Action setDeletedAt, - Func getReferences, - Action removeReference, - Func copy, - Action>? configureEntry = null - ) where T : class - { - _objectTypes.Add(typeof(T), - new CustomAdapterRegistration(typeof(T), - typeName, - builder => - { - var entity = builder.Entity(); - configureEntry?.Invoke(entity); - return entity; - }, - this, - o => getId.Invoke((T)o), - o => getDeletedAt.Invoke((T)o), - (o, deletedAt) => setDeletedAt.Invoke((T)o, deletedAt), - o => getReferences.Invoke((T)o), - (o, id, commit) => removeReference.Invoke((T)o, id, commit), - o => copy.Invoke((T)o) - )); - return this; - } - - public IEnumerable GetRegistrations() - { - return _objectTypes.Values; - } - - private class CustomIObjectAdapter(CustomAdapterRegistration registration, object obj) : IObjectBase - { - public Type ObjectType => registration.ObjectType; - public object DbObject => obj; - public T Is() - { - return (T)obj; - } - - public Guid Id => registration.GetId(obj); - - public DateTimeOffset? DeletedAt - { - get => registration.GetDeletedAt(obj); - set => registration.SetDeletedAt(obj, value); - } - - public Guid[] GetReferences() - { - return registration.GetReferences(obj); - } - - public void RemoveReference(Guid id, Commit commit) - { - registration.RemoveReference(obj, id, commit); - } - - public IObjectBase Copy() - { - return registration.Adapter.Adapt(registration.Copy(obj)); - } - - public string TypeName => registration.TypeName; - } - - public IObjectBase Adapt(object obj) - { - var registration = _objectTypes[obj.GetType()]; - return new CustomIObjectAdapter(registration, obj); - } -} \ No newline at end of file diff --git a/src/SIL.Harmony/Adapters/CustomAdapterProvider.cs b/src/SIL.Harmony/Adapters/CustomAdapterProvider.cs new file mode 100644 index 0000000..e2b918a --- /dev/null +++ b/src/SIL.Harmony/Adapters/CustomAdapterProvider.cs @@ -0,0 +1,67 @@ +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 : IObjectAdapter + where TCommonInterface : class + where TCustomAdapter : class, ICustomAdapter, IPolyType +{ + public CustomAdapterProvider() + { + JsonTypes.AddDerivedType(typeof(IObjectBase), typeof(TCustomAdapter), TCustomAdapter.TypeName); + } + + private readonly List _objectTypes = new(); + public Dictionary> JsonTypes { get; } = []; + + 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 + { + _objectTypes.Add( + new AdapterRegistration(typeof(T), + builder => + { + var entity = builder.Entity(); + configureEntry?.Invoke(entity); + return entity; + }) + ); + return this; + } + + public IEnumerable GetRegistrations() + { + return _objectTypes; + } + + public IObjectBase Adapt(object obj) + { + return TCustomAdapter.Create((TCommonInterface)obj); + } +} + +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; } + + T IObjectBase.Is() + { + return (T)DbObject; + } +} \ No newline at end of file diff --git a/src/SIL.Harmony/Adapters/DefaultAdapter.cs b/src/SIL.Harmony/Adapters/DefaultAdapter.cs index 86a32ac..ee3d85c 100644 --- a/src/SIL.Harmony/Adapters/DefaultAdapter.cs +++ b/src/SIL.Harmony/Adapters/DefaultAdapter.cs @@ -1,5 +1,7 @@ +using System.Text.Json.Serialization.Metadata; using Microsoft.EntityFrameworkCore.Metadata.Builders; using SIL.Harmony.Entities; +using SIL.Harmony.Helpers; namespace SIL.Harmony.Adapters; @@ -12,14 +14,15 @@ IEnumerable IObjectAdapter.GetRegistrations() return _objectTypes.AsReadOnly(); } - public DefaultAdapter Add(Action>? configureEntry = null) where T : class, IObjectBase + public DefaultAdapter Add(Action>? configureEntry = null) where T : class, IObjectBase { - _objectTypes.Add(new(typeof(T), T.TypeName, builder => + JsonTypes.AddDerivedType(typeof(IObjectBase), typeof(T), T.TypeName); + _objectTypes.Add(new(typeof(T), builder => { var entity = builder.Entity(); configureEntry?.Invoke(entity); return entity; - }, this)); + })); return this; } @@ -33,4 +36,6 @@ IObjectBase IObjectAdapter.Adapt(object obj) throw new ArgumentException( $"Object is of type {obj.GetType().Name} which does not implement {nameof(IObjectBase)}"); } + + public Dictionary> JsonTypes { get; } = []; } \ No newline at end of file diff --git a/src/SIL.Harmony/Adapters/IObjectAdapter.cs b/src/SIL.Harmony/Adapters/IObjectAdapter.cs index 12f2c82..61a0430 100644 --- a/src/SIL.Harmony/Adapters/IObjectAdapter.cs +++ b/src/SIL.Harmony/Adapters/IObjectAdapter.cs @@ -1,16 +1,16 @@ +using System.Text.Json.Serialization.Metadata; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using SIL.Harmony.Entities; namespace SIL.Harmony.Adapters; -public record AdapterRegistration( - Type ObjectType, - string ObjectName, - Func EntityBuilder, - IObjectAdapter Adapter); +public record AdapterRegistration(Type ObjectDbType, Func EntityBuilder); + public interface IObjectAdapter { IEnumerable GetRegistrations(); IObjectBase Adapt(object obj); + + Dictionary> JsonTypes { get; } } \ No newline at end of file diff --git a/src/SIL.Harmony/CrdtConfig.cs b/src/SIL.Harmony/CrdtConfig.cs index ed851ba..996d564 100644 --- a/src/SIL.Harmony/CrdtConfig.cs +++ b/src/SIL.Harmony/CrdtConfig.cs @@ -58,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); } @@ -106,18 +107,16 @@ public void Freeze() { if (_frozen) return; _frozen = true; + JsonTypes = Adapter.JsonTypes; foreach (var registration in Adapter.GetRegistrations()) { - if (Types.Any(t => t.DerivedType == registration.ObjectType)) - throw new InvalidOperationException($"Type {registration.ObjectType} already added"); - Types.Add(new JsonDerivedType(registration.ObjectType, registration.ObjectName)); ModelConfigurations.Add((builder, config) => { if (!config.EnableProjectedTables) return; var entity = registration.EntityBuilder(builder); entity.HasOne(typeof(ObjectSnapshot)) .WithOne() - .HasForeignKey(registration.ObjectType, ObjectSnapshot.ShadowRefName) + .HasForeignKey(registration.ObjectDbType, ObjectSnapshot.ShadowRefName) .OnDelete(DeleteBehavior.SetNull); }); } @@ -128,7 +127,7 @@ private void CheckFrozen() if (_frozen) throw new InvalidOperationException($"{nameof(ObjectTypeListBuilder)} is frozen"); } - internal List Types { get; } = []; + internal Dictionary>? JsonTypes { get; set; } internal List> ModelConfigurations { get; } = []; @@ -148,9 +147,10 @@ public DefaultAdapter DefaultAdapter() return adapter; } - public CustomAdapter CustomAdapter() + public CustomAdapterProvider CustomAdapter() + where TCommonInterface : class where TAdapter : class, ICustomAdapter, IPolyType { - var adapter = new CustomAdapter(); + var adapter = new CustomAdapterProvider(); _adapter = adapter; return adapter; } diff --git a/src/SIL.Harmony/Db/ObjectSnapshot.cs b/src/SIL.Harmony/Db/ObjectSnapshot.cs index 6262eaf..c689d2d 100644 --- a/src/SIL.Harmony/Db/ObjectSnapshot.cs +++ b/src/SIL.Harmony/Db/ObjectSnapshot.cs @@ -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.ObjectTypeName; CommitId = commit.Id; Commit = commit; IsRoot = isRoot; diff --git a/src/SIL.Harmony/Entities/IObjectBase.cs b/src/SIL.Harmony/Entities/IObjectBase.cs index f514c48..cbcbb22 100644 --- a/src/SIL.Harmony/Entities/IObjectBase.cs +++ b/src/SIL.Harmony/Entities/IObjectBase.cs @@ -22,16 +22,16 @@ public T Is() public void RemoveReference(Guid id, Commit commit); public IObjectBase Copy(); - new string TypeName { get; } - Type ObjectType { get; } + public string ObjectTypeName { get; } + public Type ObjectType { get; } [JsonIgnore] - object DbObject { get; } + public object DbObject { get; } // static string IPolyType.TypeName => throw new NotImplementedException(); } public interface IObjectBase : IObjectBase, IPolyType where TThis : IPolyType { - string IObjectBase.TypeName => TThis.TypeName; + string IObjectBase.ObjectTypeName => TThis.TypeName; Type IObjectBase.ObjectType => typeof(TThis); static string IPolyType.TypeName => typeof(TThis).Name; [JsonIgnore] 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)); + } } From 260bd5cda59083f243266c45b0bd6ce5f435828a Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 9 Oct 2024 11:41:57 +0700 Subject: [PATCH 06/14] remove requirements on DataModel methods that we are always working with an IObjectBase --- src/SIL.Harmony.Tests/SyncTests.cs | 14 +++++--------- src/SIL.Harmony/Changes/SetOrderChange.cs | 1 + src/SIL.Harmony/DataModel.cs | 8 ++++---- src/SIL.Harmony/Db/CrdtRepository.cs | 10 ++++------ 4 files changed, 14 insertions(+), 19 deletions(-) diff --git a/src/SIL.Harmony.Tests/SyncTests.cs b/src/SIL.Harmony.Tests/SyncTests.cs index df043c2..221f04f 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 = (Word) 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 = (Word) 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 = (Word) 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 = (Word) await client.DataModel.GetBySnapshotId(simpleSnapshot.Id); + entity.Should().BeEquivalentTo(serverEntity); } } } diff --git a/src/SIL.Harmony/Changes/SetOrderChange.cs b/src/SIL.Harmony/Changes/SetOrderChange.cs index c53602e..18fb9c1 100644 --- a/src/SIL.Harmony/Changes/SetOrderChange.cs +++ b/src/SIL.Harmony/Changes/SetOrderChange.cs @@ -5,6 +5,7 @@ namespace SIL.Harmony.Changes; public interface IOrderableCrdt { public double Order { get; set; } + public Guid Id { get; } } public class SetOrderChange : EditChange, IPolyType diff --git a/src/SIL.Harmony/DataModel.cs b/src/SIL.Harmony/DataModel.cs index 6df6c48..176bb7a 100644 --- a/src/SIL.Harmony/DataModel.cs +++ b/src/SIL.Harmony/DataModel.cs @@ -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,17 +209,17 @@ public async Task GetProjectSnapshot(bool includeDeleted = false) return new ModelSnapshot(await _crdtRepository.CurrenSimpleSnapshots(includeDeleted).ToArrayAsync()); } - public IQueryable GetLatestObjects() where T : class, IObjectBase, IPolyType + 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); } diff --git a/src/SIL.Harmony/Db/CrdtRepository.cs b/src/SIL.Harmony/Db/CrdtRepository.cs index 54f04e4..88dcb4e 100644 --- a/src/SIL.Harmony/Db/CrdtRepository.cs +++ b/src/SIL.Harmony/Db/CrdtRepository.cs @@ -158,7 +158,7 @@ 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) @@ -168,7 +168,7 @@ public async Task GetObjectBySnapshotId(Guid snapshotId) return 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() @@ -176,15 +176,13 @@ public async Task GetObjectBySnapshotId(Guid snapshotId) return snapshot?.Entity.Is(); } - public IQueryable GetCurrentObjects() where T : class, IObjectBase, IPolyType + 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() From 8077a68426e0ad3ab01396f276f3dc033431a786 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 9 Oct 2024 12:10:37 +0700 Subject: [PATCH 07/14] rename adapter to adapter provider, and write some help docs --- .../Adapters/CustomAdapterProvider.cs | 22 +++++---- ...ltAdapter.cs => DefaultAdapterProvider.cs} | 14 +++--- ...ctAdapter.cs => IObjectAdapterProvider.cs} | 4 +- src/SIL.Harmony/Changes/ChangeContext.cs | 2 +- src/SIL.Harmony/CrdtConfig.cs | 47 ++++++++++++------- 5 files changed, 55 insertions(+), 34 deletions(-) rename src/SIL.Harmony/Adapters/{DefaultAdapter.cs => DefaultAdapterProvider.cs} (55%) rename src/SIL.Harmony/Adapters/{IObjectAdapter.cs => IObjectAdapterProvider.cs} (70%) diff --git a/src/SIL.Harmony/Adapters/CustomAdapterProvider.cs b/src/SIL.Harmony/Adapters/CustomAdapterProvider.cs index e2b918a..7de660f 100644 --- a/src/SIL.Harmony/Adapters/CustomAdapterProvider.cs +++ b/src/SIL.Harmony/Adapters/CustomAdapterProvider.cs @@ -6,18 +6,21 @@ namespace SIL.Harmony.Adapters; -public class CustomAdapterProvider : IObjectAdapter +public class CustomAdapterProvider : IObjectAdapterProvider where TCommonInterface : class where TCustomAdapter : class, ICustomAdapter, IPolyType { - public CustomAdapterProvider() + 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); } - - private readonly List _objectTypes = new(); - public Dictionary> JsonTypes { get; } = []; - + public CustomAdapterProvider AddWithCustomPolymorphicMapping(string typeName, Action>? configureEntry = null ) where T : class, TCommonInterface @@ -25,10 +28,12 @@ public CustomAdapterProvider AddWithCustomPoly 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 => @@ -41,17 +46,18 @@ public CustomAdapterProvider Add( return this; } - public IEnumerable GetRegistrations() + IEnumerable IObjectAdapterProvider.GetRegistrations() { return _objectTypes; } - public IObjectBase Adapt(object obj) + 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 public interface ICustomAdapter : IObjectBase, IPolyType where TSelf : class, ICustomAdapter diff --git a/src/SIL.Harmony/Adapters/DefaultAdapter.cs b/src/SIL.Harmony/Adapters/DefaultAdapterProvider.cs similarity index 55% rename from src/SIL.Harmony/Adapters/DefaultAdapter.cs rename to src/SIL.Harmony/Adapters/DefaultAdapterProvider.cs index ee3d85c..53bea0f 100644 --- a/src/SIL.Harmony/Adapters/DefaultAdapter.cs +++ b/src/SIL.Harmony/Adapters/DefaultAdapterProvider.cs @@ -5,17 +5,18 @@ namespace SIL.Harmony.Adapters; -public class DefaultAdapter : IObjectAdapter +public class DefaultAdapterProvider(ObjectTypeListBuilder objectTypeListBuilder) : IObjectAdapterProvider { - private readonly List _objectTypes = new(); + private readonly List _objectTypes = []; - IEnumerable IObjectAdapter.GetRegistrations() + IEnumerable IObjectAdapterProvider.GetRegistrations() { return _objectTypes.AsReadOnly(); } - public DefaultAdapter Add(Action>? configureEntry = null) where T : class, IObjectBase + 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 => { @@ -26,7 +27,7 @@ public DefaultAdapter Add(Action>? configureEntry = null return this; } - IObjectBase IObjectAdapter.Adapt(object obj) + IObjectBase IObjectAdapterProvider.Adapt(object obj) { if (obj is IObjectBase objectBase) { @@ -37,5 +38,6 @@ IObjectBase IObjectAdapter.Adapt(object obj) $"Object is of type {obj.GetType().Name} which does not implement {nameof(IObjectBase)}"); } - public Dictionary> JsonTypes { get; } = []; + private Dictionary> JsonTypes { get; } = []; + Dictionary> IObjectAdapterProvider.JsonTypes => JsonTypes; } \ No newline at end of file diff --git a/src/SIL.Harmony/Adapters/IObjectAdapter.cs b/src/SIL.Harmony/Adapters/IObjectAdapterProvider.cs similarity index 70% rename from src/SIL.Harmony/Adapters/IObjectAdapter.cs rename to src/SIL.Harmony/Adapters/IObjectAdapterProvider.cs index 61a0430..6504c0a 100644 --- a/src/SIL.Harmony/Adapters/IObjectAdapter.cs +++ b/src/SIL.Harmony/Adapters/IObjectAdapterProvider.cs @@ -5,9 +5,9 @@ namespace SIL.Harmony.Adapters; -public record AdapterRegistration(Type ObjectDbType, Func EntityBuilder); +internal record AdapterRegistration(Type ObjectDbType, Func EntityBuilder); -public interface IObjectAdapter +internal interface IObjectAdapterProvider { IEnumerable GetRegistrations(); IObjectBase Adapt(object obj); diff --git a/src/SIL.Harmony/Changes/ChangeContext.cs b/src/SIL.Harmony/Changes/ChangeContext.cs index 42c97c4..72104c6 100644 --- a/src/SIL.Harmony/Changes/ChangeContext.cs +++ b/src/SIL.Harmony/Changes/ChangeContext.cs @@ -19,5 +19,5 @@ internal ChangeContext(Commit commit, SnapshotWorker worker, CrdtConfig crdtConf public async ValueTask GetSnapshot(Guid entityId) => await _worker.GetSnapshot(entityId); public async ValueTask IsObjectDeleted(Guid entityId) => (await GetSnapshot(entityId))?.EntityIsDeleted ?? true; - public IObjectBase Adapt(object obj) => _crdtConfig.ObjectTypeListBuilder.Adapter.Adapt(obj); + public IObjectBase Adapt(object obj) => _crdtConfig.ObjectTypeListBuilder.AdapterProvider.Adapt(obj); } diff --git a/src/SIL.Harmony/CrdtConfig.cs b/src/SIL.Harmony/CrdtConfig.cs index 996d564..1b9837a 100644 --- a/src/SIL.Harmony/CrdtConfig.cs +++ b/src/SIL.Harmony/CrdtConfig.cs @@ -107,8 +107,8 @@ public void Freeze() { if (_frozen) return; _frozen = true; - JsonTypes = Adapter.JsonTypes; - foreach (var registration in Adapter.GetRegistrations()) + JsonTypes = AdapterProvider.JsonTypes; + foreach (var registration in AdapterProvider.GetRegistrations()) { ModelConfigurations.Add((builder, config) => { @@ -122,7 +122,7 @@ public void Freeze() } } - private void CheckFrozen() + internal void CheckFrozen() { if (_frozen) throw new InvalidOperationException($"{nameof(ObjectTypeListBuilder)} is frozen"); } @@ -131,27 +131,40 @@ private void CheckFrozen() internal List> ModelConfigurations { get; } = []; - public ObjectTypeListBuilder AddDbModelConfig(Action modelConfiguration) - { - CheckFrozen(); - ModelConfigurations.Add((builder, _) => modelConfiguration(builder)); - return this; - } - internal IObjectAdapter Adapter => _adapter ?? throw new InvalidOperationException("No adapter has been added to the builder"); - private IObjectAdapter? _adapter; + internal IObjectAdapterProvider AdapterProvider => _adapterProvider ?? throw new InvalidOperationException("No adapter has been added to the builder"); + private IObjectAdapterProvider? _adapterProvider; - public DefaultAdapter DefaultAdapter() + public DefaultAdapterProvider DefaultAdapter() { - var adapter = new DefaultAdapter(); - _adapter = adapter; + CheckFrozen(); + if (_adapterProvider is not null) throw new InvalidOperationException("adapter has already been added"); + var adapter = new DefaultAdapterProvider(this); + _adapterProvider = adapter; return adapter; } - + + /// + /// 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 { - var adapter = new CustomAdapterProvider(); - _adapter = adapter; + CheckFrozen(); + if (_adapterProvider is not null) throw new InvalidOperationException("adapter has already been added"); + var adapter = new CustomAdapterProvider(this); + _adapterProvider = adapter; return adapter; } } From 77179a5f61adb3b47e0715062ee2bd046fdc24f7 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 9 Oct 2024 12:26:59 +0700 Subject: [PATCH 08/14] simplify IObjectBase interface --- .../Adapter/CustomObjectAdapterTests.cs | 6 +--- .../ObjectBaseTestingHelpers.cs | 11 ++++++ .../Adapters/CustomAdapterProvider.cs | 8 ++--- src/SIL.Harmony/Changes/ChangeContext.cs | 2 +- src/SIL.Harmony/Db/CrdtRepository.cs | 4 +-- src/SIL.Harmony/Db/ObjectSnapshot.cs | 2 +- src/SIL.Harmony/Entities/IObjectBase.cs | 34 +++++++++++-------- 7 files changed, 37 insertions(+), 30 deletions(-) create mode 100644 src/SIL.Harmony.Tests/ObjectBaseTestingHelpers.cs diff --git a/src/SIL.Harmony.Tests/Adapter/CustomObjectAdapterTests.cs b/src/SIL.Harmony.Tests/Adapter/CustomObjectAdapterTests.cs index 1148322..c34d994 100644 --- a/src/SIL.Harmony.Tests/Adapter/CustomObjectAdapterTests.cs +++ b/src/SIL.Harmony.Tests/Adapter/CustomObjectAdapterTests.cs @@ -101,11 +101,7 @@ public DateTimeOffset? DeletedAt set => Obj.DeletedTime = value?.ToUnixTimeSeconds(); } - [JsonIgnore] - public string ObjectTypeName => Obj.TypeName; - - [JsonIgnore] - public Type ObjectType => typeof(MyClass); + public string GetObjectTypeName() => Obj.TypeName; [JsonIgnore] public object DbObject => Obj; 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/Adapters/CustomAdapterProvider.cs b/src/SIL.Harmony/Adapters/CustomAdapterProvider.cs index 7de660f..53231cc 100644 --- a/src/SIL.Harmony/Adapters/CustomAdapterProvider.cs +++ b/src/SIL.Harmony/Adapters/CustomAdapterProvider.cs @@ -57,7 +57,8 @@ IObjectBase IObjectAdapterProvider.Adapt(object 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 +// 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 @@ -65,9 +66,4 @@ public interface ICustomAdapter : IObjectBase, IPolyTyp public static abstract TSelf Create(TCommonInterface obj); static string IPolyType.TypeName => TSelf.AdapterTypeName; public static abstract string AdapterTypeName { get; } - - T IObjectBase.Is() - { - return (T)DbObject; - } } \ No newline at end of file diff --git a/src/SIL.Harmony/Changes/ChangeContext.cs b/src/SIL.Harmony/Changes/ChangeContext.cs index 72104c6..3929999 100644 --- a/src/SIL.Harmony/Changes/ChangeContext.cs +++ b/src/SIL.Harmony/Changes/ChangeContext.cs @@ -19,5 +19,5 @@ internal ChangeContext(Commit commit, SnapshotWorker worker, CrdtConfig crdtConf public async ValueTask GetSnapshot(Guid entityId) => await _worker.GetSnapshot(entityId); public async ValueTask IsObjectDeleted(Guid entityId) => (await GetSnapshot(entityId))?.EntityIsDeleted ?? true; - public IObjectBase Adapt(object obj) => _crdtConfig.ObjectTypeListBuilder.AdapterProvider.Adapt(obj); + internal IObjectBase Adapt(object obj) => _crdtConfig.ObjectTypeListBuilder.AdapterProvider.Adapt(obj); } diff --git a/src/SIL.Harmony/Db/CrdtRepository.cs b/src/SIL.Harmony/Db/CrdtRepository.cs index 88dcb4e..2a4ea39 100644 --- a/src/SIL.Harmony/Db/CrdtRepository.cs +++ b/src/SIL.Harmony/Db/CrdtRepository.cs @@ -173,7 +173,7 @@ public async Task GetObjectBySnapshotId(Guid snapshotId) 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 @@ -228,7 +228,7 @@ 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.ObjectType, 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 diff --git a/src/SIL.Harmony/Db/ObjectSnapshot.cs b/src/SIL.Harmony/Db/ObjectSnapshot.cs index c689d2d..53f2e10 100644 --- a/src/SIL.Harmony/Db/ObjectSnapshot.cs +++ b/src/SIL.Harmony/Db/ObjectSnapshot.cs @@ -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.ObjectTypeName; + 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 cbcbb22..a941712 100644 --- a/src/SIL.Harmony/Entities/IObjectBase.cs +++ b/src/SIL.Harmony/Entities/IObjectBase.cs @@ -8,31 +8,35 @@ public interface IObjectBase Guid Id { get; } DateTimeOffset? DeletedAt { get; set; } - public T Is() - { - 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(); - public string ObjectTypeName { get; } - public Type ObjectType { get; } + /// + /// 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; } - // static string IPolyType.TypeName => throw new NotImplementedException(); } public interface IObjectBase : IObjectBase, IPolyType where TThis : IPolyType { - string IObjectBase.ObjectTypeName => TThis.TypeName; - Type IObjectBase.ObjectType => typeof(TThis); + string IObjectBase.GetObjectTypeName() => TThis.TypeName; static string IPolyType.TypeName => typeof(TThis).Name; [JsonIgnore] object IObjectBase.DbObject => this; From f7bb0ee67ba29c50077be6643b819deab9c526e3 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 9 Oct 2024 12:34:28 +0700 Subject: [PATCH 09/14] fix crash in perf tests due to passing a null change context --- src/SIL.Harmony.Tests/DataModelPerformanceTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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) { From 3e8978a7ac0ae43a7e433b379db9bff2c5e7a673 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 9 Oct 2024 13:51:54 +0700 Subject: [PATCH 10/14] remove requirement of IObject base on EditChange --- src/SIL.Harmony/Changes/EditChange.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/SIL.Harmony/Changes/EditChange.cs b/src/SIL.Harmony/Changes/EditChange.cs index 6996c27..5d3d70c 100644 --- a/src/SIL.Harmony/Changes/EditChange.cs +++ b/src/SIL.Harmony/Changes/EditChange.cs @@ -1,9 +1,7 @@ -using SIL.Harmony.Entities; - -namespace SIL.Harmony.Changes; +namespace SIL.Harmony.Changes; public abstract class EditChange(Guid entityId) : Change(entityId) - where T : class, IObjectBase + where T : class { public override ValueTask NewEntity(Commit commit, ChangeContext context) { From cc0a769af81a6f59a902a1e483668ba33c6926eb Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 9 Oct 2024 13:54:36 +0700 Subject: [PATCH 11/14] remove IObjectBase constraint on DeleteChange --- src/SIL.Harmony/Changes/DeleteChange.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/SIL.Harmony/Changes/DeleteChange.cs b/src/SIL.Harmony/Changes/DeleteChange.cs index 0127355..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 : class, 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; } } From a63c3ab5c50284a01d8166a9eddb49c958761e20 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Wed, 9 Oct 2024 14:13:29 +0700 Subject: [PATCH 12/14] fix issue where edit changes would fail to apply due to an incompatible cast on the wrong object --- src/SIL.Harmony/Changes/Change.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/SIL.Harmony/Changes/Change.cs b/src/SIL.Harmony/Changes/Change.cs index 5fc72d1..6114b36 100644 --- a/src/SIL.Harmony/Changes/Change.cs +++ b/src/SIL.Harmony/Changes/Change.cs @@ -48,9 +48,16 @@ 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 (entity.DbObject is T entityT) + { + await ApplyChange(entityT, context); + } + else + { + throw new NotSupportedException($"Type {entity.DbObject.GetType()} is not type {typeof(T)}"); + } } [JsonIgnore] public Type EntityType => typeof(T); -} \ No newline at end of file +} From 7d81adeb12768d2839e0db6474e809213017f3dd Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Fri, 11 Oct 2024 16:21:58 +0700 Subject: [PATCH 13/14] add GetCurrent to ChangeContext --- src/SIL.Harmony/Changes/ChangeContext.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/SIL.Harmony/Changes/ChangeContext.cs b/src/SIL.Harmony/Changes/ChangeContext.cs index 3929999..15c10d7 100644 --- a/src/SIL.Harmony/Changes/ChangeContext.cs +++ b/src/SIL.Harmony/Changes/ChangeContext.cs @@ -17,6 +17,12 @@ internal ChangeContext(Commit commit, SnapshotWorker worker, CrdtConfig crdtConf 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); From 7ac1cd4673be440afb003b6c589985e6edfcd619 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Tue, 15 Oct 2024 09:21:52 +0700 Subject: [PATCH 14/14] make GetObjectBySnapshotId generic --- src/SIL.Harmony.Tests/DefinitionTests.cs | 2 +- src/SIL.Harmony.Tests/ExampleSentenceTests.cs | 2 +- src/SIL.Harmony.Tests/ModelSnapshotTests.cs | 2 +- src/SIL.Harmony.Tests/SyncTests.cs | 8 ++++---- src/SIL.Harmony/DataModel.cs | 4 ++-- src/SIL.Harmony/Db/CrdtRepository.cs | 4 ++-- 6 files changed, 11 insertions(+), 11 deletions(-) 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 8700460..90bed26 100644 --- a/src/SIL.Harmony.Tests/ModelSnapshotTests.cs +++ b/src/SIL.Harmony.Tests/ModelSnapshotTests.cs @@ -28,7 +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 entry = (Word) await DataModel.GetBySnapshotId(simpleSnapshot.Id); + var entry = await DataModel.GetBySnapshotId(simpleSnapshot.Id); entry.Text.Should().Be("second"); snapshot.LastChange.Should().Be(secondChange.DateTime); } diff --git a/src/SIL.Harmony.Tests/SyncTests.cs b/src/SIL.Harmony.Tests/SyncTests.cs index 221f04f..4e3f0c0 100644 --- a/src/SIL.Harmony.Tests/SyncTests.cs +++ b/src/SIL.Harmony.Tests/SyncTests.cs @@ -45,9 +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 client2Entity1 = (Word) await _client2.DataModel.GetBySnapshotId(client2Snapshot.Snapshots[entity1Id].Id); + var client2Entity1 = await _client2.DataModel.GetBySnapshotId(client2Snapshot.Snapshots[entity1Id].Id); client2Entity1.Text.Should().Be("entity1"); - var client1Entity2 = (Word) await _client1.DataModel.GetBySnapshotId(client1Snapshot.Snapshots[entity2Id].Id); + var client1Entity2 = await _client1.DataModel.GetBySnapshotId(client1Snapshot.Snapshots[entity2Id].Id); client1Entity2.Text.Should().Be("entity2"); } @@ -93,12 +93,12 @@ public async Task SyncMultipleClientChanges(int clientCount) serverSnapshot.Snapshots.Should().HaveCount(clientCount + 1); foreach (var entitySnapshot in serverSnapshot.Snapshots.Values) { - var serverEntity = (Word) await _client1.DataModel.GetBySnapshotId(entitySnapshot.Id); + 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 entity = (Word) await client.DataModel.GetBySnapshotId(simpleSnapshot.Id); + var entity = await client.DataModel.GetBySnapshotId(simpleSnapshot.Id); entity.Should().BeEquivalentTo(serverEntity); } } diff --git a/src/SIL.Harmony/DataModel.cs b/src/SIL.Harmony/DataModel.cs index 176bb7a..a0e0292 100644 --- a/src/SIL.Harmony/DataModel.cs +++ b/src/SIL.Harmony/DataModel.cs @@ -219,9 +219,9 @@ public IQueryable GetLatestObjects() where T : class 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) diff --git a/src/SIL.Harmony/Db/CrdtRepository.cs b/src/SIL.Harmony/Db/CrdtRepository.cs index 2a4ea39..bb94e70 100644 --- a/src/SIL.Harmony/Db/CrdtRepository.cs +++ b/src/SIL.Harmony/Db/CrdtRepository.cs @@ -158,14 +158,14 @@ 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