< Summary

Information
Class: ArturRios.Util.Test.Mock.FakeEntityStore<T1, T2>
Assembly: ArturRios.Util.Test
File(s): /home/runner/work/dotnet-test-util/dotnet-test-util/src/Mock/FakeEntityStore.cs
Line coverage
100%
Covered lines: 79
Uncovered lines: 0
Coverable lines: 79
Total lines: 247
Line coverage: 100%
Branch coverage
96%
Covered branches: 50
Total branches: 52
Branch coverage: 96.1%
Method coverage
100%
Covered methods: 17
Fully covered methods: 17
Total methods: 17
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%22100%
get_Items()100%11100%
GetAll()100%11100%
GetById(...)100%11100%
Create(...)100%22100%
CreateRange(...)100%44100%
Update(...)100%22100%
UpdateRange(...)100%66100%
Delete(...)100%22100%
DeleteRange(...)100%22100%
Find(...)100%11100%
FindWritable(...)100%66100%
Apply(...)75%44100%
AssignId(...)100%1010100%
DefaultIdGenerator()100%66100%
CopyWritableProperties(...)83.33%66100%

File(s)

/home/runner/work/dotnet-test-util/dotnet-test-util/src/Mock/FakeEntityStore.cs

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Entities;
 2using ArturRios.Data.Relational.Core.Repositories;
 3using ArturRios.Output;
 4
 5namespace ArturRios.Util.Test.Mock;
 6
 7/// <summary>
 8/// The in-memory storage and semantics shared by <see cref="FakeRepository{T, TKey}"/> and
 9/// <see cref="AsyncFakeRepository{T, TKey}"/>. Outcomes follow <see cref="EfRepository{T, TKey}"/>: an unknown id is a
 10/// successful lookup with no data, writes that would affect no row (or a row whose <see cref="IVersionedEntity"/>
 11/// stamp has moved on) fail with <see cref="RelationalErrors.ConcurrencyMessage"/> and change nothing, and an id
 12/// that is already stored fails with <see cref="RelationalErrors.UniqueViolationMessage"/>.
 13/// </summary>
 14/// <typeparam name="T">The entity type.</typeparam>
 15/// <typeparam name="TKey">The entity's primary key type.</typeparam>
 16internal sealed class FakeEntityStore<T, TKey> where T : Entity<TKey> where TKey : IEquatable<TKey>
 17{
 518    private static readonly EqualityComparer<TKey> KeyComparer = EqualityComparer<TKey>.Default;
 19
 20    private readonly Func<TKey>? _idGenerator;
 21
 22    /// <summary>Creates a store that assigns identifiers with <paramref name="idGenerator"/>.</summary>
 23    /// <param name="idGenerator">
 24    /// The identifier generator, or <c>null</c> to use the default for <typeparamref name="TKey"/>
 25    /// (see <see cref="DefaultIdGenerator"/>).
 26    /// </param>
 12627    public FakeEntityStore(Func<TKey>? idGenerator) => _idGenerator = idGenerator ?? DefaultIdGenerator();
 28
 29    /// <summary>The stored entities, in insertion order.</summary>
 32230    public List<T> Items { get; } = [];
 31
 32    // A copy, as the real repository materializes a new list: handing out Items itself let a later write change
 33    // a result the caller already holds, and made deleting while enumerating it throw.
 734    public DataOutput<IEnumerable<T>> GetAll() => DataOutput<IEnumerable<T>>.New.WithData(Items.ToList());
 35
 36    // No match is a successful lookup with no data, as the repository contract and EfRepository define it.
 3337    public DataOutput<T?> GetById(TKey id) => DataOutput<T?>.New.WithData(Find(id));
 38
 39    public DataOutput<TKey> Create(T entity)
 40    {
 6241        var error = AssignId(entity, []);
 42
 6243        if (error is not null)
 44        {
 445            return DataOutput<TKey>.New.WithError(error);
 46        }
 47
 5848        Items.Add(entity);
 49
 5850        return DataOutput<TKey>.New.WithData(entity.Id);
 51    }
 52
 53    public DataOutput<IEnumerable<TKey>> CreateRange(IEnumerable<T> entities)
 54    {
 55        // Assign and validate every id before storing anything, so a rejected range leaves the store untouched.
 1156        var pending = new List<T>();
 1157        var pendingIds = new HashSet<TKey>(KeyComparer);
 58
 6759        foreach (var entity in entities)
 60        {
 2361            var error = AssignId(entity, pendingIds);
 62
 2363            if (error is not null)
 64            {
 165                return DataOutput<IEnumerable<TKey>>.New.WithError(error);
 66            }
 67
 2268            pending.Add(entity);
 2269            pendingIds.Add(entity.Id);
 70        }
 71
 1072        Items.AddRange(pending);
 73
 3074        return DataOutput<IEnumerable<TKey>>.New.WithData(pending.Select(e => e.Id).ToList());
 175    }
 76
 77    public DataOutput<T> Update(T entity)
 78    {
 1179        var existingItem = FindWritable(entity);
 80
 1181        if (existingItem is null)
 82        {
 583            return DataOutput<T>.New.WithError(RelationalErrors.ConcurrencyMessage);
 84        }
 85
 686        Apply(entity, existingItem);
 87
 688        return DataOutput<T>.New.WithData(existingItem);
 89    }
 90
 91    public DataOutput<IEnumerable<T>> UpdateRange(IEnumerable<T> entities)
 92    {
 93        // Check every entity before changing any, so a range with an unknown or stale entity fails as a whole and
 94        // leaves the store untouched, as the real repository's single SaveChanges does.
 595        var pairs = new List<(T Entity, T Existing)>();
 96
 2797        foreach (var entity in entities)
 98        {
 1099            var existingItem = FindWritable(entity);
 100
 10101            if (existingItem is null)
 102            {
 3103                return DataOutput<IEnumerable<T>>.New.WithError(RelationalErrors.ConcurrencyMessage);
 104            }
 105
 7106            pairs.Add((entity, existingItem));
 107        }
 108
 12109        foreach (var (entity, existingItem) in pairs)
 110        {
 4111            Apply(entity, existingItem);
 112        }
 113
 6114        return DataOutput<IEnumerable<T>>.New.WithData(pairs.Select(pair => pair.Existing).ToList());
 3115    }
 116
 117    public DataOutput<TKey> Delete(T entity)
 118    {
 9119        var existingItem = FindWritable(entity);
 120
 9121        if (existingItem is null)
 122        {
 4123            return DataOutput<TKey>.New.WithError(RelationalErrors.ConcurrencyMessage);
 124        }
 125
 5126        Items.Remove(existingItem);
 127
 5128        return DataOutput<TKey>.New.WithData(existingItem.Id);
 129    }
 130
 131    public DataOutput<IEnumerable<TKey>> DeleteRange(IEnumerable<TKey> ids)
 132    {
 4133        var idSet = ids.ToHashSet(KeyComparer);
 15134        var entities = Items.Where(e => idSet.Contains(e.Id)).ToList();
 135
 22136        foreach (var entity in entities)
 137        {
 7138            Items.Remove(entity);
 139        }
 140
 11141        return DataOutput<IEnumerable<TKey>>.New.WithData(entities.Select(e => e.Id).ToList());
 142    }
 143
 230144    private T? Find(TKey id) => Items.FirstOrDefault(item => KeyComparer.Equals(item.Id, id));
 145
 146    /// <summary>
 147    /// The stored entity <paramref name="entity"/> may update or delete: the one with its id, provided that, for an
 148    /// <see cref="IVersionedEntity"/>, it carries the stored <see cref="IVersionedEntity.ConcurrencyStamp"/>. The real
 149    /// repository's UPDATE/DELETE matches on the id and the stamp, and affecting no row is a concurrency conflict.
 150    /// </summary>
 151    /// <returns>The stored entity, or <c>null</c> when the write would affect no row.</returns>
 152    private T? FindWritable(T entity)
 153    {
 30154        var existingItem = Find(entity.Id);
 155
 30156        if (existingItem is IVersionedEntity stored && entity is IVersionedEntity incoming &&
 30157            stored.ConcurrencyStamp != incoming.ConcurrencyStamp)
 158        {
 3159            return null;
 160        }
 161
 27162        return existingItem;
 163    }
 164
 165    /// <summary>
 166    /// Copies <paramref name="source"/> onto the stored <paramref name="target"/> and, for an
 167    /// <see cref="IVersionedEntity"/>, issues a new stamp on both, as the real context does on every update.
 168    /// </summary>
 169    private static void Apply(T source, T target)
 170    {
 10171        CopyWritableProperties(source, target);
 172
 10173        if (target is not IVersionedEntity versioned)
 174        {
 8175            return;
 176        }
 177
 2178        versioned.ConcurrencyStamp = Guid.NewGuid();
 179
 2180        if (source is IVersionedEntity incoming)
 181        {
 2182            incoming.ConcurrencyStamp = versioned.ConcurrencyStamp;
 183        }
 2184    }
 185
 186    /// <summary>
 187    /// Gives <paramref name="entity"/> its identifier: a generated one when the store has a generator (overwriting
 188    /// any value the caller set), otherwise the caller-assigned one, which must be set and not yet stored.
 189    /// </summary>
 190    /// <returns><c>null</c> on success, otherwise the error message.</returns>
 191    private string? AssignId(T entity, HashSet<TKey> pendingIds)
 192    {
 85193        if (_idGenerator is not null)
 194        {
 71195            entity.Id = _idGenerator();
 196        }
 14197        else if (entity.Id is null || KeyComparer.Equals(entity.Id, default!))
 198        {
 2199            return $"Entity Id must be assigned before it is created: there is no default id generator for {typeof(TKey)
 200        }
 201
 83202        return Find(entity.Id) is not null || pendingIds.Contains(entity.Id)
 83203            ? RelationalErrors.UniqueViolationMessage
 83204            : null;
 205    }
 206
 207    /// <summary>
 208    /// The generator used when none is supplied: sequential from <c>1</c> for <see cref="long"/> and <see cref="int"/>
 209    /// keys, <see cref="Guid.NewGuid"/> for <see cref="Guid"/> keys, and none for any other key type.
 210    /// </summary>
 211    private static Func<TKey>? DefaultIdGenerator()
 212    {
 59213        if (typeof(TKey) == typeof(long))
 214        {
 45215            long next = 0;
 216
 98217            return () => (TKey)(object)++next;
 218        }
 219
 14220        if (typeof(TKey) == typeof(int))
 221        {
 2222            var next = 0;
 223
 8224            return () => (TKey)(object)++next;
 225        }
 226
 12227        if (typeof(TKey) == typeof(Guid))
 228        {
 7229            return () => (TKey)(object)Guid.NewGuid();
 230        }
 231
 9232        return null;
 233    }
 234
 235    private static void CopyWritableProperties(T source, T target)
 236    {
 76237        foreach (var prop in typeof(T).GetProperties())
 238        {
 28239            if (!prop.CanWrite || prop.Name == nameof(Entity<TKey>.Id))
 240            {
 241                continue;
 242            }
 243
 18244            prop.SetValue(target, prop.GetValue(source));
 245        }
 10246    }
 247}