Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/SquidStd.Persistence.Abstractions/Data/PagedResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace SquidStd.Persistence.Abstractions.Data;

/// <summary>One page of entities, and the total the filter matched before paging.</summary>
/// <param name="Items">The entities on this page, as detached clones.</param>
/// <param name="Total">
/// How many entities the filter matched, before <paramref name="Skip" /> and <paramref name="Take" /> —
/// which is what a caller needs to know how many pages exist. Counting it costs nothing: the filter has
/// already run.
/// </param>
/// <param name="Skip">The offset this page was taken from.</param>
/// <param name="Take">The page size asked for. The page may hold fewer at the end of the results.</param>
public sealed record PagedResult<TEntity>(IReadOnlyList<TEntity> Items, int Total, int Skip, int Take);
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using SquidStd.Persistence.Abstractions.Data;

namespace SquidStd.Persistence.Abstractions.Interfaces.Persistence;

/// <summary>
Expand Down Expand Up @@ -28,6 +30,34 @@ public interface IEntityStore<TEntity, in TKey>

ValueTask<TEntity?> GetByIdAsync(TKey id, CancellationToken cancellationToken = default);
IQueryable<TEntity> Query();

/// <summary>
/// Completes synchronously from memory. Filters, orders and pages under the state lock, cloning only
/// the entities on the returned page — unlike <see cref="GetAll" /> and <see cref="Query" />, whose
/// cost is a deep clone of the whole store on every call.
/// </summary>
/// <param name="filter">
/// 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.
/// </param>
/// <param name="orderBy">
/// 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 <typeparamref name="TKey" /> to be comparable — every realistic key is, and a key that is
/// not throws on the first comparison rather than mis-ordering quietly.
/// </param>
/// <param name="skip">Entities to skip. Past the end yields an empty page and the true total.</param>
/// <param name="take">Page size. The last page may hold fewer.</param>
/// <param name="descending">Reverses <paramref name="orderBy" />. The tiebreak reverses with it.</param>
PagedResult<TEntity> QueryPaged<TOrder>(
Func<TEntity, bool>? filter,
Func<TEntity, TOrder> orderBy,
int skip,
int take,
bool descending = false
);
ValueTask<bool> RemoveAsync(TKey id, CancellationToken cancellationToken = default);
ValueTask UpsertAsync(TEntity entity, CancellationToken cancellationToken = default);
}
34 changes: 34 additions & 0 deletions src/SquidStd.Persistence/Services/EntityStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,40 @@ public IQueryable<TEntity> Query()
}
}

public PagedResult<TEntity> QueryPaged<TOrder>(
Func<TEntity, bool>? filter,
Func<TEntity, TOrder> 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<TEntity> matching = Bucket().Values;

if (filter is not null)
{
matching = matching.Where(filter);
}

var matched = matching as IList<TEntity> ?? [.. 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<TEntity> 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);
Expand Down
202 changes: 202 additions & 0 deletions tests/SquidStd.Tests/Persistence/EntityStorePagedTests.cs
Original file line number Diff line number Diff line change
@@ -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"));

Check notice

Code scanning / CodeQL

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments Note test

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments.
private readonly CountingDescriptor _descriptor;
private readonly BinaryJournalService _journal;
private readonly PersistenceStateStore _stateStore = new();
private readonly EntityStore<Player, int> _store;

public EntityStorePagedTests()
{
var serializer = new JsonDataSerializer();
_descriptor = new(
new PersistenceEntityDescriptor<Player, int>(serializer, serializer, 1, "Player", 1, p => p.Id)
);
_journal = new(Path.Combine(_dir, "world.journal.bin"));

Check notice

Code scanning / CodeQL

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments Note test

Call to 'System.IO.Path.Combine' may silently drop its earlier arguments.
_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);
}
}

/// <summary>Wraps a real descriptor and counts Clone calls, so a test can prove what was cloned.</summary>
private sealed class CountingDescriptor : IPersistenceEntityDescriptor<Player, int>
{
private readonly IPersistenceEntityDescriptor<Player, int> _inner;

public CountingDescriptor(IPersistenceEntityDescriptor<Player, int> 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<Player> entities)
=> _inner.SerializeBucket(entities);

public IReadOnlyList<Player> 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<string> Tags { get; set; } = [];
}
}
Loading