From 03b6679f4222047b8e4acfd085daaf94f037f0f3 Mon Sep 17 00:00:00 2001 From: Timothy Baldridge Date: Tue, 19 Dec 2023 13:19:01 -0700 Subject: [PATCH] Rework everything to work via transactions --- docs/index.md | 182 ++++++++++++++++++ .../AEntity.cs | 2 +- .../IEntityContext.cs | 8 +- .../ITransaction.cs | 26 +-- .../MultiEntityAttributeDefinition.cs | 15 ++ .../ScalarAttribute.cs | 130 ++++++------- src/NexusMods.EventSourcing/EntityContext.cs | 8 +- src/NexusMods.EventSourcing/EventAndIds.cs | 21 -- .../Events/TransactionEvent.cs | 22 +-- .../ForwardEventContext.cs | 2 +- src/NexusMods.EventSourcing/Services.cs | 2 + src/NexusMods.EventSourcing/Transaction.cs | 27 +++ .../Events/AddCollection.cs | 30 +++ .../Events/AddMod.cs | 8 +- .../Events/CreateLoadout.cs | 7 +- .../Events/DeleteMod.cs | 4 + .../Model/Collection.cs | 27 +++ .../Model/Loadout.cs | 5 + .../Model/Mod.cs | 8 +- .../AEventStoreTest.cs | 12 +- .../BasicFunctionalityTests.cs | 134 ++++++++----- 21 files changed, 498 insertions(+), 182 deletions(-) delete mode 100644 src/NexusMods.EventSourcing/EventAndIds.cs create mode 100644 src/NexusMods.EventSourcing/Transaction.cs create mode 100644 tests/NexusMods.EventSourcing.TestModel/Events/AddCollection.cs create mode 100644 tests/NexusMods.EventSourcing.TestModel/Model/Collection.cs diff --git a/docs/index.md b/docs/index.md index d273f6ce..348a5e41 100644 --- a/docs/index.md +++ b/docs/index.md @@ -387,17 +387,199 @@ This interface is pretty simple, and is the only interface to KV stores like Roc In the future this interface may be updated to replay events in reverse, as some features like "Undo" may be much more efficient if events are read in reverse. +## The IEvent Interface +The `IEvent` interface is the core of the system, it defines a single `Apply` method that takes a IEventContext. +!!!tip "Do not confuse the `IEventContext` with the similarly named `IEntityContext` the former defines the data that a `IEvent` needs to perform it write operations, while the latter defines the read-only side of the system." +```csharp + +/// +/// A single event that can be applied to an entity. +/// +[MemoryPackable(GenerateType.NoGenerate)] +public interface IEvent +{ + /// + /// Applies the event to the entities attached to the event. + /// + void Apply(T context) where T : IEventContext; +} + +/// +/// This is the context interface passed to event handlers, it allows the handler to attach new entities to the context +/// +public interface IEventContext +{ + /// + /// Gets the accumulator for the given attribute definition, if the accumulator does not exist it will be created. If + /// the context is not setup for this entityId then false will be returned and the accumulator should be ignored. + /// + /// + /// + /// + /// + /// + /// + /// + /// + public bool GetAccumulator(EntityId entityId, + TAttribute attributeDefinition, + [NotNullWhen(true)] out TAccumulator accumulator) + where TAttribute : IAttribute + where TAccumulator : IAccumulator + where TOwner : IEntity; +} +``` + +Granted this code looks a bit like word salad, but thankfully the `IEventContext` is never used directly by event implementations, it is passed on to the attributes which are rarely implemented +by end-user developers. The key thing to see here is that the `IEventContext` is responsible for creating and retrieving accumulators. The perhaps strange thing about this +method is that it returns `bool` and the accumulator is an `out` parameter. This is because the context is free to ignore the accumulator request if the given context does not wish to process +the accumulator change. Thus internally each attribute will check this value and short circuit if the accumulator is not needed. This pattern allows the framework to interrogate the event +and gather information about it simply by calling `Apply` on the event, without having to actually apply the event to the read model. + +The most common form of interrogation is to log the `EntityId`s that are passed to the `GetAccumulator` method. Using this information the framework +can quickly determine what entities are affected by the event, and thus how to index the event in the datastore. + +Armed with this knowledge, let's take a look at a simple attribute implementation, starting first with the `IAttribute` interface + +### IAttribute + +```csharp + +public interface IAttribute +{ + public Type Owner { get; } + + public string Name { get; } +} + +public interface IAttribute : IAttribute where TAccumulator : IAccumulator +{ + public TAccumulator CreateAccumulator(); +} + +public interface IAccumulator +{ +} +``` + +Since most of the logic for attributes is contained in the attributes themselves, the interfaces here are quite simple and opaque. +An attribute has a name and a type, and the typed version returns a new accumulator. The accumulator has no public methods as its implementation +is hidden to the the attribute itself. The accumulator is simply a mutable object that is used to store mutable the data for the attribute for a given entity. + +Let's look an actual implementation: + +```csharp +public class ScalarAttribute(string attrName) : IAttribute> +where TOwner : AEntity +{ + #region Framework API + public Type Owner => typeof(TOwner); + public string Name => attrName; + + public ScalarAccumulator CreateAccumulator() + { + return new ScalarAccumulator(); + } + #endregion + + #region Attribute DSL + public void Set(TContext context, EntityId owner, TType value) + where TContext : IEventContext + { + if (context.GetAccumulator, ScalarAccumulator>(owner, this, out var accumulator)) + accumulator.Value = value; + } + + public void Unset(TContext context, EntityId owner) + where TContext : IEventContext + { + if (context.GetAccumulator, ScalarAccumulator>(owner, this, out var accumulator)) + accumulator.Value = default!; + } + + public TType Get(TOwner owner) + { + if (owner.Context.GetReadOnlyAccumulator, ScalarAccumulator>(owner.Id, this, out var accumulator)) + return accumulator.Value; + throw new InvalidOperationException($"Attribute not found for {Name} on {Owner.Name} with id {owner.Id}"); + } + #endregion +} + +public class ScalarAccumulator : IAccumulator +{ + public TVal Value = default! ; +} + +``` + +The attribute is divided into two logical parts: the framework specific methods, and the attribute DSL. Each attribute is free +to define its own DSL and is encouraged to make it as easy to use as possible. Note the `if` in every write method that short-circuts +the work if the accumulator is not found. Also note the general DSL pattern: get an accumulator from the context, manipulate the context. + +!!!tip "The event ingestion for a given entity are always single threaded, thus it is not required to make accumulators explicitly thread-safe. It is assumed that they will only ever be modified by one thread at a time" + +### IEntityContext +The final piece in this puzzle is the `IEntityContext` interface. This interface is responsible for creating and retrieving entities, as well +as replaying events, and accepting new events from the application. This interface is the primary abstraction applications will use to interact +with the framework. + +```csharp +/// +/// A context for working with entities and events. Multiple contexts can be used to work with different sets of entities +/// as of different transaction ids. +/// +public interface IEntityContext +{ + public TEntity Get(EntityId id) where TEntity : IEntity; + public TEntity Get() where TEntity : ISingletonEntity; + + + public TransactionId Add(TEvent entity) where TEvent : IEvent; + + // Used by attributes, not used directly by application developers + bool GetReadOnlyAccumulator(EntityId ownerId, TAttribute attributeDefinition, out TAccumulator accumulator) + where TOwner : IEntity + where TAttribute : IAttribute + where TAccumulator : IAccumulator; +} +``` + +To start with there are two gettters, one for singleton entities, and for non-singleton entities. These methods are often used +for getting entities when the id of the entity is already known. Attributes may also use these methods to resolve entities and provide them +to the application. + +The `Add` method is used to add new events to the datastore, and ratchet the state of the system forward. + +!!!info "It is assumed that the EntityContext will maintain a cache of the entities and accumulators in use by the system. Thus it is not required to cache these entities or restrict the amount of calls made to .Get()" + +The `GetReadOnlyAccumulator` method is used by attributes to get accumulators for entities. This method is not used directly by application developers. +# Smaller features of the Framework +## Reactive UI integration +All entities support `INotifyPropertyChanged`, when the state of an entity changes via the ingestion of a new event, the entity will raise the `PropertyChanged` event for the modified attributes. +This is the primary reason why all Attributes contain a `Name` field, as this field will be used to raise the `PropertyChanged` event. +In addition, the multi-valued collection attributes implement `INotifyCollectionChanged` and will raise the `CollectionChanged` event when the collection is modified. The combination +of these two interfaces means that the entities can be used directly with WPF, Avalonia, and other UI frameworks that support these interfaces. +## Future areas for optimization +### Dynamic lookup +Currently calling a `Attribute.Get` method requires call to `IEntityContext.GetReadOnlyAccumulator`. In the current implementation of `EntityContext` this is a double dictionary lookup. +The first lookup resolves the entity down to a collection of accumulators the second gets the accumulator for the given attribute. This fairly fast today, roughly 12ns per call, but it could likely be made +faster via using the DLR to cache a revision of the entity state. In otherwords, setup a guard such as `if the entity cache is still valid, return this constant accumulator`. This would effectively make +a call to `Attribute.Get` as performant as checking a field on the context and then a field lookup on an accumulator. +### Weak Entities +Currently once entities are loaded into a context they live forever. This is not a problem for initial testing, but the EntityContext should probably store these entities +in a dictionary with weak references to the entities so they can be garbage collected if they are no longer in use. diff --git a/src/NexusMods.EventSourcing.Abstractions/AEntity.cs b/src/NexusMods.EventSourcing.Abstractions/AEntity.cs index cb5a8499..930dbab5 100644 --- a/src/NexusMods.EventSourcing.Abstractions/AEntity.cs +++ b/src/NexusMods.EventSourcing.Abstractions/AEntity.cs @@ -18,7 +18,7 @@ public abstract class AEntity : IEntity /// /// The typed entity id. /// - protected internal readonly EntityId Id; + public readonly EntityId Id; /// /// The base class for all entities. diff --git a/src/NexusMods.EventSourcing.Abstractions/IEntityContext.cs b/src/NexusMods.EventSourcing.Abstractions/IEntityContext.cs index aaa79808..0d515cb7 100644 --- a/src/NexusMods.EventSourcing.Abstractions/IEntityContext.cs +++ b/src/NexusMods.EventSourcing.Abstractions/IEntityContext.cs @@ -1,5 +1,3 @@ -using System.Threading.Tasks; - namespace NexusMods.EventSourcing.Abstractions; /// @@ -33,6 +31,12 @@ public interface IEntityContext /// public TransactionId Add(TEvent entity) where TEvent : IEvent; + /// + /// Starts a new transaction, events can be added to the transaction, then applied + /// at once by calling commit on the transaction. + /// + /// + public ITransaction Begin(); /// /// Gets the value of the attribute for the given entity. diff --git a/src/NexusMods.EventSourcing.Abstractions/ITransaction.cs b/src/NexusMods.EventSourcing.Abstractions/ITransaction.cs index 30d199d2..4249230f 100644 --- a/src/NexusMods.EventSourcing.Abstractions/ITransaction.cs +++ b/src/NexusMods.EventSourcing.Abstractions/ITransaction.cs @@ -1,34 +1,20 @@ using System; -using System.Threading.Tasks; namespace NexusMods.EventSourcing.Abstractions; /// -/// A interface for a transaction that can be used to add new events to storage. +/// A context for adding events to an aggregate event that will apply the events together. /// public interface ITransaction : IDisposable { /// - /// Confirms the transaction and commits the changes to the underlying storage. + /// Adds the event to the transaction, but does not apply it. /// - /// - public ValueTask CommitAsync(); + /// + public void Add(IEvent @event); /// - /// Gets the current state of an entity. + /// Commits the transaction, applying all events /// - /// - /// - /// - public T Retrieve(EntityId entityId) where T : IEntity; - - /// - /// Adds a new event to the transaction, this will also update the current - /// entity states - /// - /// - /// - /// - public ValueTask Add(T eventToAdd) where T : IEvent; - + public TransactionId Commit(); } diff --git a/src/NexusMods.EventSourcing.Abstractions/MultiEntityAttributeDefinition.cs b/src/NexusMods.EventSourcing.Abstractions/MultiEntityAttributeDefinition.cs index 70411cbb..48740e15 100644 --- a/src/NexusMods.EventSourcing.Abstractions/MultiEntityAttributeDefinition.cs +++ b/src/NexusMods.EventSourcing.Abstractions/MultiEntityAttributeDefinition.cs @@ -36,6 +36,21 @@ public void Add(TContext context, EntityId owner, EntityId + /// Adds multiple links to the other entity. + /// + /// + /// + /// + /// + public void AddAll(TContext context, EntityId owner, EntityId[] values) + where TContext : IEventContext + { + if (context.GetAccumulator, MultiEntityAccumulator>(owner, this, out var accumulator)) + foreach (var value in values) + accumulator.Add(value); + } + /// /// Removes a link to the other entity. /// diff --git a/src/NexusMods.EventSourcing.Abstractions/ScalarAttribute.cs b/src/NexusMods.EventSourcing.Abstractions/ScalarAttribute.cs index 8054865a..a087c83c 100644 --- a/src/NexusMods.EventSourcing.Abstractions/ScalarAttribute.cs +++ b/src/NexusMods.EventSourcing.Abstractions/ScalarAttribute.cs @@ -3,74 +3,74 @@ namespace NexusMods.EventSourcing.Abstractions; /// -/// A scalar attribute that can be exposed on an entity. -/// -public class ScalarAttribute(string attrName) : IAttribute> -where TOwner : AEntity -{ - /// - public Type Owner => typeof(TOwner); + /// A scalar attribute that can be exposed on an entity. + /// + public class ScalarAttribute(string attrName) : IAttribute> + where TOwner : AEntity + { + /// + public Type Owner => typeof(TOwner); - /// - public string Name => attrName; + /// + public string Name => attrName; - /// - public ScalarAccumulator CreateAccumulator() - { - return new ScalarAccumulator(); - } + /// + public ScalarAccumulator CreateAccumulator() + { + return new ScalarAccumulator(); + } - /// - /// Sets the value of the attribute for the given entity. - /// - /// - /// - /// - /// - public void Set(TContext context, EntityId owner, TType value) - where TContext : IEventContext - { - if (context.GetAccumulator, ScalarAccumulator>(owner, this, out var accumulator)) - accumulator.Value = value; - } + /// + /// Sets the value of the attribute for the given entity. + /// + /// + /// + /// + /// + public void Set(TContext context, EntityId owner, TType value) + where TContext : IEventContext + { + if (context.GetAccumulator, ScalarAccumulator>(owner, this, out var accumulator)) + accumulator.Value = value; + } - /// - /// Resets the value of the attribute for the given entity to the default value. - /// - /// - /// - /// - public void Unset(TContext context, EntityId owner) - where TContext : IEventContext - { - if (context.GetAccumulator, ScalarAccumulator>(owner, this, out var accumulator)) - accumulator.Value = default!; - } + /// + /// Resets the value of the attribute for the given entity to the default value. + /// + /// + /// + /// + public void Unset(TContext context, EntityId owner) + where TContext : IEventContext + { + if (context.GetAccumulator, ScalarAccumulator>(owner, this, out var accumulator)) + accumulator.Value = default!; + } - /// - /// Gets the value of the attribute for the given entity. - /// - /// - /// - /// - /// - public TType Get(TOwner owner) - { - if (owner.Context.GetReadOnlyAccumulator, ScalarAccumulator>(owner.Id, this, out var accumulator)) - return accumulator.Value; - // TODO, make this a custom exception and extract it to another method - throw new InvalidOperationException($"Attribute not found for {Name} on {Owner.Name} with id {owner.Id}"); - } -} + /// + /// Gets the value of the attribute for the given entity. + /// + /// + /// + /// + /// + public TType Get(TOwner owner) + { + if (owner.Context.GetReadOnlyAccumulator, ScalarAccumulator>(owner.Id, this, out var accumulator)) + return accumulator.Value; + // TODO, make this a custom exception and extract it to another method + throw new InvalidOperationException($"Attribute not found for {Name} on {Owner.Name} with id {owner.Id}"); + } + } -/// -/// A scalar attribute accumulator, used to store a single value -/// -/// -public class ScalarAccumulator : IAccumulator -{ - /// - /// The value of the accumulator - /// - public TVal Value = default! ; -} + /// + /// A scalar attribute accumulator, used to store a single value + /// + /// + public class ScalarAccumulator : IAccumulator + { + /// + /// The value of the accumulator + /// + public TVal Value = default! ; + } diff --git a/src/NexusMods.EventSourcing/EntityContext.cs b/src/NexusMods.EventSourcing/EntityContext.cs index 438a82f5..43f1607b 100644 --- a/src/NexusMods.EventSourcing/EntityContext.cs +++ b/src/NexusMods.EventSourcing/EntityContext.cs @@ -1,8 +1,6 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.ComponentModel; -using System.Runtime.CompilerServices; using NexusMods.EventSourcing.Abstractions; namespace NexusMods.EventSourcing; @@ -122,6 +120,12 @@ public bool GetReadOnlyAccumulator(EntityId + public ITransaction Begin() + { + return new Transaction(this, new List()); + } + /// /// Empties all caches, any existing entities will be stale and likely no longer work, use only for testing. /// diff --git a/src/NexusMods.EventSourcing/EventAndIds.cs b/src/NexusMods.EventSourcing/EventAndIds.cs deleted file mode 100644 index 5ef5e2cf..00000000 --- a/src/NexusMods.EventSourcing/EventAndIds.cs +++ /dev/null @@ -1,21 +0,0 @@ -using MemoryPack; -using NexusMods.EventSourcing.Abstractions; - -namespace NexusMods.EventSourcing; - -/// -/// A pair of an event and the entity ids it applies to. -/// -[MemoryPackable] -public class EventAndIds -{ - /// - /// The event - /// - public required IEvent Event { get; init; } - - /// - /// The entities retrieved by the event - /// - public required EntityId[] EntityIds { get; init; } -} diff --git a/src/NexusMods.EventSourcing/Events/TransactionEvent.cs b/src/NexusMods.EventSourcing/Events/TransactionEvent.cs index 2a71b493..95611588 100644 --- a/src/NexusMods.EventSourcing/Events/TransactionEvent.cs +++ b/src/NexusMods.EventSourcing/Events/TransactionEvent.cs @@ -1,27 +1,19 @@ -using System; -using System.Threading.Tasks; using MemoryPack; using NexusMods.EventSourcing.Abstractions; namespace NexusMods.EventSourcing.Events; +/// +/// An aggregate event that groups together a set of events that should be applied together. +/// +[EventId("DFEC36C4-ACAB-405D-AAEE-2F6348BA108F")] [MemoryPackable] -public class TransactionEvent : IEvent +public partial record TransactionEvent(IEvent[] Events) : IEvent { - /// - /// A list of events that are part of the transaction. - /// - public required EventAndIds[] Events { get; init; } - - /// - /// Applies all the events in the transaction to the entities attached to the events. - /// - /// - /// - /// + /// public void Apply(T context) where T : IEventContext { foreach (var evt in Events) - evt.Event.Apply(context); + evt.Apply(context); } } diff --git a/src/NexusMods.EventSourcing/ForwardEventContext.cs b/src/NexusMods.EventSourcing/ForwardEventContext.cs index 0245f4f3..5a7982f1 100644 --- a/src/NexusMods.EventSourcing/ForwardEventContext.cs +++ b/src/NexusMods.EventSourcing/ForwardEventContext.cs @@ -10,7 +10,7 @@ namespace NexusMods.EventSourcing; /// accumulators and entities. In other words is for moving a /// /// -public readonly struct ForwardEventContext(ImmutableDictionary> trackedEntities, HashSet<(EntityId, string)> updatedAttributes) : IEventContext +public readonly struct ForwardEventContext(ConcurrentDictionary> trackedEntities, HashSet<(EntityId, string)> updatedAttributes) : IEventContext { /// public bool GetAccumulator(EntityId entityId, TAttribute attributeDefinition, out TAccumulator accumulator) diff --git a/src/NexusMods.EventSourcing/Services.cs b/src/NexusMods.EventSourcing/Services.cs index 4b23fa6c..46d952d0 100644 --- a/src/NexusMods.EventSourcing/Services.cs +++ b/src/NexusMods.EventSourcing/Services.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using NexusMods.EventSourcing.Abstractions; +using NexusMods.EventSourcing.Events; namespace NexusMods.EventSourcing; @@ -9,6 +10,7 @@ public static IServiceCollection AddEventSourcing(this IServiceCollection servic { return services .AddSingleton() + .AddEvent() .AddSingleton(); } diff --git a/src/NexusMods.EventSourcing/Transaction.cs b/src/NexusMods.EventSourcing/Transaction.cs new file mode 100644 index 00000000..1bcf5cb7 --- /dev/null +++ b/src/NexusMods.EventSourcing/Transaction.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using NexusMods.EventSourcing.Abstractions; +using NexusMods.EventSourcing.Events; + +namespace NexusMods.EventSourcing; + +internal class Transaction(IEntityContext context, List events) : ITransaction +{ + /// + public void Add(IEvent @event) + { + events.Add(@event); + } + + /// + public TransactionId Commit() + { + if (events.Count == 1) + return context.Add(events[0]); + return context.Add(new TransactionEvent(events.ToArray())); + } + + public void Dispose() + { + events.Clear(); + } +} diff --git a/tests/NexusMods.EventSourcing.TestModel/Events/AddCollection.cs b/tests/NexusMods.EventSourcing.TestModel/Events/AddCollection.cs new file mode 100644 index 00000000..221002e5 --- /dev/null +++ b/tests/NexusMods.EventSourcing.TestModel/Events/AddCollection.cs @@ -0,0 +1,30 @@ +using MemoryPack; +using NexusMods.EventSourcing.Abstractions; +using NexusMods.EventSourcing.TestModel.Model; + +namespace NexusMods.EventSourcing.TestModel.Events; + +[EventId("9C6CF87E-9469-4C9E-87AB-6FE7EF331358")] +[MemoryPackable] +public partial record AddCollection(EntityId CollectionId, string Name, EntityId LoadoutId, EntityId[] Mods) : IEvent +{ + public void Apply(T context) where T : IEventContext + { + IEntity.TypeAttribute.New(context, CollectionId); + Collection._name.Set(context, CollectionId, Name); + Collection._loadout.Link(context, CollectionId, LoadoutId); + Collection._mods.AddAll(context, CollectionId, Mods); + Loadout._collections.Add(context, LoadoutId, CollectionId); + foreach (var mod in Mods) + { + Mod._collection.Link(context, mod, CollectionId); + } + } + + public static EntityId Create(ITransaction tx, string name, EntityId loadout, params EntityId[] mods) + { + var id = EntityId.NewId(); + tx.Add(new AddCollection(id, name, loadout, mods)); + return id; + } +} diff --git a/tests/NexusMods.EventSourcing.TestModel/Events/AddMod.cs b/tests/NexusMods.EventSourcing.TestModel/Events/AddMod.cs index d45d2acb..a0c76346 100644 --- a/tests/NexusMods.EventSourcing.TestModel/Events/AddMod.cs +++ b/tests/NexusMods.EventSourcing.TestModel/Events/AddMod.cs @@ -25,6 +25,10 @@ public void Apply(T context) where T : IEventContext /// /// /// - public static AddMod Create(string name, EntityId loadoutId, bool enabled = true) - => new(name, enabled, EntityId.NewId(), loadoutId); + public static EntityId Create(ITransaction tx, string name, EntityId loadoutId, bool enabled = true) + { + var id = EntityId.NewId(); + tx.Add(new AddMod(name, enabled, id, loadoutId)); + return id; + } } diff --git a/tests/NexusMods.EventSourcing.TestModel/Events/CreateLoadout.cs b/tests/NexusMods.EventSourcing.TestModel/Events/CreateLoadout.cs index c4562307..52edd223 100644 --- a/tests/NexusMods.EventSourcing.TestModel/Events/CreateLoadout.cs +++ b/tests/NexusMods.EventSourcing.TestModel/Events/CreateLoadout.cs @@ -15,5 +15,10 @@ public void Apply(T context) where T : IEventContext Loadout._name.Set(context, Id, Name); LoadoutRegistry._loadouts.Add(context, LoadoutRegistry.SingletonId, Id); } - public static CreateLoadout Create(string name) => new(EntityId.NewId(), name); + public static EntityId Create(ITransaction tx, string name) + { + var id = EntityId.NewId(); + tx.Add(new CreateLoadout(id, name)); + return id; + } } diff --git a/tests/NexusMods.EventSourcing.TestModel/Events/DeleteMod.cs b/tests/NexusMods.EventSourcing.TestModel/Events/DeleteMod.cs index a5639e4b..96e4c36d 100644 --- a/tests/NexusMods.EventSourcing.TestModel/Events/DeleteMod.cs +++ b/tests/NexusMods.EventSourcing.TestModel/Events/DeleteMod.cs @@ -13,4 +13,8 @@ public void Apply(T context) where T : IEventContext Loadout._mods.Remove(context, LoadoutId, ModId); Mod._loadout.Unlink(context, ModId); } + public static void Create(ITransaction tx, EntityId modId, EntityId loadoutId) + { + tx.Add(new DeleteMod(modId, loadoutId)); + } } diff --git a/tests/NexusMods.EventSourcing.TestModel/Model/Collection.cs b/tests/NexusMods.EventSourcing.TestModel/Model/Collection.cs new file mode 100644 index 00000000..87933a1a --- /dev/null +++ b/tests/NexusMods.EventSourcing.TestModel/Model/Collection.cs @@ -0,0 +1,27 @@ +using System.Collections.ObjectModel; +using NexusMods.EventSourcing.Abstractions; + +namespace NexusMods.EventSourcing.TestModel.Model; + +public class Collection : AEntity +{ + public Collection(IEntityContext context, EntityId id) : base(context, id) { } + + /// + /// Name of the collection + /// + public string Name => _name.Get(this); + internal static readonly ScalarAttribute _name = new(nameof(Name)); + + /// + /// The collection's loadout. + /// + public Loadout Loadout => _loadout.Get(this); + internal static readonly EntityAttributeDefinition _loadout = new(nameof(Loadout)); + + /// + /// The mods in the collection. + /// + public ReadOnlyObservableCollection Mods => _mods.Get(this); + internal static readonly MultiEntityAttributeDefinition _mods = new(nameof(Mods)); +} diff --git a/tests/NexusMods.EventSourcing.TestModel/Model/Loadout.cs b/tests/NexusMods.EventSourcing.TestModel/Model/Loadout.cs index 4c1947d2..de199961 100644 --- a/tests/NexusMods.EventSourcing.TestModel/Model/Loadout.cs +++ b/tests/NexusMods.EventSourcing.TestModel/Model/Loadout.cs @@ -18,4 +18,9 @@ public class Loadout(IEntityContext context, EntityId id) : AEntity Mods => _mods.Get(this); internal static readonly MultiEntityAttributeDefinition _mods = new(nameof(Mods)); + /// + /// The Collections contained in the loadout. + /// + public ReadOnlyObservableCollection Collections => _collections.Get(this); + internal static readonly MultiEntityAttributeDefinition _collections = new(nameof(Collections)); } diff --git a/tests/NexusMods.EventSourcing.TestModel/Model/Mod.cs b/tests/NexusMods.EventSourcing.TestModel/Model/Mod.cs index 7955203f..3ae0c9e1 100644 --- a/tests/NexusMods.EventSourcing.TestModel/Model/Mod.cs +++ b/tests/NexusMods.EventSourcing.TestModel/Model/Mod.cs @@ -5,7 +5,6 @@ namespace NexusMods.EventSourcing.TestModel.Model; public class Mod(IEntityContext context, EntityId id) : AEntity(context, id) { - public Loadout Loadout => _loadout.Get(this); internal static readonly EntityAttributeDefinition _loadout = new(nameof(Loadout)); @@ -20,4 +19,11 @@ public class Mod(IEntityContext context, EntityId id) : AEntity(contex /// public bool Enabled => _enabled.Get(this); internal static readonly ScalarAttribute _enabled = new(nameof(Enabled)); + + /// + /// The Collection the mod is in, if any + /// + public Collection Collection => _collection.Get(this); + internal static readonly EntityAttributeDefinition _collection = new(nameof(Collection)); + } diff --git a/tests/NexusMods.EventSourcing.Tests/AEventStoreTest.cs b/tests/NexusMods.EventSourcing.Tests/AEventStoreTest.cs index 74428961..153cf98d 100644 --- a/tests/NexusMods.EventSourcing.Tests/AEventStoreTest.cs +++ b/tests/NexusMods.EventSourcing.Tests/AEventStoreTest.cs @@ -16,20 +16,20 @@ public AEventStoreTest(T store) [Fact] public void CanGetAndReturnEvents() { - var evt = CreateLoadout.Create("Test"); - Store.Add(evt); + var enityId = EntityId.NewId(); + Store.Add(new CreateLoadout(enityId, "Test")); for (var i = 0; i < 10; i++) { - Store.Add(new RenameLoadout(evt.Id, $"Test {i}")); + Store.Add(new RenameLoadout(enityId, $"Test {i}")); } var accumulator = new EventAccumulator(); - Store.EventsForEntity(evt.Id.Value, accumulator); + Store.EventsForEntity(enityId.Value, accumulator); accumulator.Events.Count.Should().Be(11); - accumulator.Events[0].Should().BeEquivalentTo(evt); + accumulator.Events[0].Should().BeEquivalentTo(new CreateLoadout(enityId, "Test")); for (var i = 1; i < 11; i++) { - accumulator.Events[i].Should().BeEquivalentTo(new RenameLoadout(evt.Id, $"Test {i - 1}")); + accumulator.Events[i].Should().BeEquivalentTo(new RenameLoadout(enityId, $"Test {i - 1}")); } } diff --git a/tests/NexusMods.EventSourcing.Tests/BasicFunctionalityTests.cs b/tests/NexusMods.EventSourcing.Tests/BasicFunctionalityTests.cs index e323770e..0a6965e9 100644 --- a/tests/NexusMods.EventSourcing.Tests/BasicFunctionalityTests.cs +++ b/tests/NexusMods.EventSourcing.Tests/BasicFunctionalityTests.cs @@ -20,9 +20,13 @@ public BasicFunctionalityTests(EventSerializer serializer) [Fact] public void CanSetupBasicLoadout() { - var createEvent = CreateLoadout.Create("Test"); - _ctx.Add(createEvent); - var loadout = _ctx.Get(createEvent.Id); + EntityId loadoutId; + using (var tx = _ctx.Begin()) + { + loadoutId = CreateLoadout.Create(tx, "Test"); + tx.Commit(); + } + var loadout = _ctx.Get(loadoutId); loadout.Should().NotBeNull(); loadout.Name.Should().Be("Test"); } @@ -30,52 +34,59 @@ public void CanSetupBasicLoadout() [Fact] public void ChangingPropertyChangesTheValue() { - var createEvent = CreateLoadout.Create("Test"); - _ctx.Add(createEvent); - var loadout = _ctx.Get(createEvent.Id); + using var tx = _ctx.Begin(); + var loadoutId = CreateLoadout.Create(tx, "Test"); + tx.Commit(); + + var loadout = _ctx.Get(loadoutId); loadout.Name.Should().Be("Test"); - _ctx.Add(new RenameLoadout(createEvent.Id, "New Name")); + using var tx2 = _ctx.Begin(); + _ctx.Add(new RenameLoadout(loadoutId, "New Name")); + tx2.Commit(); + loadout.Name.Should().Be("New Name"); } [Fact] public void CanLinkEntities() { - var loadoutEvent = CreateLoadout.Create("Test"); - _ctx.Add(loadoutEvent); - var loadout = _ctx.Get(loadoutEvent.Id); - loadout.Name.Should().Be("Test"); - - var modEvent = AddMod.Create("First Mod", loadoutEvent.Id); - _ctx.Add(modEvent); + using var tx = _ctx.Begin(); + var loadoutId = CreateLoadout.Create(tx, "Test"); + var modId = AddMod.Create(tx, "First Mod", loadoutId); + tx.Commit(); + var loadout = _ctx.Get(loadoutId); + loadout.Mods.Count().Should().Be(1); loadout.Mods.First().Name.Should().Be("First Mod"); - loadout.Mods.First().Loadout.Should().BeSameAs(loadout); + + var mod = _ctx.Get(modId); + mod.Loadout.Should().NotBeNull(); + mod.Loadout.Name.Should().Be("Test"); } [Fact] public void CanDeleteEntities() { - var loadoutEvent = CreateLoadout.Create("Test"); - _ctx.Add(loadoutEvent); - var loadout = _ctx.Get(loadoutEvent.Id); - loadout.Name.Should().Be("Test"); + using var tx = _ctx.Begin(); + var loadoutId = CreateLoadout.Create(tx, "Test"); + var modId = AddMod.Create(tx, "First Mod", loadoutId); + tx.Commit(); - var modEvent1 = AddMod.Create("First Mod", loadoutEvent.Id); - _ctx.Add(modEvent1); - - var modEvent2 = AddMod.Create("Second Mod", loadoutEvent.Id); - _ctx.Add(modEvent2); - - loadout.Mods.Count().Should().Be(2); + var loadout = _ctx.Get(loadoutId); + loadout.Mods.Count().Should().Be(1); + loadout.Mods.First().Name.Should().Be("First Mod"); - _ctx.Add(new DeleteMod(modEvent1.ModId, loadoutEvent.Id)); + var mod = _ctx.Get(modId); + mod.Loadout.Should().NotBeNull(); + mod.Loadout.Name.Should().Be("Test"); - loadout.Mods.Count().Should().Be(1); + using var tx2 = _ctx.Begin(); + DeleteMod.Create(tx2, modId, loadoutId); + tx2.Commit(); - loadout.Mods.First().Name.Should().Be("Second Mod"); + loadout.Mods.Count().Should().Be(0); } [Fact] @@ -88,44 +99,77 @@ public void CanGetSingletonEntities() [Fact] public void UpdatingAValueCallesNotifyPropertyChanged() { - var createEvent = CreateLoadout.Create("Test"); - _ctx.Add(createEvent); - var loadout = _ctx.Get(createEvent.Id); - loadout.Name.Should().Be("Test"); + var loadout = _ctx.Get(); + loadout.Loadouts.Should().BeEmpty(); var called = false; loadout.PropertyChanged += (sender, args) => { called = true; - args.PropertyName.Should().Be(nameof(Loadout.Name)); + args.PropertyName.Should().Be(nameof(LoadoutRegistry.Loadouts)); }; - _ctx.Add(new RenameLoadout(createEvent.Id, "New Name")); - loadout.Name.Should().Be("New Name"); + using var tx = _ctx.Begin(); + var loadoutId = CreateLoadout.Create(tx, "Test"); + tx.Commit(); + called.Should().BeTrue(); + loadout.Loadouts.Should().NotBeEmpty(); } [Fact] public void EntityCollectionsAreObservable() { - var loadouts = _ctx.Get(); - _ctx.Add(CreateLoadout.Create("Test")); - loadouts.Loadouts.Should().NotBeEmpty(); + using var tx = _ctx.Begin(); + var loadoutId = CreateLoadout.Create(tx, "Test"); + var modId = AddMod.Create(tx, "First Mod", loadoutId); + tx.Commit(); + + var loadout = _ctx.Get(); + loadout.Loadouts.Should().NotBeEmpty(); var called = false; - ((INotifyCollectionChanged)loadouts.Loadouts).CollectionChanged += (sender, args) => + ((INotifyCollectionChanged)loadout.Loadouts).CollectionChanged += (sender, args) => { called = true; args.Action.Should().Be(NotifyCollectionChangedAction.Add); args.NewItems.Should().NotBeNull(); - args.NewItems!.Count.Should().Be(1); - args.NewItems!.OfType().First().Name.Should().Be("Test2"); }; - _ctx.Add(CreateLoadout.Create("Test2")); + using var tx2 = _ctx.Begin(); + var modId2 = AddMod.Create(tx2, "Second Mod", loadoutId); + tx2.Commit(); + called.Should().BeTrue(); + loadout.Loadouts.Should().NotBeEmpty(); + } + + [Fact] + public void CanCreateCyclicDependencies() + { + var loadouts = _ctx.Get(); - loadouts.Loadouts.Count.Should().Be(2); - loadouts.Loadouts.FirstOrDefault(l => l.Name == "Test2").Should().NotBeNull(); + using (var tx = _ctx.Begin()) + { + + var loadoutId = CreateLoadout.Create(tx, "Test"); + + var mod1 =AddMod.Create(tx, "First Mod", loadoutId); + var mod2 = AddMod.Create(tx, "Second Mod", loadoutId); + + var collection = AddCollection.Create(tx, "First Collection", loadoutId, mod1, mod2); + + tx.Commit(); + } + + var loadout = loadouts.Loadouts.First(); + loadout.Collections.Count.Should().Be(1); + loadout.Collections.First().Mods.Count.Should().Be(2); + loadout.Collections.First().Mods.First().Name.Should().Be("First Mod"); + loadout.Collections.First().Name.Should().Be("First Collection"); + loadout.Mods.Count.Should().Be(2); + loadout.Mods.First().Name.Should().Be("First Mod"); + loadout.Mods.Last().Name.Should().Be("Second Mod"); + loadout.Mods.First().Collection.Name.Should().Be("First Collection"); } }