diff --git a/src/EventStore.Core.XUnit.Tests/Services/Storage/Indexing/InMemoryIndexDefinitionStoreTests.cs b/src/EventStore.Core.XUnit.Tests/Services/Storage/Indexing/InMemoryIndexDefinitionStoreTests.cs new file mode 100644 index 000000000..a2b7bd6f6 --- /dev/null +++ b/src/EventStore.Core.XUnit.Tests/Services/Storage/Indexing/InMemoryIndexDefinitionStoreTests.cs @@ -0,0 +1,225 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using EventStore.Core.Services.Storage.Indexing; +using Xunit; + +namespace EventStore.Core.XUnit.Tests.Services.Storage.Indexing; + +public class InMemoryIndexDefinitionStoreTests +{ + [Fact] + public async Task read_returns_null_when_missing() + { + var store = new InMemoryIndexDefinitionStore(); + + var definition = await store.Read(new IndexName("orders"), CancellationToken.None); + + Assert.Null(definition); + } + + [Fact] + public async Task create_stores_definition() + { + var store = new InMemoryIndexDefinitionStore(); + var name = new IndexName("orders"); + var definition = CreateDefinition("event.type == 'order'"); + + var result = await store.Create(name, definition, CancellationToken.None); + var stored = await store.Read(name, CancellationToken.None); + + Assert.Equal(IndexDefinitionCreateResult.Created, result); + Assert.Equal(new StoredIndexDefinition(name, definition), stored); + } + + [Fact] + public async Task create_is_idempotent_for_same_definition() + { + var store = new InMemoryIndexDefinitionStore(); + var name = new IndexName("orders"); + var definition = CreateDefinition("event.type == 'order'"); + + await store.Create(name, definition, CancellationToken.None); + var result = await store.Create(name, definition, CancellationToken.None); + var stored = await store.Read(name, CancellationToken.None); + + Assert.Equal(IndexDefinitionCreateResult.AlreadyExists, result); + Assert.Equal(definition, stored.Definition); + } + + [Fact] + public async Task create_is_idempotent_for_equivalent_definition() + { + var store = new InMemoryIndexDefinitionStore(); + var name = new IndexName("orders"); + var first = CreateDefinition("event.type == 'order'"); + var second = CreateDefinition("event.type == 'order'"); + + await store.Create(name, first, CancellationToken.None); + var result = await store.Create(name, second, CancellationToken.None); + var stored = await store.Read(name, CancellationToken.None); + + Assert.Equal(IndexDefinitionCreateResult.AlreadyExists, result); + Assert.Equal(first, stored.Definition); + } + + [Fact] + public async Task create_reports_conflict_for_different_definition() + { + var store = new InMemoryIndexDefinitionStore(); + var name = new IndexName("orders"); + var first = CreateDefinition("event.type == 'order'"); + var second = CreateDefinition("event.type == 'invoice'"); + + await store.Create(name, first, CancellationToken.None); + var result = await store.Create(name, second, CancellationToken.None); + var stored = await store.Read(name, CancellationToken.None); + + Assert.Equal(IndexDefinitionCreateResult.Conflicts, result); + Assert.Equal(first, stored.Definition); + } + + [Fact] + public async Task list_returns_stored_definitions() + { + var store = new InMemoryIndexDefinitionStore(); + var orders = new StoredIndexDefinition(new IndexName("orders"), CreateDefinition("event.type == 'order'")); + var invoices = new StoredIndexDefinition(new IndexName("invoices"), CreateDefinition("event.type == 'invoice'")); + + await store.Create(orders.Name, orders.Definition, CancellationToken.None); + await store.Create(invoices.Name, invoices.Definition, CancellationToken.None); + var definitions = await store.List(CancellationToken.None); + + Assert.Equal([invoices, orders], definitions); + } + + [Fact] + public async Task list_returns_snapshot() + { + var store = new InMemoryIndexDefinitionStore(); + var orders = new StoredIndexDefinition(new IndexName("orders"), CreateDefinition("event.type == 'order'")); + + await store.Create(orders.Name, orders.Definition, CancellationToken.None); + var first = await store.List(CancellationToken.None); + await store.Delete(orders.Name, CancellationToken.None); + var second = await store.List(CancellationToken.None); + + Assert.Equal([orders], first); + Assert.Empty(second); + } + + [Fact] + public async Task delete_removes_definition() + { + var store = new InMemoryIndexDefinitionStore(); + var name = new IndexName("orders"); + + await store.Create(name, CreateDefinition("event.type == 'order'"), CancellationToken.None); + var deleted = await store.Delete(name, CancellationToken.None); + var stored = await store.Read(name, CancellationToken.None); + + Assert.True(deleted); + Assert.Null(stored); + } + + [Fact] + public async Task delete_returns_false_when_missing() + { + var store = new InMemoryIndexDefinitionStore(); + + var deleted = await store.Delete(new IndexName("orders"), CancellationToken.None); + + Assert.False(deleted); + } + + [Fact] + public async Task create_rejects_missing_name() + { + var store = new InMemoryIndexDefinitionStore(); + + var exception = await Assert.ThrowsAsync(() => + store.Create(null, CreateDefinition("event.type == 'order'"), CancellationToken.None).AsTask()); + + Assert.Equal("name", exception.ParamName); + } + + [Fact] + public async Task create_rejects_missing_definition() + { + var store = new InMemoryIndexDefinitionStore(); + + var exception = await Assert.ThrowsAsync(() => + store.Create(new IndexName("orders"), null, CancellationToken.None).AsTask()); + + Assert.Equal("definition", exception.ParamName); + } + + [Fact] + public async Task read_rejects_missing_name() + { + var store = new InMemoryIndexDefinitionStore(); + + var exception = await Assert.ThrowsAsync(() => + store.Read(null, CancellationToken.None).AsTask()); + + Assert.Equal("name", exception.ParamName); + } + + [Fact] + public async Task delete_rejects_missing_name() + { + var store = new InMemoryIndexDefinitionStore(); + + var exception = await Assert.ThrowsAsync(() => + store.Delete(null, CancellationToken.None).AsTask()); + + Assert.Equal("name", exception.ParamName); + } + + [Fact] + public async Task create_honors_cancelled_token() + { + var store = new InMemoryIndexDefinitionStore(); + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + + await Assert.ThrowsAsync(() => + store.Create(new IndexName("orders"), CreateDefinition("event.type == 'order'"), cancellation.Token).AsTask()); + } + + [Fact] + public async Task read_honors_cancelled_token() + { + var store = new InMemoryIndexDefinitionStore(); + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + + await Assert.ThrowsAsync(() => + store.Read(new IndexName("orders"), cancellation.Token).AsTask()); + } + + [Fact] + public async Task list_honors_cancelled_token() + { + var store = new InMemoryIndexDefinitionStore(); + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + + await Assert.ThrowsAsync(() => + store.List(cancellation.Token).AsTask()); + } + + [Fact] + public async Task delete_honors_cancelled_token() + { + var store = new InMemoryIndexDefinitionStore(); + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + + await Assert.ThrowsAsync(() => + store.Delete(new IndexName("orders"), cancellation.Token).AsTask()); + } + + private static IndexDefinition CreateDefinition(string filter) => + new(new IndexEventFilter(filter), [new IndexFieldDefinition("id", new IndexFieldSelector("event.body.id"))]); +} diff --git a/src/EventStore.Core.XUnit.Tests/Services/Storage/Indexing/IndexDefinitionTests.cs b/src/EventStore.Core.XUnit.Tests/Services/Storage/Indexing/IndexDefinitionTests.cs index f78dfd6f9..d2436670a 100644 --- a/src/EventStore.Core.XUnit.Tests/Services/Storage/Indexing/IndexDefinitionTests.cs +++ b/src/EventStore.Core.XUnit.Tests/Services/Storage/Indexing/IndexDefinitionTests.cs @@ -62,6 +62,33 @@ public void constructor_copies_fields() Assert.Equal([field], definition.Fields); } + [Fact] + public void equivalent_definitions_are_equal() + { + var first = new IndexDefinition( + new IndexEventFilter("event.type == 'order'"), + [new IndexFieldDefinition("customerId", new IndexFieldSelector("event.body.customerId"))]); + var second = new IndexDefinition( + new IndexEventFilter("event.type == 'order'"), + [new IndexFieldDefinition("customerId", new IndexFieldSelector("event.body.customerId"))]); + + Assert.Equal(first, second); + Assert.Equal(first.GetHashCode(), second.GetHashCode()); + } + + [Fact] + public void different_fields_are_not_equal() + { + var first = new IndexDefinition( + new IndexEventFilter("event.type == 'order'"), + [new IndexFieldDefinition("customerId", new IndexFieldSelector("event.body.customerId"))]); + var second = new IndexDefinition( + new IndexEventFilter("event.type == 'order'"), + [new IndexFieldDefinition("tenantId", new IndexFieldSelector("event.body.tenantId"))]); + + Assert.NotEqual(first, second); + } + [Theory] [InlineData(null)] [InlineData("")] diff --git a/src/EventStore.Core.XUnit.Tests/Services/Storage/Indexing/IndexNameTests.cs b/src/EventStore.Core.XUnit.Tests/Services/Storage/Indexing/IndexNameTests.cs new file mode 100644 index 000000000..05f2b91bf --- /dev/null +++ b/src/EventStore.Core.XUnit.Tests/Services/Storage/Indexing/IndexNameTests.cs @@ -0,0 +1,51 @@ +using System; +using EventStore.Core.Services.Storage.Indexing; +using Xunit; + +namespace EventStore.Core.XUnit.Tests.Services.Storage.Indexing; + +public class IndexNameTests +{ + [Theory] + [InlineData("orders")] + [InlineData("orders_by_customer")] + [InlineData("orders-by-customer")] + [InlineData("orders2")] + public void constructor_accepts_valid_name(string value) + { + var name = new IndexName(value); + + Assert.Equal(value, name.Value); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void constructor_rejects_empty_name(string value) + { + var exception = Assert.Throws(() => new IndexName(value)); + + Assert.Equal("value", exception.ParamName); + } + + [Theory] + [InlineData("Orders")] + [InlineData("orders by customer")] + [InlineData("orders.by.customer")] + [InlineData("orders/active")] + public void constructor_rejects_invalid_characters(string value) + { + var exception = Assert.Throws(() => new IndexName(value)); + + Assert.Equal("value", exception.ParamName); + } + + [Fact] + public void to_string_returns_name() + { + var name = new IndexName("orders"); + + Assert.Equal("orders", name.ToString()); + } +} diff --git a/src/EventStore.Core/Services/Storage/Indexing/IIndexDefinitionStore.cs b/src/EventStore.Core/Services/Storage/Indexing/IIndexDefinitionStore.cs new file mode 100644 index 000000000..9fd34cdfb --- /dev/null +++ b/src/EventStore.Core/Services/Storage/Indexing/IIndexDefinitionStore.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +#nullable enable + +namespace EventStore.Core.Services.Storage.Indexing; + +public interface IIndexDefinitionStore +{ + ValueTask Create(IndexName name, IndexDefinition definition, CancellationToken token); + + ValueTask Read(IndexName name, CancellationToken token); + + ValueTask> List(CancellationToken token); + + ValueTask Delete(IndexName name, CancellationToken token); +} + +public enum IndexDefinitionCreateResult +{ + Created, + AlreadyExists, + Conflicts +} + +public sealed record StoredIndexDefinition +{ + public IndexName Name { get; } + + public IndexDefinition Definition { get; } + + public StoredIndexDefinition(IndexName name, IndexDefinition definition) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Definition = definition ?? throw new ArgumentNullException(nameof(definition)); + } +} diff --git a/src/EventStore.Core/Services/Storage/Indexing/InMemoryIndexDefinitionStore.cs b/src/EventStore.Core/Services/Storage/Indexing/InMemoryIndexDefinitionStore.cs new file mode 100644 index 000000000..3a4053e51 --- /dev/null +++ b/src/EventStore.Core/Services/Storage/Indexing/InMemoryIndexDefinitionStore.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +#nullable enable + +namespace EventStore.Core.Services.Storage.Indexing; + +public sealed class InMemoryIndexDefinitionStore : IIndexDefinitionStore +{ + private readonly object _lock = new(); + private readonly Dictionary _definitions = new(StringComparer.Ordinal); + + public ValueTask Create(IndexName name, IndexDefinition definition, CancellationToken token) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(definition); + token.ThrowIfCancellationRequested(); + + lock (_lock) + { + if (!_definitions.TryGetValue(name.Value, out var existing)) + { + _definitions.Add(name.Value, new StoredIndexDefinition(name, definition)); + return ValueTask.FromResult(IndexDefinitionCreateResult.Created); + } + + return ValueTask.FromResult(existing.Definition == definition + ? IndexDefinitionCreateResult.AlreadyExists + : IndexDefinitionCreateResult.Conflicts); + } + } + + public ValueTask Read(IndexName name, CancellationToken token) + { + ArgumentNullException.ThrowIfNull(name); + token.ThrowIfCancellationRequested(); + + lock (_lock) + { + _definitions.TryGetValue(name.Value, out var definition); + return ValueTask.FromResult(definition); + } + } + + public ValueTask> List(CancellationToken token) + { + token.ThrowIfCancellationRequested(); + + lock (_lock) + { + return ValueTask.FromResult>( + _definitions.Values.OrderBy(static definition => definition.Name.Value, StringComparer.Ordinal).ToArray()); + } + } + + public ValueTask Delete(IndexName name, CancellationToken token) + { + ArgumentNullException.ThrowIfNull(name); + token.ThrowIfCancellationRequested(); + + lock (_lock) + { + return ValueTask.FromResult(_definitions.Remove(name.Value)); + } + } +} diff --git a/src/EventStore.Core/Services/Storage/Indexing/IndexDefinition.cs b/src/EventStore.Core/Services/Storage/Indexing/IndexDefinition.cs index ac6301450..80ed66c45 100644 --- a/src/EventStore.Core/Services/Storage/Indexing/IndexDefinition.cs +++ b/src/EventStore.Core/Services/Storage/Indexing/IndexDefinition.cs @@ -27,6 +27,24 @@ public IndexDefinition(IndexEventFilter filter, IReadOnlyList + other is not null + && Equals(Filter, other.Filter) + && Fields.SequenceEqual(other.Fields); + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(Filter); + + foreach (var field in Fields) + { + hash.Add(field); + } + + return hash.ToHashCode(); + } } public sealed record IndexEventFilter diff --git a/src/EventStore.Core/Services/Storage/Indexing/IndexName.cs b/src/EventStore.Core/Services/Storage/Indexing/IndexName.cs new file mode 100644 index 000000000..6aaa4c727 --- /dev/null +++ b/src/EventStore.Core/Services/Storage/Indexing/IndexName.cs @@ -0,0 +1,32 @@ +using System; +using System.Linq; + +namespace EventStore.Core.Services.Storage.Indexing; + +public sealed record IndexName +{ + public string Value { get; } + + public IndexName(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("Index name cannot be empty.", nameof(value)); + } + + if (!value.All(IsValidCharacter)) + { + throw new ArgumentException("Index name can contain only lowercase alphanumeric characters, underscores and dashes.", nameof(value)); + } + + Value = value; + } + + public override string ToString() => Value; + + private static bool IsValidCharacter(char value) => + value is >= 'a' and <= 'z' + or >= '0' and <= '9' + or '_' + or '-'; +}