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
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,30 @@ namespace SquidStd.Persistence.Abstractions.Interfaces.Persistence;

/// <summary>
/// In-memory CRUD over a registered persisted entity type. Reads complete synchronously from memory
/// and return detached clones; writes append to the journal then apply to memory.
/// and return detached clones; writes append to the journal then apply to memory. Reads are available
/// in both synchronous and asynchronous forms; the async overloads delegate to the synchronous ones.
/// </summary>
/// <remarks>
/// WARNING: do not register or resolve <see cref="IEntityStore{TEntity,TKey}" /> directly in the DI
/// container - it is intentionally never registered there, and its implementation cannot be constructed
/// by the container. Register the entity with <c>RegisterPersistedEntity</c> and obtain the store from
/// <see cref="IPersistenceService.GetStore{TEntity,TKey}" /> instead.
/// </remarks>
public interface IEntityStore<TEntity, in TKey>
{
/// <summary>Completes synchronously from memory. Returns the number of entities of this type currently held.</summary>
int Count();

ValueTask<int> CountAsync(CancellationToken cancellationToken = default);

/// <summary>Completes synchronously from memory. Returns a detached clone of every entity of this type.</summary>
IReadOnlyCollection<TEntity> GetAll();

ValueTask<IReadOnlyCollection<TEntity>> GetAllAsync(CancellationToken cancellationToken = default);

/// <summary>Completes synchronously from memory. Returns a detached clone of the entity with <paramref name="id"/>, or default when missing.</summary>
TEntity? GetById(TKey id);

ValueTask<TEntity?> GetByIdAsync(TKey id, CancellationToken cancellationToken = default);
IQueryable<TEntity> Query();
ValueTask<bool> RemoveAsync(TKey id, CancellationToken cancellationToken = default);
Expand Down
21 changes: 15 additions & 6 deletions src/SquidStd.Persistence/Services/EntityStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,32 +28,41 @@ IPersistenceEntityDescriptor<TEntity, TKey> descriptor
_descriptor = descriptor;
}

public ValueTask<int> CountAsync(CancellationToken cancellationToken = default)
public int Count()
{
lock (_stateStore.SyncRoot)
{
return ValueTask.FromResult(Bucket().Count);
return Bucket().Count;
}
}

public ValueTask<IReadOnlyCollection<TEntity>> GetAllAsync(CancellationToken cancellationToken = default)
public ValueTask<int> CountAsync(CancellationToken cancellationToken = default)
=> ValueTask.FromResult(Count());

public IReadOnlyCollection<TEntity> GetAll()
{
lock (_stateStore.SyncRoot)
{
IReadOnlyCollection<TEntity> clones = [.. Bucket().Values.Select(_descriptor.Clone)];

return ValueTask.FromResult(clones);
return clones;
}
}

public ValueTask<TEntity?> GetByIdAsync(TKey id, CancellationToken cancellationToken = default)
public ValueTask<IReadOnlyCollection<TEntity>> GetAllAsync(CancellationToken cancellationToken = default)
=> ValueTask.FromResult(GetAll());

public TEntity? GetById(TKey id)
{
lock (_stateStore.SyncRoot)
{
return ValueTask.FromResult(Bucket().TryGetValue(id, out var entity) ? _descriptor.Clone(entity) : default);
return Bucket().TryGetValue(id, out var entity) ? _descriptor.Clone(entity) : default;
}
}

public ValueTask<TEntity?> GetByIdAsync(TKey id, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(GetById(id));

public IQueryable<TEntity> Query()
{
lock (_stateStore.SyncRoot)
Expand Down
43 changes: 43 additions & 0 deletions tests/SquidStd.Tests/Persistence/EntityStoreTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,49 @@ public async Task Query_ReturnsQueryableClones()
Assert.Equal(["Alice"], names);
}

[Fact]
public async Task Count_And_GetAll_Sync_ReflectStateAfterUpserts()
{
await _store.UpsertAsync(new() { Id = 1 });
await _store.UpsertAsync(new() { Id = 2 });

Assert.Equal(2, _store.Count());
Assert.Equal(2, _store.GetAll().Count);
}

[Fact]
public async Task GetById_Sync_ReturnsDetachedClone()
{
await _store.UpsertAsync(new() { Id = 1, Tags = ["a"] });

var first = _store.GetById(1);
first!.Tags.Add("mutated");
var second = _store.GetById(1);

Assert.Equal(["a"], second!.Tags);
}

[Fact]
public void GetById_Sync_MissingKey_ReturnsDefault()
{
Assert.Null(_store.GetById(99));
}

[Fact]
public async Task SyncAndAsyncReads_Agree()
{
await _store.UpsertAsync(new() { Id = 1, Name = "Alice" });
await _store.UpsertAsync(new() { Id = 2, Name = "Bob" });

Assert.Equal(await _store.CountAsync(), _store.Count());
Assert.Equal((await _store.GetAllAsync()).Count, _store.GetAll().Count);

var asyncEntity = await _store.GetByIdAsync(1);
var syncEntity = _store.GetById(1);

Assert.Equal(asyncEntity!.Name, syncEntity!.Name);
}

public async ValueTask DisposeAsync()
{
await _journal.DisposeAsync();
Expand Down
Loading