Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow storing extra data on objects before persisting #17

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
4 changes: 4 additions & 0 deletions src/SIL.Harmony.Sample/CrdtSampleKernel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ public static IServiceCollection AddCrdtDataSample(this IServiceCollection servi
{
services.AddDbContext<SampleDbContext>((provider, builder) =>
{
//this ensures that Ef Conversion methods will not be cached across different IoC containers
//this can show up as the second instance using the JsonSerializerOptions from the first container
//only needed for testing scenarios
builder.EnableServiceProviderCaching(false);
builder.UseLinqToDbCrdt(provider);
optionsBuilder(builder);
builder.EnableDetailedErrors();
Expand Down
2 changes: 1 addition & 1 deletion src/SIL.Harmony.Tests/Adapter/CustomObjectAdapterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,6 @@ await dataModel.AddChange(Guid.NewGuid(),
myClass2.MyNumber.Should().Be(123.45m);
myClass2.DeletedTime.Should().BeNull();

dataModel.GetLatestObjects<MyClass>().Should().NotBeEmpty();
dataModel.QueryLatest<MyClass>().Should().NotBeEmpty();
}
}
2 changes: 1 addition & 1 deletion src/SIL.Harmony.Tests/DataModelSimpleChanges.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ public async Task WriteMultipleCommits()

await WriteNextChange(SetWord(Guid.NewGuid(), "change 3"));
DbContext.Snapshots.Should().HaveCount(3);
DataModel.GetLatestObjects<Word>().Should().HaveCount(3);
DataModel.QueryLatest<Word>().Should().HaveCount(3);
}

[Fact]
Expand Down
17 changes: 9 additions & 8 deletions src/SIL.Harmony.Tests/DataModelTestBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,24 +22,25 @@ public class DataModelTestBase : IAsyncLifetime
internal readonly CrdtRepository CrdtRepository;
protected readonly MockTimeProvider MockTimeProvider = new();

public DataModelTestBase(bool saveToDisk = false, bool alwaysValidate = true) : this(saveToDisk
public DataModelTestBase(bool saveToDisk = false, bool alwaysValidate = true,
Action<IServiceCollection>? configure = null) : this(saveToDisk
? new SqliteConnection("Data Source=test.db")
: new SqliteConnection("Data Source=:memory:"), alwaysValidate)
: new SqliteConnection("Data Source=:memory:"), alwaysValidate, configure)
{
}

public DataModelTestBase() : this(new SqliteConnection("Data Source=:memory:"))
{
}

public DataModelTestBase(SqliteConnection connection, bool alwaysValidate = true)
public DataModelTestBase(SqliteConnection connection, bool alwaysValidate = true, Action<IServiceCollection>? configure = null)
{
_services = new ServiceCollection()
var serviceCollection = new ServiceCollection()
.AddCrdtDataSample(connection)
.AddOptions<CrdtConfig>().Configure(config => config.AlwaysValidateCommits = alwaysValidate)
.Services
.Replace(ServiceDescriptor.Singleton<IHybridDateTimeProvider>(MockTimeProvider))
.BuildServiceProvider();
.Configure<CrdtConfig>(config => config.AlwaysValidateCommits = alwaysValidate)
.Replace(ServiceDescriptor.Singleton<IHybridDateTimeProvider>(MockTimeProvider));
configure?.Invoke(serviceCollection);
_services = serviceCollection.BuildServiceProvider();
DbContext = _services.GetRequiredService<SampleDbContext>();
DbContext.Database.OpenConnection();
DbContext.Database.EnsureCreated();
Expand Down
2 changes: 1 addition & 1 deletion src/SIL.Harmony.Tests/DataQueryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public override async Task InitializeAsync()
[Fact]
public async Task CanQueryLatestData()
{
var entries = await DataModel.GetLatestObjects<Word>().ToArrayAsync();
var entries = await DataModel.QueryLatest<Word>().ToArrayAsync();
var entry = entries.Should().ContainSingle().Subject;
entry.Text.Should().Be("entity1");
}
Expand Down
8 changes: 4 additions & 4 deletions src/SIL.Harmony.Tests/DefinitionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public async Task CanGetInOrder()
await WriteNextChange(NewDefinition(wordId, "greet someone", "verb", 2));
await WriteNextChange(NewDefinition(wordId, "a greeting", "noun", 1));

var definitions = await DataModel.GetLatestObjects<Definition>().ToArrayAsync();
var definitions = await DataModel.QueryLatest<Definition>().ToArrayAsync();
definitions.Select(d => d.PartOfSpeech).Should().ContainInConsecutiveOrder(
"noun",
"verb"
Expand All @@ -69,7 +69,7 @@ public async Task CanChangeOrderBetweenExistingDefinitions()
await WriteNextChange(NewDefinition(wordId, "greet someone", "verb", 2, definitionBId));
await WriteNextChange(NewDefinition(wordId, "used as a greeting", "exclamation", 3, definitionCId));

var definitions = await DataModel.GetLatestObjects<Definition>().ToArrayAsync();
var definitions = await DataModel.QueryLatest<Definition>().ToArrayAsync();
definitions.Select(d => d.PartOfSpeech).Should().ContainInConsecutiveOrder(
"noun",
"verb",
Expand All @@ -79,7 +79,7 @@ public async Task CanChangeOrderBetweenExistingDefinitions()
//change the order of the exclamation to be between the noun and verb
await WriteNextChange(SetOrderChange<Definition>.Between(definitionCId, definitions[0], definitions[1]));

definitions = await DataModel.GetLatestObjects<Definition>().ToArrayAsync();
definitions = await DataModel.QueryLatest<Definition>().ToArrayAsync();
definitions.Select(d => d.PartOfSpeech).Should().ContainInConsecutiveOrder(
"noun",
"exclamation",
Expand All @@ -98,7 +98,7 @@ public async Task ConsistentlySortsItems()
await WriteNextChange(NewDefinition(wordId, "greet someone", "verb", 1, definitionAId));
await WriteNextChange(NewDefinition(wordId, "a greeting", "noun", 1, definitionBId));

var definitions = await DataModel.GetLatestObjects<Definition>().ToArrayAsync();
var definitions = await DataModel.QueryLatest<Definition>().ToArrayAsync();
definitions.Select(d => d.Id).Should().ContainInConsecutiveOrder(
definitionBId,
definitionAId
Expand Down
88 changes: 88 additions & 0 deletions src/SIL.Harmony.Tests/PersistExtraDataTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using SIL.Harmony.Changes;
using SIL.Harmony.Core;
using SIL.Harmony.Entities;
using SIL.Harmony.Sample;

namespace SIL.Harmony.Tests;

public class PersistExtraDataTests
{
private DataModelTestBase _dataModelTestBase;

public class CreateExtraDataModelChange(Guid entityId) : CreateChange<ExtraDataModel>(entityId), ISelfNamedType<CreateExtraDataModelChange>
{
public override ValueTask<ExtraDataModel> NewEntity(Commit commit, ChangeContext context)
{
return ValueTask.FromResult(new ExtraDataModel()
{
Id = EntityId,
});
}
}

public class ExtraDataModel : IObjectBase<ExtraDataModel>
{
public Guid Id { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public Guid CommitId { get; set; }
public DateTimeOffset? DateTime { get; set; }
public long Counter { get; set; }

public Guid[] GetReferences()
{
return [];
}

public void RemoveReference(Guid id, Commit commit)
{
}

public IObjectBase Copy()
{
return new ExtraDataModel()
{
Id = Id,
DeletedAt = DeletedAt,
CommitId = CommitId,
DateTime = DateTime,
Counter = Counter
};
}
}

public PersistExtraDataTests()
{
_dataModelTestBase = new DataModelTestBase(configure: services =>
{
services.Configure<CrdtConfig>(config =>
{
config.ObjectTypeListBuilder.DefaultAdapter().Add<ExtraDataModel>();
config.ChangeTypeListBuilder.Add<CreateExtraDataModelChange>();
config.BeforePersistObject = (obj, snapshot) =>
{
if (obj is ExtraDataModel extraDataModel)
{
extraDataModel.CommitId = snapshot.CommitId;
extraDataModel.DateTime = snapshot.Commit.HybridDateTime.DateTime;
extraDataModel.Counter = snapshot.Commit.HybridDateTime.Counter;
}
return ValueTask.CompletedTask;
};
});
});
}

[Fact]
public async Task CanPersistExtraData()
{
var entityId = Guid.NewGuid();
var commit = await _dataModelTestBase.WriteNextChange(new CreateExtraDataModelChange(entityId));
var extraDataModel = _dataModelTestBase.DataModel.QueryLatest<ExtraDataModel>().Should().ContainSingle().Subject;
extraDataModel.Id.Should().Be(entityId);
extraDataModel.CommitId.Should().Be(commit.Id);
extraDataModel.DateTime.Should().Be(commit.HybridDateTime.DateTime);
extraDataModel.Counter.Should().Be(commit.HybridDateTime.Counter);
}
}
4 changes: 2 additions & 2 deletions src/SIL.Harmony.Tests/SyncTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ public async Task CanSync_AddDependentWithMultipleChanges()

await _client2.DataModel.SyncWith(_client1.DataModel);

_client2.DataModel.GetLatestObjects<Definition>().Should()
.BeEquivalentTo(_client1.DataModel.GetLatestObjects<Definition>());
_client2.DataModel.QueryLatest<Definition>().Should()
.BeEquivalentTo(_client1.DataModel.QueryLatest<Definition>());
}
}
8 changes: 6 additions & 2 deletions src/SIL.Harmony/Adapters/CustomAdapterProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ public class CustomAdapterProvider<TCommonInterface, TCustomAdapter> : IObjectAd
{
private readonly ObjectTypeListBuilder _objectTypeListBuilder;
private readonly List<AdapterRegistration> _objectTypes = new();
private Dictionary<Type, List<JsonDerivedType>> JsonTypes { get; } = [];
Dictionary<Type, List<JsonDerivedType>> IObjectAdapterProvider.JsonTypes => JsonTypes;
private Dictionary<Type, List<JsonDerivedType>> JsonTypes => _objectTypeListBuilder.JsonTypes;

public CustomAdapterProvider(ObjectTypeListBuilder objectTypeListBuilder)
{
Expand Down Expand Up @@ -55,6 +54,11 @@ IObjectBase IObjectAdapterProvider.Adapt(object obj)
{
return TCustomAdapter.Create((TCommonInterface)obj);
}

public bool CanAdapt(object obj)
{
return obj is TCommonInterface;
}
}

// it's possible to implement this without a Common interface
Expand Down
8 changes: 6 additions & 2 deletions src/SIL.Harmony/Adapters/DefaultAdapterProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ IObjectBase IObjectAdapterProvider.Adapt(object obj)
$"Object is of type {obj.GetType().Name} which does not implement {nameof(IObjectBase)}");
}

private Dictionary<Type, List<JsonDerivedType>> JsonTypes { get; } = [];
Dictionary<Type, List<JsonDerivedType>> IObjectAdapterProvider.JsonTypes => JsonTypes;
public bool CanAdapt(object obj)
{
return obj is IObjectBase;
}

private Dictionary<Type, List<JsonDerivedType>> JsonTypes => objectTypeListBuilder.JsonTypes;
}
3 changes: 1 addition & 2 deletions src/SIL.Harmony/Adapters/IObjectAdapterProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,5 @@ internal interface IObjectAdapterProvider
{
IEnumerable<AdapterRegistration> GetRegistrations();
IObjectBase Adapt(object obj);

Dictionary<Type, List<JsonDerivedType>> JsonTypes { get; }
bool CanAdapt(object obj);
}
2 changes: 1 addition & 1 deletion src/SIL.Harmony/Changes/ChangeContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,5 @@ internal ChangeContext(Commit commit, SnapshotWorker worker, CrdtConfig crdtConf
}

public async ValueTask<bool> IsObjectDeleted(Guid entityId) => (await GetSnapshot(entityId))?.EntityIsDeleted ?? true;
internal IObjectBase Adapt(object obj) => _crdtConfig.ObjectTypeListBuilder.AdapterProvider.Adapt(obj);
internal IObjectBase Adapt(object obj) => _crdtConfig.ObjectTypeListBuilder.Adapt(obj);
}
40 changes: 28 additions & 12 deletions src/SIL.Harmony/CrdtConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@
using SIL.Harmony.Entities;

namespace SIL.Harmony;

public delegate ValueTask BeforeSaveObjectDelegate(object obj, ObjectSnapshot snapshot);
public class CrdtConfig
{
/// <summary>
/// recommended to increase query performance, as getting objects can just query the table for that object.
/// it does however increase database size as now objects are stored both in snapshots and in their projected tables
/// </summary>
public bool EnableProjectedTables { get; set; } = true;
public BeforeSaveObjectDelegate BeforePersistObject { get; set; } = (o, snapshot) => ValueTask.CompletedTask;
/// <summary>
/// after adding any commit validate the commit history, not great for performance but good for testing.
/// </summary>
Expand Down Expand Up @@ -107,8 +108,7 @@ public void Freeze()
{
if (_frozen) return;
_frozen = true;
JsonTypes = AdapterProvider.JsonTypes;
foreach (var registration in AdapterProvider.GetRegistrations())
foreach (var registration in AdapterProviders.SelectMany(a => a.GetRegistrations()))
{
ModelConfigurations.Add((builder, config) =>
{
Expand All @@ -127,19 +127,18 @@ internal void CheckFrozen()
if (_frozen) throw new InvalidOperationException($"{nameof(ObjectTypeListBuilder)} is frozen");
}

internal Dictionary<Type, List<JsonDerivedType>>? JsonTypes { get; set; }
internal Dictionary<Type, List<JsonDerivedType>> JsonTypes { get; } = [];

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

internal IObjectAdapterProvider AdapterProvider => _adapterProvider ?? throw new InvalidOperationException("No adapter has been added to the builder");
private IObjectAdapterProvider? _adapterProvider;
internal List<IObjectAdapterProvider> AdapterProviders { get; } = [];

public DefaultAdapterProvider DefaultAdapter()
{
CheckFrozen();
if (_adapterProvider is not null) throw new InvalidOperationException("adapter has already been added");
var adapter = new DefaultAdapterProvider(this);
_adapterProvider = adapter;
if (AdapterProviders.OfType<DefaultAdapterProvider>().SingleOrDefault() is {} adapter) return adapter;
adapter = new DefaultAdapterProvider(this);
AdapterProviders.Add(adapter);
return adapter;
}

Expand All @@ -162,9 +161,26 @@ public CustomAdapterProvider<TCommonInterface, TAdapter> CustomAdapter<TCommonIn
where TCommonInterface : class where TAdapter : class, ICustomAdapter<TAdapter, TCommonInterface>, IPolyType
{
CheckFrozen();
if (_adapterProvider is not null) throw new InvalidOperationException("adapter has already been added");
var adapter = new CustomAdapterProvider<TCommonInterface, TAdapter>(this);
_adapterProvider = adapter;
if (AdapterProviders.OfType<CustomAdapterProvider<TCommonInterface, TAdapter>>().SingleOrDefault() is {} adapter) return adapter;
adapter = new CustomAdapterProvider<TCommonInterface, TAdapter>(this);
AdapterProviders.Add(adapter);
return adapter;
}

internal IObjectBase Adapt(object obj)
{
if (AdapterProviders is [{ } defaultAdapter])
{
return defaultAdapter.Adapt(obj);
}

foreach (var objectAdapterProvider in AdapterProviders)
{
if (objectAdapterProvider.CanAdapt(obj))
{
return objectAdapterProvider.Adapt(obj);
}
}
throw new ArgumentException($"Unable to adapt object of type {obj.GetType()}");
}
}
2 changes: 1 addition & 1 deletion src/SIL.Harmony/DataModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ public async Task<ModelSnapshot> GetProjectSnapshot(bool includeDeleted = false)
return new ModelSnapshot(await _crdtRepository.CurrenSimpleSnapshots(includeDeleted).ToArrayAsync());
}

public IQueryable<T> GetLatestObjects<T>() where T : class
public IQueryable<T> QueryLatest<T>() where T : class
{
var q = _crdtRepository.GetCurrentObjects<T>();
if (q is IQueryable<IOrderableCrdt>)
Expand Down
7 changes: 5 additions & 2 deletions src/SIL.Harmony/Db/CrdtRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -229,11 +229,13 @@ private async ValueTask SnapshotAdded(ObjectSnapshot objectSnapshot)
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.DbObject.GetType(), objectSnapshot.EntityId);
object? entity;
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().DbObject)
entity = objectSnapshot.Entity.Copy().DbObject;
_dbContext.Add(entity)
.Property(ObjectSnapshot.ShadowRefName).CurrentValue = objectSnapshot.Id;
return;
}
Expand All @@ -245,7 +247,8 @@ private async ValueTask SnapshotAdded(ObjectSnapshot objectSnapshot)
return;
}

existingEntry.CurrentValues.SetValues(objectSnapshot.Entity.DbObject);
entity = objectSnapshot.Entity.DbObject;
existingEntry.CurrentValues.SetValues(entity);
existingEntry.Property(ObjectSnapshot.ShadowRefName).CurrentValue = objectSnapshot.Id;
}

Expand Down
2 changes: 2 additions & 0 deletions src/SIL.Harmony/SnapshotWorker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ private async ValueTask ApplyCommitChanges(IEnumerable<Commit> commits, bool upd
{
intermediateSnapshots[prevSnapshot.Entity.Id] = prevSnapshot;
}

await _crdtConfig.BeforePersistObject.Invoke(entity.DbObject, newSnapshot);

AddSnapshot(newSnapshot);
}
Expand Down
Loading