diff --git a/src/SquidStd.Persistence.Abstractions/Data/PagedResult.cs b/src/SquidStd.Persistence.Abstractions/Data/PagedResult.cs new file mode 100644 index 00000000..ba24a988 --- /dev/null +++ b/src/SquidStd.Persistence.Abstractions/Data/PagedResult.cs @@ -0,0 +1,12 @@ +namespace SquidStd.Persistence.Abstractions.Data; + +/// One page of entities, and the total the filter matched before paging. +/// The entities on this page, as detached clones. +/// +/// How many entities the filter matched, before and — +/// which is what a caller needs to know how many pages exist. Counting it costs nothing: the filter has +/// already run. +/// +/// The offset this page was taken from. +/// The page size asked for. The page may hold fewer at the end of the results. +public sealed record PagedResult(IReadOnlyList Items, int Total, int Skip, int Take); diff --git a/src/SquidStd.Persistence.Abstractions/Interfaces/Persistence/IEntityStore.cs b/src/SquidStd.Persistence.Abstractions/Interfaces/Persistence/IEntityStore.cs index 337ee30a..a327824e 100644 --- a/src/SquidStd.Persistence.Abstractions/Interfaces/Persistence/IEntityStore.cs +++ b/src/SquidStd.Persistence.Abstractions/Interfaces/Persistence/IEntityStore.cs @@ -1,3 +1,5 @@ +using SquidStd.Persistence.Abstractions.Data; + namespace SquidStd.Persistence.Abstractions.Interfaces.Persistence; /// @@ -28,6 +30,34 @@ public interface IEntityStore ValueTask GetByIdAsync(TKey id, CancellationToken cancellationToken = default); IQueryable Query(); + + /// + /// Completes synchronously from memory. Filters, orders and pages under the state lock, cloning only + /// the entities on the returned page — unlike and , whose + /// cost is a deep clone of the whole store on every call. + /// + /// + /// Kept entities, or null for all of them. Runs under the state lock against live, uncloned entities, + /// so it must be a pure, cheap read: no I/O, no locks, no calls back into the store, and it must not + /// mutate its argument — a mutation here would change stored state with no journal entry behind it. + /// + /// + /// The sort key. Required, not optional: paging an unordered bucket is meaningless, because + /// enumeration order is not contractual and shifts as entries come and go, so a page could repeat or + /// drop an entity with nothing in the result admitting it. Ties break on the entity's own key, which + /// requires to be comparable — every realistic key is, and a key that is + /// not throws on the first comparison rather than mis-ordering quietly. + /// + /// Entities to skip. Past the end yields an empty page and the true total. + /// Page size. The last page may hold fewer. + /// Reverses . The tiebreak reverses with it. + PagedResult QueryPaged( + Func? filter, + Func orderBy, + int skip, + int take, + bool descending = false + ); ValueTask RemoveAsync(TKey id, CancellationToken cancellationToken = default); ValueTask UpsertAsync(TEntity entity, CancellationToken cancellationToken = default); } diff --git a/src/SquidStd.Persistence/Services/EntityStore.cs b/src/SquidStd.Persistence/Services/EntityStore.cs index 46e49976..ca718a2c 100644 --- a/src/SquidStd.Persistence/Services/EntityStore.cs +++ b/src/SquidStd.Persistence/Services/EntityStore.cs @@ -71,6 +71,40 @@ public IQueryable Query() } } + public PagedResult QueryPaged( + Func? filter, + Func orderBy, + int skip, + int take, + bool descending = false + ) + { + lock (_stateStore.SyncRoot) + { + // Filtering and ordering run against the live entities, not clones: that is the whole saving. + // Only what survives skip/take is cloned, so the cost is O(bucket) comparisons plus O(page) + // clones instead of O(bucket) clones. + IEnumerable matching = Bucket().Values; + + if (filter is not null) + { + matching = matching.Where(filter); + } + + var matched = matching as IList ?? [.. matching]; + + // The entity key breaks ties. The caller's key is rarely unique, and leaving equal keys to + // Dictionary order is exactly the instability orderBy exists to remove. + var ordered = descending + ? matched.OrderByDescending(orderBy).ThenByDescending(_descriptor.GetKey) + : matched.OrderBy(orderBy).ThenBy(_descriptor.GetKey); + + IReadOnlyList page = [.. ordered.Skip(skip).Take(take).Select(_descriptor.Clone)]; + + return new(page, matched.Count, skip, take); + } + } + public async ValueTask UpsertAsync(TEntity entity, CancellationToken cancellationToken = default) { await _stateStore.WriteLock.WaitAsync(cancellationToken); diff --git a/tests/SquidStd.Tests/Persistence/EntityStorePagedTests.cs b/tests/SquidStd.Tests/Persistence/EntityStorePagedTests.cs new file mode 100644 index 00000000..02a3a443 --- /dev/null +++ b/tests/SquidStd.Tests/Persistence/EntityStorePagedTests.cs @@ -0,0 +1,202 @@ +using SquidStd.Core.Json; +using SquidStd.Persistence.Abstractions.Interfaces.Persistence; +using SquidStd.Persistence.Data; +using SquidStd.Persistence.Internal; +using SquidStd.Persistence.Services; + +namespace SquidStd.Tests.Persistence; + +public sealed class EntityStorePagedTests : IAsyncDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), "squidstd-paged-" + Guid.NewGuid().ToString("N")); + private readonly CountingDescriptor _descriptor; + private readonly BinaryJournalService _journal; + private readonly PersistenceStateStore _stateStore = new(); + private readonly EntityStore _store; + + public EntityStorePagedTests() + { + var serializer = new JsonDataSerializer(); + _descriptor = new( + new PersistenceEntityDescriptor(serializer, serializer, 1, "Player", 1, p => p.Id) + ); + _journal = new(Path.Combine(_dir, "world.journal.bin")); + _store = new(_stateStore, _journal, _descriptor); + } + + private async Task SeedAsync(params (int Id, string Name)[] players) + { + foreach (var (id, name) in players) + { + await _store.UpsertAsync(new() { Id = id, Name = name }); + } + } + + [Fact] + public async Task QueryPaged_OrdersAndPages() + { + await SeedAsync((1, "Carol"), (2, "Alice"), (3, "Bob")); + + var page = _store.QueryPaged(null, p => p.Name, 0, 2); + + Assert.Equal(["Alice", "Bob"], page.Items.Select(p => p.Name)); + Assert.Equal(3, page.Total); + Assert.Equal(0, page.Skip); + Assert.Equal(2, page.Take); + } + + [Fact] + public async Task QueryPaged_Descending_ReversesTheOrder() + { + await SeedAsync((1, "Carol"), (2, "Alice"), (3, "Bob")); + + var page = _store.QueryPaged(null, p => p.Name, 0, 2, true); + + Assert.Equal(["Carol", "Bob"], page.Items.Select(p => p.Name)); + } + + [Fact] + public async Task QueryPaged_Total_CountsTheFilterNotThePage() + { + await SeedAsync((1, "Alice"), (2, "Amy"), (3, "Bob")); + + var page = _store.QueryPaged(p => p.Name.StartsWith('A'), p => p.Name, 0, 1); + + Assert.Single(page.Items); + Assert.Equal(2, page.Total); // Alice and Amy matched; only one is on the page + } + + [Fact] + public async Task QueryPaged_SkipPastTheEnd_IsEmptyButKeepsTheTotal() + { + await SeedAsync((1, "Alice"), (2, "Bob")); + + var page = _store.QueryPaged(null, p => p.Name, 99, 10); + + Assert.Empty(page.Items); + Assert.Equal(2, page.Total); + } + + [Fact] + public async Task QueryPaged_ReturnsDetachedClones() + { + await SeedAsync((1, "Alice")); + + var page = _store.QueryPaged(null, p => p.Name, 0, 10); + page.Items[0].Tags.Add("mutated"); + + var again = _store.QueryPaged(null, p => p.Name, 0, 10); + + Assert.Empty(again.Items[0].Tags); + } + + [Fact] + public async Task QueryPaged_ClonesOnlyThePage() + { + // The entire point of this method. GetAll and Query deep-clone the whole store on every call; if + // this did the same there would be no reason for it to exist, and no other assertion would notice. + await SeedAsync((1, "A"), (2, "B"), (3, "C"), (4, "D"), (5, "E")); + _descriptor.CloneCount = 0; + + var page = _store.QueryPaged(null, p => p.Name, 0, 2); + + Assert.Equal(2, page.Items.Count); + Assert.Equal(2, _descriptor.CloneCount); + } + + [Fact] + public async Task QueryPaged_TiedOrderKeys_AreBrokenByEntityKey() + { + // Two entities sorting equal must not be left to Dictionary order, or page 2 could repeat page 1's + // row and drop another, with nothing in the response admitting it. + await SeedAsync((3, "Same"), (1, "Same"), (2, "Same")); + + var first = _store.QueryPaged(null, p => p.Name, 0, 3); + var second = _store.QueryPaged(null, p => p.Name, 0, 3); + + Assert.Equal([1, 2, 3], first.Items.Select(p => p.Id)); + Assert.Equal(first.Items.Select(p => p.Id), second.Items.Select(p => p.Id)); + } + + [Fact] + public async Task QueryPaged_PagesDoNotOverlapOrSkipWhenKeysTie() + { + await SeedAsync((1, "Same"), (2, "Same"), (3, "Same"), (4, "Same")); + + var first = _store.QueryPaged(null, p => p.Name, 0, 2); + var next = _store.QueryPaged(null, p => p.Name, 2, 2); + + Assert.Equal([1, 2], first.Items.Select(p => p.Id)); + Assert.Equal([3, 4], next.Items.Select(p => p.Id)); + } + + public async ValueTask DisposeAsync() + { + await _journal.DisposeAsync(); + + if (Directory.Exists(_dir)) + { + Directory.Delete(_dir, true); + } + } + + /// Wraps a real descriptor and counts Clone calls, so a test can prove what was cloned. + private sealed class CountingDescriptor : IPersistenceEntityDescriptor + { + private readonly IPersistenceEntityDescriptor _inner; + + public CountingDescriptor(IPersistenceEntityDescriptor inner) + { + _inner = inner; + } + + public int CloneCount { get; set; } + + public ushort TypeId => _inner.TypeId; + + public string TypeName => _inner.TypeName; + + public int SchemaVersion => _inner.SchemaVersion; + + public Type EntityType => _inner.EntityType; + + public Type KeyType => _inner.KeyType; + + public int GetKey(Player entity) + => _inner.GetKey(entity); + + public Player Clone(Player entity) + { + CloneCount++; + + return _inner.Clone(entity); + } + + public byte[] SerializeEntity(Player entity) + => _inner.SerializeEntity(entity); + + public Player DeserializeEntity(byte[] payload) + => _inner.DeserializeEntity(payload); + + public byte[] SerializeBucket(IReadOnlyCollection entities) + => _inner.SerializeBucket(entities); + + public IReadOnlyList DeserializeBucket(byte[] payload) + => _inner.DeserializeBucket(payload); + + public byte[] SerializeKey(int key) + => _inner.SerializeKey(key); + + public int DeserializeKey(byte[] payload) + => _inner.DeserializeKey(payload); + } + + private sealed class Player + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public List Tags { get; set; } = []; + } +}