| | | 1 | | using ArturRios.Data.Relational.Core.Entities; |
| | | 2 | | using ArturRios.Data.Relational.Core.Repositories; |
| | | 3 | | using ArturRios.Output; |
| | | 4 | | |
| | | 5 | | namespace 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> |
| | | 16 | | internal sealed class FakeEntityStore<T, TKey> where T : Entity<TKey> where TKey : IEquatable<TKey> |
| | | 17 | | { |
| | 5 | 18 | | 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> |
| | 126 | 27 | | public FakeEntityStore(Func<TKey>? idGenerator) => _idGenerator = idGenerator ?? DefaultIdGenerator(); |
| | | 28 | | |
| | | 29 | | /// <summary>The stored entities, in insertion order.</summary> |
| | 322 | 30 | | 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. |
| | 7 | 34 | | 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. |
| | 33 | 37 | | public DataOutput<T?> GetById(TKey id) => DataOutput<T?>.New.WithData(Find(id)); |
| | | 38 | | |
| | | 39 | | public DataOutput<TKey> Create(T entity) |
| | | 40 | | { |
| | 62 | 41 | | var error = AssignId(entity, []); |
| | | 42 | | |
| | 62 | 43 | | if (error is not null) |
| | | 44 | | { |
| | 4 | 45 | | return DataOutput<TKey>.New.WithError(error); |
| | | 46 | | } |
| | | 47 | | |
| | 58 | 48 | | Items.Add(entity); |
| | | 49 | | |
| | 58 | 50 | | 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. |
| | 11 | 56 | | var pending = new List<T>(); |
| | 11 | 57 | | var pendingIds = new HashSet<TKey>(KeyComparer); |
| | | 58 | | |
| | 67 | 59 | | foreach (var entity in entities) |
| | | 60 | | { |
| | 23 | 61 | | var error = AssignId(entity, pendingIds); |
| | | 62 | | |
| | 23 | 63 | | if (error is not null) |
| | | 64 | | { |
| | 1 | 65 | | return DataOutput<IEnumerable<TKey>>.New.WithError(error); |
| | | 66 | | } |
| | | 67 | | |
| | 22 | 68 | | pending.Add(entity); |
| | 22 | 69 | | pendingIds.Add(entity.Id); |
| | | 70 | | } |
| | | 71 | | |
| | 10 | 72 | | Items.AddRange(pending); |
| | | 73 | | |
| | 30 | 74 | | return DataOutput<IEnumerable<TKey>>.New.WithData(pending.Select(e => e.Id).ToList()); |
| | 1 | 75 | | } |
| | | 76 | | |
| | | 77 | | public DataOutput<T> Update(T entity) |
| | | 78 | | { |
| | 11 | 79 | | var existingItem = FindWritable(entity); |
| | | 80 | | |
| | 11 | 81 | | if (existingItem is null) |
| | | 82 | | { |
| | 5 | 83 | | return DataOutput<T>.New.WithError(RelationalErrors.ConcurrencyMessage); |
| | | 84 | | } |
| | | 85 | | |
| | 6 | 86 | | Apply(entity, existingItem); |
| | | 87 | | |
| | 6 | 88 | | 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. |
| | 5 | 95 | | var pairs = new List<(T Entity, T Existing)>(); |
| | | 96 | | |
| | 27 | 97 | | foreach (var entity in entities) |
| | | 98 | | { |
| | 10 | 99 | | var existingItem = FindWritable(entity); |
| | | 100 | | |
| | 10 | 101 | | if (existingItem is null) |
| | | 102 | | { |
| | 3 | 103 | | return DataOutput<IEnumerable<T>>.New.WithError(RelationalErrors.ConcurrencyMessage); |
| | | 104 | | } |
| | | 105 | | |
| | 7 | 106 | | pairs.Add((entity, existingItem)); |
| | | 107 | | } |
| | | 108 | | |
| | 12 | 109 | | foreach (var (entity, existingItem) in pairs) |
| | | 110 | | { |
| | 4 | 111 | | Apply(entity, existingItem); |
| | | 112 | | } |
| | | 113 | | |
| | 6 | 114 | | return DataOutput<IEnumerable<T>>.New.WithData(pairs.Select(pair => pair.Existing).ToList()); |
| | 3 | 115 | | } |
| | | 116 | | |
| | | 117 | | public DataOutput<TKey> Delete(T entity) |
| | | 118 | | { |
| | 9 | 119 | | var existingItem = FindWritable(entity); |
| | | 120 | | |
| | 9 | 121 | | if (existingItem is null) |
| | | 122 | | { |
| | 4 | 123 | | return DataOutput<TKey>.New.WithError(RelationalErrors.ConcurrencyMessage); |
| | | 124 | | } |
| | | 125 | | |
| | 5 | 126 | | Items.Remove(existingItem); |
| | | 127 | | |
| | 5 | 128 | | return DataOutput<TKey>.New.WithData(existingItem.Id); |
| | | 129 | | } |
| | | 130 | | |
| | | 131 | | public DataOutput<IEnumerable<TKey>> DeleteRange(IEnumerable<TKey> ids) |
| | | 132 | | { |
| | 4 | 133 | | var idSet = ids.ToHashSet(KeyComparer); |
| | 15 | 134 | | var entities = Items.Where(e => idSet.Contains(e.Id)).ToList(); |
| | | 135 | | |
| | 22 | 136 | | foreach (var entity in entities) |
| | | 137 | | { |
| | 7 | 138 | | Items.Remove(entity); |
| | | 139 | | } |
| | | 140 | | |
| | 11 | 141 | | return DataOutput<IEnumerable<TKey>>.New.WithData(entities.Select(e => e.Id).ToList()); |
| | | 142 | | } |
| | | 143 | | |
| | 230 | 144 | | 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 | | { |
| | 30 | 154 | | var existingItem = Find(entity.Id); |
| | | 155 | | |
| | 30 | 156 | | if (existingItem is IVersionedEntity stored && entity is IVersionedEntity incoming && |
| | 30 | 157 | | stored.ConcurrencyStamp != incoming.ConcurrencyStamp) |
| | | 158 | | { |
| | 3 | 159 | | return null; |
| | | 160 | | } |
| | | 161 | | |
| | 27 | 162 | | 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 | | { |
| | 10 | 171 | | CopyWritableProperties(source, target); |
| | | 172 | | |
| | 10 | 173 | | if (target is not IVersionedEntity versioned) |
| | | 174 | | { |
| | 8 | 175 | | return; |
| | | 176 | | } |
| | | 177 | | |
| | 2 | 178 | | versioned.ConcurrencyStamp = Guid.NewGuid(); |
| | | 179 | | |
| | 2 | 180 | | if (source is IVersionedEntity incoming) |
| | | 181 | | { |
| | 2 | 182 | | incoming.ConcurrencyStamp = versioned.ConcurrencyStamp; |
| | | 183 | | } |
| | 2 | 184 | | } |
| | | 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 | | { |
| | 85 | 193 | | if (_idGenerator is not null) |
| | | 194 | | { |
| | 71 | 195 | | entity.Id = _idGenerator(); |
| | | 196 | | } |
| | 14 | 197 | | else if (entity.Id is null || KeyComparer.Equals(entity.Id, default!)) |
| | | 198 | | { |
| | 2 | 199 | | return $"Entity Id must be assigned before it is created: there is no default id generator for {typeof(TKey) |
| | | 200 | | } |
| | | 201 | | |
| | 83 | 202 | | return Find(entity.Id) is not null || pendingIds.Contains(entity.Id) |
| | 83 | 203 | | ? RelationalErrors.UniqueViolationMessage |
| | 83 | 204 | | : 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 | | { |
| | 59 | 213 | | if (typeof(TKey) == typeof(long)) |
| | | 214 | | { |
| | 45 | 215 | | long next = 0; |
| | | 216 | | |
| | 98 | 217 | | return () => (TKey)(object)++next; |
| | | 218 | | } |
| | | 219 | | |
| | 14 | 220 | | if (typeof(TKey) == typeof(int)) |
| | | 221 | | { |
| | 2 | 222 | | var next = 0; |
| | | 223 | | |
| | 8 | 224 | | return () => (TKey)(object)++next; |
| | | 225 | | } |
| | | 226 | | |
| | 12 | 227 | | if (typeof(TKey) == typeof(Guid)) |
| | | 228 | | { |
| | 7 | 229 | | return () => (TKey)(object)Guid.NewGuid(); |
| | | 230 | | } |
| | | 231 | | |
| | 9 | 232 | | return null; |
| | | 233 | | } |
| | | 234 | | |
| | | 235 | | private static void CopyWritableProperties(T source, T target) |
| | | 236 | | { |
| | 76 | 237 | | foreach (var prop in typeof(T).GetProperties()) |
| | | 238 | | { |
| | 28 | 239 | | if (!prop.CanWrite || prop.Name == nameof(Entity<TKey>.Id)) |
| | | 240 | | { |
| | | 241 | | continue; |
| | | 242 | | } |
| | | 243 | | |
| | 18 | 244 | | prop.SetValue(target, prop.GetValue(source)); |
| | | 245 | | } |
| | 10 | 246 | | } |
| | | 247 | | } |