From 34608121d189764995cef9c877ce0af8efcb1fe2 Mon Sep 17 00:00:00 2001 From: Rasmus Mikkelsen Date: Wed, 25 Oct 2017 17:43:45 +0200 Subject: [PATCH 01/10] Version is now 0.52 --- RELEASE_NOTES.md | 6 +++++- appveyor.yml | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f95a4cfe1..f9baa88a6 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,4 +1,8 @@ -### New in 0.51 (not released yet) +### New in 0.52 (not released yet) + +* _Nothing yet_ + +### New in 0.51.3155 (released 2017-10-25) * New: Removed the `new()` requirement for read models * New: If `ISagaLocator.LocateSagaAsync` cannot identify the saga for a given diff --git a/appveyor.yml b/appveyor.yml index ef589af46..ebfb34bdd 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,7 +1,7 @@ init: - git config --global core.autocrlf input -version: 0.51.{build} +version: 0.52.{build} skip_tags: true From 75c5b6afba7dff68ee05d9830cd335671201fc20 Mon Sep 17 00:00:00 2001 From: Frank Ebersoll Date: Sun, 22 Oct 2017 23:16:14 +0200 Subject: [PATCH 02/10] FilesEventPersistence as singleton, added concurrency tests --- .../ConcurrentFilesEventPersistanceTests.cs | 206 ++++++++++++++++++ .../Files/FilesEventPersistence.cs | 46 ++-- .../EventFlowOptionsEventStoresExtensions.cs | 2 +- 3 files changed, 233 insertions(+), 21 deletions(-) create mode 100644 Source/EventFlow.Tests/UnitTests/EventStores/ConcurrentFilesEventPersistanceTests.cs diff --git a/Source/EventFlow.Tests/UnitTests/EventStores/ConcurrentFilesEventPersistanceTests.cs b/Source/EventFlow.Tests/UnitTests/EventStores/ConcurrentFilesEventPersistanceTests.cs new file mode 100644 index 000000000..266ea50a2 --- /dev/null +++ b/Source/EventFlow.Tests/UnitTests/EventStores/ConcurrentFilesEventPersistanceTests.cs @@ -0,0 +1,206 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2017 Rasmus Mikkelsen +// Copyright (c) 2015-2017 eBay Software Foundation +// https://github.com/eventflow/EventFlow +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using EventFlow.Aggregates; +using EventFlow.Core; +using EventFlow.EventStores; +using EventFlow.EventStores.Files; +using EventFlow.Exceptions; +using EventFlow.Logs; +using EventFlow.TestHelpers.Aggregates; +using EventFlow.TestHelpers.Aggregates.Events; +using EventFlow.TestHelpers.Aggregates.ValueObjects; +using FluentAssertions; +using NUnit.Framework; + +namespace EventFlow.Tests.UnitTests.EventStores +{ + public class ConcurrentFilesEventPersistanceTests + { + private const int DegreeOfParallelism = 10; + private const int NumberOfEventsPerBatch = 10; + + private static readonly ThingyId ThingyId = ThingyId.New; + + private string _storeRootPath; + private EventJsonSerializer _serializer; + + [SetUp] + public void SetUp() + { + var factory = new DomainEventFactory(); + var definitionService = new EventDefinitionService(new NullLog()); + definitionService.Load(typeof(ThingyPingEvent)); + + _serializer = new EventJsonSerializer(new JsonSerializer(), definitionService, factory); + } + + [SetUp] + public void CreateStoreRootDir() + { + _storeRootPath = Path.Combine( + Path.GetTempPath(), + Guid.NewGuid().ToString()); + + Directory.CreateDirectory(_storeRootPath); + } + + [TearDown] + public void DeleteStoreRootDir() + { + Directory.Delete(_storeRootPath, true); + } + + [Test] + public void MultipleInstancesWithSamePathFail() + { + // Arrange + var tasks = RunInParallel(async i => + { + var persistence = CreatePersistence("SameForAll"); + await CommitEvents(persistence); + }); + + // Act + Action action = () => Task.WaitAll(tasks.ToArray()); + + // Assert + action.ShouldThrow("because of concurrent access to the same files."); + } + + [Test] + public void MultipleInstancesWithDifferentPathsWork() + { + // Arrange + var tasks = RunInParallel(async i => + { + var persistence = CreatePersistence(i.ToString()); + await CommitEvents(persistence); + }); + + // Act + Action action = () => Task.WaitAll(tasks.ToArray()); + + // Assert + action.ShouldNotThrow(); + } + + [Test] + public void SingleInstanceWorks() + { + // Arrange + var persistence = CreatePersistence(); + var tasks = RunInParallel(async i => + { + await CommitEvents(persistence); + }); + + // Act + Action action = () => Task.WaitAll(tasks.ToArray()); + + // Assert + action.ShouldNotThrow(); + } + + private IFilesEventStoreConfiguration ConfigurePath(string storePath) + { + var fullPath = Path.Combine(_storeRootPath, storePath); + return FilesEventStoreConfiguration.Create(fullPath); + } + + private FilesEventPersistence CreatePersistence(string storePath = "") + { + var log = new NullLog(); + var serializer = new JsonSerializer(); + var config = ConfigurePath(storePath); + var locator = new FilesEventLocator(config); + return new FilesEventPersistence(log, serializer, config, locator); + } + + private async Task CommitEvents(FilesEventPersistence persistence) + { + var events = Enumerable.Range(0, NumberOfEventsPerBatch) + .Select(i => new ThingyPingEvent(PingId.New)) + .ToArray(); + + await Retry(async () => + { + var version = await GetVersion(persistence); + + var serializedEvents = from aggregateEvent in events + let metadata = new Metadata + { + AggregateSequenceNumber = ++version + } + let serializedEvent = _serializer.Serialize(aggregateEvent, metadata) + select serializedEvent; + + var readOnlyEvents = new ReadOnlyCollection(serializedEvents.ToList()); + + await persistence.CommitEventsAsync(ThingyId, readOnlyEvents, CancellationToken.None); + }); + } + + private static async Task GetVersion(FilesEventPersistence persistence) + { + var existingEvents = await persistence.LoadCommittedEventsAsync( + ThingyId, + 1, + CancellationToken.None); + + int version = existingEvents.LastOrDefault()?.AggregateSequenceNumber ?? 0; + return version; + } + + private static Task[] RunInParallel(Func action) + { + var tasks = Enumerable.Range(1, DegreeOfParallelism) + .AsParallel() + .WithDegreeOfParallelism(DegreeOfParallelism) + .Select(action); + + return tasks.ToArray(); + } + + private static async Task Retry(Func action) + { + for (int retry = 0; retry < DegreeOfParallelism; retry++) + { + try + { + await action(); + return; + } + catch (OptimisticConcurrencyException) + { + } + } + } + } +} \ No newline at end of file diff --git a/Source/EventFlow/EventStores/Files/FilesEventPersistence.cs b/Source/EventFlow/EventStores/Files/FilesEventPersistence.cs index 43545a4ec..1513cd0d6 100644 --- a/Source/EventFlow/EventStores/Files/FilesEventPersistence.cs +++ b/Source/EventFlow/EventStores/Files/FilesEventPersistence.cs @@ -98,16 +98,20 @@ public async Task LoadAllCommittedEvents( ? 1 : int.Parse(globalPosition.Value); - var paths = Enumerable.Range(startPosition, pageSize) - .TakeWhile(g => _eventLog.ContainsKey(g)) - .Select(g => _eventLog[g]) - .ToList(); - var committedDomainEvents = new List(); - foreach (var path in paths) + + using (await _asyncLock.WaitAsync(cancellationToken).ConfigureAwait(false)) { - var committedDomainEvent = await LoadFileEventDataFile(path).ConfigureAwait(false); - committedDomainEvents.Add(committedDomainEvent); + var paths = Enumerable.Range(startPosition, pageSize) + .TakeWhile(g => _eventLog.ContainsKey(g)) + .Select(g => _eventLog[g]) + .ToList(); + + foreach (var path in paths) + { + var committedDomainEvent = await LoadFileEventDataFile(path).ConfigureAwait(false); + committedDomainEvents.Add(committedDomainEvent); + } } var nextPosition = committedDomainEvents.Any() @@ -139,14 +143,14 @@ public async Task> CommitEventsAsync( _eventLog[_globalSequenceNumber] = eventPath; var fileEventData = new FileEventData - { - AggregateId = id.Value, - AggregateSequenceNumber = serializedEvent.AggregateSequenceNumber, - Data = serializedEvent.SerializedData, - Metadata = serializedEvent.SerializedMetadata, - GlobalSequenceNumber = _globalSequenceNumber, - }; - + { + AggregateId = id.Value, + AggregateSequenceNumber = serializedEvent.AggregateSequenceNumber, + Data = serializedEvent.SerializedData, + Metadata = serializedEvent.SerializedMetadata, + GlobalSequenceNumber = _globalSequenceNumber, + }; + var json = _jsonSerializer.Serialize(fileEventData, true); if (File.Exists(eventPath)) @@ -177,7 +181,7 @@ public async Task> CommitEventsAsync( new EventStoreLog { GlobalSequenceNumber = _globalSequenceNumber, - Log = _eventLog, + Log = _eventLog }, true); await streamWriter.WriteAsync(json).ConfigureAwait(false); @@ -209,12 +213,14 @@ public async Task> LoadCommittedEvent } } - public Task DeleteEventsAsync(IIdentity id, CancellationToken cancellationToken) + public async Task DeleteEventsAsync(IIdentity id, CancellationToken cancellationToken) { _log.Verbose("Deleting entity with ID '{0}'", id); var path = _filesEventLocator.GetEntityPath(id); - Directory.Delete(path, true); - return Task.FromResult(0); + using (await _asyncLock.WaitAsync(cancellationToken).ConfigureAwait(false)) + { + Directory.Delete(path, true); + } } private async Task LoadFileEventDataFile(string eventPath) diff --git a/Source/EventFlow/Extensions/EventFlowOptionsEventStoresExtensions.cs b/Source/EventFlow/Extensions/EventFlowOptionsEventStoresExtensions.cs index 1680b7989..01241bcc2 100644 --- a/Source/EventFlow/Extensions/EventFlowOptionsEventStoresExtensions.cs +++ b/Source/EventFlow/Extensions/EventFlowOptionsEventStoresExtensions.cs @@ -53,7 +53,7 @@ public static IEventFlowOptions UseFilesEventStore( return eventFlowOptions.RegisterServices(f => { f.Register(_ => filesEventStoreConfiguration, Lifetime.Singleton); - f.Register(); + f.Register(Lifetime.Singleton); f.Register(); }); } From c8fb4f88d7b2379d6b94bbbee72e68eac60d106f Mon Sep 17 00:00:00 2001 From: Frank Ebersoll Date: Sun, 22 Oct 2017 23:26:19 +0200 Subject: [PATCH 03/10] Updated release notes --- RELEASE_NOTES.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f9baa88a6..8a6f5c951 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,6 +1,8 @@ ### New in 0.52 (not released yet) -* _Nothing yet_ +* Fixed: `.UseFilesEventStore` now uses a thread safe singleton instance for + file system persistence, making it suitable for use in multi-threaded unit + tests. Please don't use the files event store in production scenarios ### New in 0.51.3155 (released 2017-10-25) @@ -10,7 +12,7 @@ the dispatching process. This might be useful in cases where some instances of an event belong to a saga process while others don't * Fixed: `StringExtensions.ToSha256()` can now be safely used from - concurrent threads. + concurrent threads ### New in 0.50.3124 (released 2017-10-21) From 500ac4b86f61f11eb018e3d2174a1fdeb618bd96 Mon Sep 17 00:00:00 2001 From: Frank Ebersoll Date: Sun, 22 Oct 2017 23:59:41 +0200 Subject: [PATCH 04/10] Added .ConfigureAwait(false) to prevent deadlocks --- .../ConcurrentFilesEventPersistanceTests.cs | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/Source/EventFlow.Tests/UnitTests/EventStores/ConcurrentFilesEventPersistanceTests.cs b/Source/EventFlow.Tests/UnitTests/EventStores/ConcurrentFilesEventPersistanceTests.cs index 266ea50a2..2b34b5993 100644 --- a/Source/EventFlow.Tests/UnitTests/EventStores/ConcurrentFilesEventPersistanceTests.cs +++ b/Source/EventFlow.Tests/UnitTests/EventStores/ConcurrentFilesEventPersistanceTests.cs @@ -33,6 +33,7 @@ using EventFlow.EventStores.Files; using EventFlow.Exceptions; using EventFlow.Logs; +using EventFlow.TestHelpers; using EventFlow.TestHelpers.Aggregates; using EventFlow.TestHelpers.Aggregates.Events; using EventFlow.TestHelpers.Aggregates.ValueObjects; @@ -41,6 +42,7 @@ namespace EventFlow.Tests.UnitTests.EventStores { + [Category(Categories.Unit)] public class ConcurrentFilesEventPersistanceTests { private const int DegreeOfParallelism = 10; @@ -84,7 +86,7 @@ public void MultipleInstancesWithSamePathFail() var tasks = RunInParallel(async i => { var persistence = CreatePersistence("SameForAll"); - await CommitEvents(persistence); + await CommitEvents(persistence).ConfigureAwait(false); }); // Act @@ -101,7 +103,7 @@ public void MultipleInstancesWithDifferentPathsWork() var tasks = RunInParallel(async i => { var persistence = CreatePersistence(i.ToString()); - await CommitEvents(persistence); + await CommitEvents(persistence).ConfigureAwait(false); }); // Act @@ -118,7 +120,7 @@ public void SingleInstanceWorks() var persistence = CreatePersistence(); var tasks = RunInParallel(async i => { - await CommitEvents(persistence); + await CommitEvents(persistence).ConfigureAwait(false); }); // Act @@ -151,7 +153,7 @@ private async Task CommitEvents(FilesEventPersistence persistence) await Retry(async () => { - var version = await GetVersion(persistence); + var version = await GetVersion(persistence).ConfigureAwait(false); var serializedEvents = from aggregateEvent in events let metadata = new Metadata @@ -163,8 +165,11 @@ await Retry(async () => var readOnlyEvents = new ReadOnlyCollection(serializedEvents.ToList()); - await persistence.CommitEventsAsync(ThingyId, readOnlyEvents, CancellationToken.None); - }); + await persistence + .CommitEventsAsync(ThingyId, readOnlyEvents, CancellationToken.None) + .ConfigureAwait(false); + }) + .ConfigureAwait(false); } private static async Task GetVersion(FilesEventPersistence persistence) @@ -172,7 +177,7 @@ private static async Task GetVersion(FilesEventPersistence persistence) var existingEvents = await persistence.LoadCommittedEventsAsync( ThingyId, 1, - CancellationToken.None); + CancellationToken.None).ConfigureAwait(false); int version = existingEvents.LastOrDefault()?.AggregateSequenceNumber ?? 0; return version; @@ -194,7 +199,7 @@ private static async Task Retry(Func action) { try { - await action(); + await action().ConfigureAwait(false); return; } catch (OptimisticConcurrencyException) From 2beb6542d806c482307438b4376e63105c2b6206 Mon Sep 17 00:00:00 2001 From: Frank Ebersoll Date: Wed, 25 Oct 2017 19:56:12 +0200 Subject: [PATCH 05/10] Renamed async methods, increased degree of parallelism --- .../ConcurrentFilesEventPersistanceTests.cs | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/Source/EventFlow.Tests/UnitTests/EventStores/ConcurrentFilesEventPersistanceTests.cs b/Source/EventFlow.Tests/UnitTests/EventStores/ConcurrentFilesEventPersistanceTests.cs index 2b34b5993..e412aa84f 100644 --- a/Source/EventFlow.Tests/UnitTests/EventStores/ConcurrentFilesEventPersistanceTests.cs +++ b/Source/EventFlow.Tests/UnitTests/EventStores/ConcurrentFilesEventPersistanceTests.cs @@ -22,6 +22,7 @@ // CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; using System.Linq; @@ -45,9 +46,12 @@ namespace EventFlow.Tests.UnitTests.EventStores [Category(Categories.Unit)] public class ConcurrentFilesEventPersistanceTests { - private const int DegreeOfParallelism = 10; + // Higher values have exponential effect on duration + // due to OptimsticConcurrency and retry + private const int DegreeOfParallelism = 15; private const int NumberOfEventsPerBatch = 10; + // All threads operate on same thingy private static readonly ThingyId ThingyId = ThingyId.New; private string _storeRootPath; @@ -86,7 +90,7 @@ public void MultipleInstancesWithSamePathFail() var tasks = RunInParallel(async i => { var persistence = CreatePersistence("SameForAll"); - await CommitEvents(persistence).ConfigureAwait(false); + await CommitEventsAsync(persistence).ConfigureAwait(false); }); // Act @@ -103,7 +107,7 @@ public void MultipleInstancesWithDifferentPathsWork() var tasks = RunInParallel(async i => { var persistence = CreatePersistence(i.ToString()); - await CommitEvents(persistence).ConfigureAwait(false); + await CommitEventsAsync(persistence).ConfigureAwait(false); }); // Act @@ -120,7 +124,7 @@ public void SingleInstanceWorks() var persistence = CreatePersistence(); var tasks = RunInParallel(async i => { - await CommitEvents(persistence).ConfigureAwait(false); + await CommitEventsAsync(persistence).ConfigureAwait(false); }); // Act @@ -145,15 +149,15 @@ private FilesEventPersistence CreatePersistence(string storePath = "") return new FilesEventPersistence(log, serializer, config, locator); } - private async Task CommitEvents(FilesEventPersistence persistence) + private async Task CommitEventsAsync(FilesEventPersistence persistence) { var events = Enumerable.Range(0, NumberOfEventsPerBatch) .Select(i => new ThingyPingEvent(PingId.New)) - .ToArray(); + .ToList(); - await Retry(async () => + await RetryAsync(async () => { - var version = await GetVersion(persistence).ConfigureAwait(false); + var version = await GetVersionAsync(persistence).ConfigureAwait(false); var serializedEvents = from aggregateEvent in events let metadata = new Metadata @@ -172,7 +176,7 @@ await persistence .ConfigureAwait(false); } - private static async Task GetVersion(FilesEventPersistence persistence) + private static async Task GetVersionAsync(FilesEventPersistence persistence) { var existingEvents = await persistence.LoadCommittedEventsAsync( ThingyId, @@ -183,17 +187,17 @@ private static async Task GetVersion(FilesEventPersistence persistence) return version; } - private static Task[] RunInParallel(Func action) + private static IEnumerable RunInParallel(Func action) { var tasks = Enumerable.Range(1, DegreeOfParallelism) .AsParallel() .WithDegreeOfParallelism(DegreeOfParallelism) .Select(action); - return tasks.ToArray(); + return tasks.ToList(); } - private static async Task Retry(Func action) + private static async Task RetryAsync(Func action) { for (int retry = 0; retry < DegreeOfParallelism; retry++) { From a35bc1de98a3cb8e933c87665e0232806926629e Mon Sep 17 00:00:00 2001 From: Frank Ebersoll Date: Tue, 24 Oct 2017 22:33:17 +0200 Subject: [PATCH 06/10] Added support for unicode type names --- RELEASE_NOTES.md | 4 +- .../IntegrationTests/UnicodeTests.cs | 132 ++++++++++++++++++ Source/EventFlow/Core/Identity.cs | 2 +- .../VersionedTypeDefinitionService.cs | 2 +- 4 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 Source/EventFlow.Tests/IntegrationTests/UnicodeTests.cs diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f9baa88a6..f3cb62dd6 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,6 +1,8 @@ ### New in 0.52 (not released yet) -* _Nothing yet_ +* New: Support for unicode characters in type names. This simplifies using an + [ubiquitous language](http://www.jamesshore.com/Agile-Book/ubiquitous_language.html) + in non-english domains ### New in 0.51.3155 (released 2017-10-25) diff --git a/Source/EventFlow.Tests/IntegrationTests/UnicodeTests.cs b/Source/EventFlow.Tests/IntegrationTests/UnicodeTests.cs new file mode 100644 index 000000000..c3153b79f --- /dev/null +++ b/Source/EventFlow.Tests/IntegrationTests/UnicodeTests.cs @@ -0,0 +1,132 @@ +// The MIT License (MIT) +// +// Copyright (c) 2015-2017 Rasmus Mikkelsen +// Copyright (c) 2015-2017 eBay Software Foundation +// https://github.com/eventflow/EventFlow +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +using System; +using System.Threading; +using System.Threading.Tasks; +using EventFlow.Aggregates; +using EventFlow.Commands; +using EventFlow.Core; +using EventFlow.EventStores; +using EventFlow.Extensions; +using EventFlow.Logs; +using EventFlow.TestHelpers; +using FluentAssertions; +using NUnit.Framework; + +namespace EventFlow.Tests.IntegrationTests +{ + [Category(Categories.Integration)] + public class UnicodeTests + { + [Test] + public void UnicodeIdentities() + { + // Arrange + Act + var identität = Identität1.New.Value; + + // Assert + identität.Should().StartWith("identität1-"); + } + + [Test] + public void UnicodeCommands() + { + // Arrange + var commandDefinitions = new CommandDefinitionService(new NullLog()); + + // Act + Action action = () => commandDefinitions.Load(typeof(Cömmand)); + + // Assert + action.ShouldNotThrow(); + } + + [Test] + public void UnicodeEvents() + { + // Arrange + var eventDefinitionService = new EventDefinitionService(new NullLog()); + + // Act + Action action = () => eventDefinitionService.Load(typeof(Püng1Event)); + + // Assert + action.ShouldNotThrow(); + } + + [Test] + public void UnicodeIntegration() + { + var resolver = EventFlowOptions.New + .AddEvents(typeof(Püng1Event)) + .AddCommands(typeof(Cömmand)) + .AddCommandHandlers(typeof(CömmandHändler)) + .CreateResolver(); + + var bus = resolver.Resolve(); + bus.Publish(new Cömmand()); + } + + private class Identität1 : Identity + { + public Identität1(string value) : base(value) + { + } + } + + private class Püng1Event : AggregateEvent + { + } + + private class Aggregät : AggregateRoot + { + public Aggregät(Identität1 id) : base(id) + { + } + + public void Püng() + { + this.Emit(new Püng1Event()); + } + + public void Apply(Püng1Event e) { } + } + + private class Cömmand : Command + { + public Cömmand() : base(Identität1.New) + { + } + } + + private class CömmandHändler : CommandHandler + { + public override Task ExecuteAsync(Aggregät aggregate, Cömmand command, CancellationToken cancellationToken) + { + aggregate.Püng(); + return Task.FromResult(true); + } + } + } +} \ No newline at end of file diff --git a/Source/EventFlow/Core/Identity.cs b/Source/EventFlow/Core/Identity.cs index 800c9592b..2c6c99539 100644 --- a/Source/EventFlow/Core/Identity.cs +++ b/Source/EventFlow/Core/Identity.cs @@ -44,7 +44,7 @@ static Identity() var nameReplace = new Regex("Id$"); Name = nameReplace.Replace(typeof(T).Name, string.Empty).ToLowerInvariant(); ValueValidation = new Regex( - @"^[a-z0-9]+\-(?[a-f0-9]{8}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{12})$", + @"^[\p{L}\p{N}]+\-(?[a-f0-9]{8}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{12})$", RegexOptions.Compiled); } diff --git a/Source/EventFlow/Core/VersionedTypes/VersionedTypeDefinitionService.cs b/Source/EventFlow/Core/VersionedTypes/VersionedTypeDefinitionService.cs index 1ab455f19..6eff85007 100644 --- a/Source/EventFlow/Core/VersionedTypes/VersionedTypeDefinitionService.cs +++ b/Source/EventFlow/Core/VersionedTypes/VersionedTypeDefinitionService.cs @@ -38,7 +38,7 @@ public abstract class VersionedTypeDefinitionService[a-zA-Z0-9]+?)(V(?[0-9]+)){0,1}$", + @"^(Old){0,1}(?[\p{L}\p{N}]+?)(V(?[0-9]+)){0,1}$", RegexOptions.Compiled); private readonly object _syncRoot = new object(); From 83619f29887469aa042207c8e210d7833efdd2ba Mon Sep 17 00:00:00 2001 From: Frank Ebersoll Date: Wed, 25 Oct 2017 00:28:28 +0200 Subject: [PATCH 07/10] Updated Identity.ValueValidation to not include upper case letters --- .../IntegrationTests/UnicodeTests.cs | 20 +++++++++++++++++++ Source/EventFlow/Core/Identity.cs | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Source/EventFlow.Tests/IntegrationTests/UnicodeTests.cs b/Source/EventFlow.Tests/IntegrationTests/UnicodeTests.cs index c3153b79f..65a6736f6 100644 --- a/Source/EventFlow.Tests/IntegrationTests/UnicodeTests.cs +++ b/Source/EventFlow.Tests/IntegrationTests/UnicodeTests.cs @@ -39,6 +39,26 @@ namespace EventFlow.Tests.IntegrationTests [Category(Categories.Integration)] public class UnicodeTests { + [Test] + public void UpperCaseIdentityThrows() + { + // Arrange + Act + Action action = () => new Identität1("Identität1-00000000-0000-0000-0000-000000000000"); + + // Assert + action.ShouldThrow(); + } + + [Test] + public void LowerCaseIdentityWorks() + { + // Arrange + Act + var id = new Identität1("identität1-00000000-0000-0000-0000-000000000000"); + + // Assert + id.GetGuid().Should().BeEmpty(); + } + [Test] public void UnicodeIdentities() { diff --git a/Source/EventFlow/Core/Identity.cs b/Source/EventFlow/Core/Identity.cs index 2c6c99539..70ad7aa22 100644 --- a/Source/EventFlow/Core/Identity.cs +++ b/Source/EventFlow/Core/Identity.cs @@ -44,7 +44,7 @@ static Identity() var nameReplace = new Regex("Id$"); Name = nameReplace.Replace(typeof(T).Name, string.Empty).ToLowerInvariant(); ValueValidation = new Regex( - @"^[\p{L}\p{N}]+\-(?[a-f0-9]{8}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{12})$", + @"^[\p{Ll}\p{Lm}\p{Lo}\p{Nd}]+\-(?[a-f0-9]{8}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{12})$", RegexOptions.Compiled); } From 4f46bfd9b40e445c738ff513018fa138d8d39333 Mon Sep 17 00:00:00 2001 From: Frank Ebersoll Date: Wed, 25 Oct 2017 21:38:31 +0200 Subject: [PATCH 08/10] Unicode: Label and CommandPublishMiddleware --- Source/EventFlow.Owin/Middlewares/CommandPublishMiddleware.cs | 2 +- Source/EventFlow/Core/Label.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Source/EventFlow.Owin/Middlewares/CommandPublishMiddleware.cs b/Source/EventFlow.Owin/Middlewares/CommandPublishMiddleware.cs index ab3b51493..64385cfe0 100644 --- a/Source/EventFlow.Owin/Middlewares/CommandPublishMiddleware.cs +++ b/Source/EventFlow.Owin/Middlewares/CommandPublishMiddleware.cs @@ -38,7 +38,7 @@ namespace EventFlow.Owin.Middlewares public class CommandPublishMiddleware : OwinMiddleware { private static readonly Regex CommandPath = new Regex( - @"/*commands/(?[a-z]+)/(?\d+)/{0,1}", + @"/*commands/(?[\p{Ll}\p{Lm}\p{Lo}]+)/(?\d+)/{0,1}", RegexOptions.Compiled | RegexOptions.IgnoreCase); private readonly ILog _log; diff --git a/Source/EventFlow/Core/Label.cs b/Source/EventFlow/Core/Label.cs index 00340acc0..8559c4a2e 100644 --- a/Source/EventFlow/Core/Label.cs +++ b/Source/EventFlow/Core/Label.cs @@ -28,7 +28,7 @@ namespace EventFlow.Core { public class Label { - private static readonly Regex NameValidator = new Regex(@"^[a-z0-9\-]{3,}$", RegexOptions.Compiled); + private static readonly Regex NameValidator = new Regex(@"^[\p{Ll}\p{Lm}\p{Lo}\p{Nd}\-]{3,}$", RegexOptions.Compiled); public static Label Named(string name) => new Label(name.ToLowerInvariant()); From af092fdf2939787840d6d0ca9ef3706bdffab5cc Mon Sep 17 00:00:00 2001 From: Frank Ebersoll Date: Wed, 25 Oct 2017 22:09:27 +0200 Subject: [PATCH 09/10] Fix in number character category --- .../Core/VersionedTypes/VersionedTypeDefinitionService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/EventFlow/Core/VersionedTypes/VersionedTypeDefinitionService.cs b/Source/EventFlow/Core/VersionedTypes/VersionedTypeDefinitionService.cs index 6eff85007..5ecc4c36d 100644 --- a/Source/EventFlow/Core/VersionedTypes/VersionedTypeDefinitionService.cs +++ b/Source/EventFlow/Core/VersionedTypes/VersionedTypeDefinitionService.cs @@ -38,7 +38,7 @@ public abstract class VersionedTypeDefinitionService[\p{L}\p{N}]+?)(V(?[0-9]+)){0,1}$", + @"^(Old){0,1}(?[\p{L}\p{Nd}]+?)(V(?[0-9]+)){0,1}$", RegexOptions.Compiled); private readonly object _syncRoot = new object(); From e601612ee1f95fbeb1059520c8c8229620a223e8 Mon Sep 17 00:00:00 2001 From: Frank Ebersoll Date: Wed, 1 Nov 2017 20:43:50 +0100 Subject: [PATCH 10/10] Identity prefix validation with dash --- RELEASE_NOTES.md | 2 ++ .../EventFlow.Tests/UnitTests/Core/IdentityTests.cs | 1 + Source/EventFlow/Core/Identity.cs | 12 ++++++------ 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 88ef30be4..6b1196054 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -6,6 +6,8 @@ * New: Support for unicode characters in type names. This simplifies using an [ubiquitous language](http://www.jamesshore.com/Agile-Book/ubiquitous_language.html) in non-english domains +* Fixed: Include hyphen in prefix validation for identity values. This fixes a bug + where invalid identities could be created (e.g. `ThingyId.With("thingyINVALID-a41e...")`) ### New in 0.51.3155 (released 2017-10-25) diff --git a/Source/EventFlow.Tests/UnitTests/Core/IdentityTests.cs b/Source/EventFlow.Tests/UnitTests/Core/IdentityTests.cs index 1127a29b7..014aae993 100644 --- a/Source/EventFlow.Tests/UnitTests/Core/IdentityTests.cs +++ b/Source/EventFlow.Tests/UnitTests/Core/IdentityTests.cs @@ -118,6 +118,7 @@ public void NewDeterministic_IsValid() } [TestCase("da7ab6b1-c513-581f-a1a0-7cdf17109deb")] + [TestCase("thingyid-da7ab6b1-c513-581f-a1a0-7cdf17109deb")] [TestCase("thingy-769077C6-F84D-46E3-AD2E-828A576AAAF3")] [TestCase("thingy-pppppppp-pppp-pppp-pppp-pppppppppppp")] [TestCase("funny-da7ab6b1-c513-581f-a1a0-7cdf17109deb")] diff --git a/Source/EventFlow/Core/Identity.cs b/Source/EventFlow/Core/Identity.cs index 70ad7aa22..eb5a28b0c 100644 --- a/Source/EventFlow/Core/Identity.cs +++ b/Source/EventFlow/Core/Identity.cs @@ -35,16 +35,16 @@ public abstract class Identity : SingleValueObject, IIdentity where T : Identity { // ReSharper disable StaticMemberInGenericType - private static readonly string Name; + private static readonly string NameWithDash; private static readonly Regex ValueValidation; // ReSharper enable StaticMemberInGenericType static Identity() { var nameReplace = new Regex("Id$"); - Name = nameReplace.Replace(typeof(T).Name, string.Empty).ToLowerInvariant(); + NameWithDash = nameReplace.Replace(typeof(T).Name, string.Empty).ToLowerInvariant() + "-"; ValueValidation = new Regex( - @"^[\p{Ll}\p{Lm}\p{Lo}\p{Nd}]+\-(?[a-f0-9]{8}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{12})$", + @"^[^\-]+\-(?[a-f0-9]{8}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{4}\-[a-f0-9]{12})$", RegexOptions.Compiled); } @@ -86,7 +86,7 @@ public static T With(string value) public static T With(Guid guid) { - var value = $"{Name}-{guid:D}"; + var value = $"{NameWithDash}{guid:D}"; return With(value); } @@ -105,8 +105,8 @@ public static IEnumerable Validate(string value) if (!string.Equals(value.Trim(), value, StringComparison.OrdinalIgnoreCase)) yield return $"Identity '{value}' of type '{typeof(T).PrettyPrint()}' contains leading and/or traling spaces"; - if (!value.StartsWith(Name)) - yield return $"Identity '{value}' of type '{typeof(T).PrettyPrint()}' does not start with '{Name}'"; + if (!value.StartsWith(NameWithDash)) + yield return $"Identity '{value}' of type '{typeof(T).PrettyPrint()}' does not start with '{NameWithDash}'"; if (!ValueValidation.IsMatch(value)) yield return $"Identity '{value}' of type '{typeof(T).PrettyPrint()}' does not follow the syntax '[NAME]-[GUID]' in lower case"; }