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
9 changes: 8 additions & 1 deletion src/Core/Grand.Data/Audit/IAuditRecordReader.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
namespace Grand.Data.Audit;

/// <summary>
/// Reads one stored record as an audit snapshot, to name the record a changed field refers to
/// Reads stored records as audit snapshots, to name the records changed fields refer to
/// </summary>
public interface IAuditRecordReader
{
Expand All @@ -11,4 +11,11 @@ public interface IAuditRecordReader
/// the snapshot may hold only that top-level field.
/// </summary>
Task<IDictionary<string, string>> Read(Type entityType, string value, string key = null, string field = null);

/// <summary>
/// The records whose <paramref name="key" /> is one of <paramref name="values" />, in one read, by that value; a
/// value without a record is left out. Otherwise as <see cref="Read" />.
/// </summary>
Task<IDictionary<string, IDictionary<string, string>>> ReadMany(Type entityType, IReadOnlyCollection<string> values,
string key = null, string field = null);
}
9 changes: 7 additions & 2 deletions src/Core/Grand.Data/Audit/RepositoryAudit.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,14 @@ public sealed class RepositoryAudit<T>(IAuditTracker tracker, Func<IReadOnlyColl
{
private bool Tracking => tracker.IsActive && tracker.IsAudited(typeof(T));

public Task Inserted(T entity)
public async Task Inserted(T entity)
{
return Tracking ? tracker.BeforeWriteAsync(entity.Id, true, loadMany) : Task.CompletedTask;
if (!Tracking)
return;

await tracker.BeforeWriteAsync(entity.Id, true, loadMany);
//the inserted entity is what was stored, so the flush does not read it back
tracker.AfterFullUpdate(entity);
}

/// <summary>
Expand Down
11 changes: 11 additions & 0 deletions src/Core/Grand.Data/LiteDb/LiteDBAuditRecordReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,15 @@ public Task<IDictionary<string, string>> Read(Type entityType, string value, str
var entity = document == null ? null : db.Mapper.ToObject(entityType, document);
return Task.FromResult(entity == null ? null : AuditSnapshot.Take(entity, entityType));
}

//an embedded database has no round trip to save, so each record is read on its own
public async Task<IDictionary<string, IDictionary<string, string>>> ReadMany(Type entityType,
IReadOnlyCollection<string> values, string key = null, string field = null)
{
var records = new Dictionary<string, IDictionary<string, string>>();
foreach (var value in values.Distinct())
if (await Read(entityType, value, key, field) is { } record)
records[value] = record;
Comment on lines +31 to +33
return records;
}
}
30 changes: 27 additions & 3 deletions src/Core/Grand.Data/Mongo/MongoAuditRecordReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,36 @@ public class MongoAuditRecordReader(Func<IMongoDatabase> database) : IAuditRecor
public async Task<IDictionary<string, string>> Read(Type entityType, string value, string key = null,
string field = null)
{
//a key other than the id may hold a number, stored as one
BsonValue match = key != null && int.TryParse(value, out var number) ? number : value;
var find = database().GetCollection<BsonDocument>(entityType.Name).Find(new BsonDocument(key ?? "_id", match));
var find = database().GetCollection<BsonDocument>(entityType.Name)
.Find(new BsonDocument(key ?? "_id", Match(value, key)));
var document = field == null
? await find.FirstOrDefaultAsync()
: await find.Project(Builders<BsonDocument>.Projection.Include(field)).FirstOrDefaultAsync();
return AuditSnapshot.Flatten(document, entityType == typeof(Setting));
}

public async Task<IDictionary<string, IDictionary<string, string>>> ReadMany(Type entityType,
IReadOnlyCollection<string> values, string key = null, string field = null)
{
var name = key ?? "_id";
var find = database().GetCollection<BsonDocument>(entityType.Name)
.Find(Builders<BsonDocument>.Filter.In(name, values.Select(v => Match(v, key))));
//the key stays in the projection: it tells which value a record answers (the id always does)
var documents = field == null
? await find.ToListAsync()
: await find.Project(key == null || key == field
? Builders<BsonDocument>.Projection.Include(field)
: Builders<BsonDocument>.Projection.Include(field).Include(key)).ToListAsync();

var records = new Dictionary<string, IDictionary<string, string>>();
foreach (var document in documents)
records.TryAdd(document[name].ToString()!, AuditSnapshot.Flatten(document, entityType == typeof(Setting)));
return records;
}

//a key other than the id may hold a number, stored as one
private static BsonValue Match(string value, string key)
{
return key != null && int.TryParse(value, out var number) ? number : value;
}
}
122 changes: 89 additions & 33 deletions src/Core/Grand.Infrastructure/Audit/AuditReferences.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ public partial class AuditReferences(IAuditRecordReader reader)
private static readonly ConcurrentDictionary<(Type, string), PropertyInfo> Properties = new();
private static readonly ConcurrentDictionary<Type, PropertyInfo> Owners = new();

private readonly Dictionary<(Type Target, string List, string Key, string Owner, string Id), string> _names = new();
private readonly Dictionary<(Type Target, string Id), string> _ownerStores = new();
//the records read for these entries, null when there is none
private readonly Dictionary<RecordKey, IDictionary<string, string>> _records = new();

//set while Prefetch walks the entries: the records they need are noted instead of read
private HashSet<RecordKey> _wanted;

[GeneratedRegex(@"\[[^\]]*\]")]
private static partial Regex ElementKeys();
Expand All @@ -32,6 +35,56 @@ public static string NameProperty(Type type)
t.GetProperties().FirstOrDefault(p => p.IsDefined(typeof(AuditNameAttribute)))?.Name ?? "Name");
}

/// <summary>
/// Reads every record the entries refer to with one query per kind of record instead of one per id, so
/// describing them afterwards reads nothing. A record that could not be read this way is read when needed.
/// </summary>
public async Task Prefetch(
IEnumerable<(Type Type, AuditEntry Entry, IDictionary<string, string> Before, IDictionary<string, string> After)> entries)
{
var wanted = _wanted = [];
try
{
foreach (var (type, entry, before, after) in entries)
try
{
await DescribeEntry(type, entry, before, after);
}
catch (Exception)
{
//fails again when described
}
Comment on lines +53 to +56
}
finally
{
_wanted = null;
}

foreach (var group in wanted.Where(k => !_records.ContainsKey(k)).GroupBy(k => (k.Target, k.Key, k.Field)))
try
{
var read = await reader.ReadMany(group.Key.Target, group.Select(k => k.Value).ToList(), group.Key.Key,
group.Key.Field);
foreach (var key in group)
_records[key] = read.TryGetValue(key.Value, out var record) ? record : null;
}
catch (Exception)
{
//read one by one when described
}
Comment on lines +71 to +74
}

/// <summary>
/// Names the list elements, the references and the owner of one entry
/// </summary>
public async Task DescribeEntry(Type entityType, AuditEntry entry, IDictionary<string, string> before,
IDictionary<string, string> after)
{
await NameElements(entityType, entry.Changes, before, after);
await Describe(entityType, entry.Changes, before, after);
await LinkOwner(entityType, entry, after ?? before);
}

public async Task Describe(Type entityType, IEnumerable<AuditChange> changes, IDictionary<string, string> before,
IDictionary<string, string> after)
{
Expand All @@ -45,9 +98,12 @@ public async Task Describe(Type entityType, IEnumerable<AuditChange> changes, ID
//a name that cannot be found leaves the ids, never the change out
try
{
var described = AuditDiff.Change(change.Path,
await Describe(property, change.Path, change.OldValue, before),
await Describe(property, change.Path, change.NewValue, after));
var oldValue = await Describe(property, change.Path, change.OldValue, before);
var newValue = await Describe(property, change.Path, change.NewValue, after);
if (_wanted != null)
continue;

var described = AuditDiff.Change(change.Path, oldValue, newValue);
change.OldValue = described.OldValue;
change.NewValue = described.NewValue;
change.Truncated = described.Truncated;
Expand Down Expand Up @@ -93,7 +149,7 @@ public async Task NameElements(Type entityType, IEnumerable<AuditChange> changes
type = elementType ?? property.PropertyType;
}

if (names.Any(n => n != ""))
if (_wanted == null && names.Any(n => n != ""))
change.ElementNames = names;
}
catch (Exception)
Expand Down Expand Up @@ -121,7 +177,7 @@ private async Task<string> ElementName(Type elementType, string path, IDictionar
return value;

var owner = reference.Owner == null ? null : SiblingValue(snapshot, field, reference.Owner);
return await NameOrNull(reference, owner, value);
return await NameOf(reference, owner, value);
}

/// <summary>
Expand All @@ -137,22 +193,17 @@ public async Task LinkOwner(Type entityType, AuditEntry entry, IDictionary<strin
return;

var target = owner.GetCustomAttribute<AuditReferenceAttribute>()!.Target;
entry.OwnerType = target.Name;
entry.OwnerId = ownerId;
if (_wanted == null)
(entry.OwnerType, entry.OwnerId) = (target.Name, ownerId);
if (entry.EntityStoreId != null)
return;

//a store that cannot be read leaves the entry without one, never the entry out
try
{
if (!_ownerStores.TryGetValue((target, ownerId), out var storeId))
{
var record = await reader.Read(target, ownerId, null, "StoreId");
_ownerStores[(target, ownerId)] = storeId =
record != null && record.TryGetValue("StoreId", out var stored) ? stored : null;
}

entry.EntityStoreId = storeId;
var record = await Record(target, ownerId, null, "StoreId");
if (_wanted == null)
entry.EntityStoreId = record != null && record.TryGetValue("StoreId", out var stored) ? stored : null;
}
catch (Exception)
{
Expand Down Expand Up @@ -242,23 +293,11 @@ private static string SiblingValue(IDictionary<string, string> snapshot, string

private async Task<string> Label(AuditReferenceAttribute reference, string owner, string id)
{
var name = await NameOrNull(reference, owner, id);
var name = await NameOf(reference, owner, id);
return name == null ? id : $"{name} ({id})";
}

private async Task<string> NameOrNull(AuditReferenceAttribute reference, string owner, string id)
{
var key = (reference.Target, reference.List, reference.Key, owner, id);
if (!_names.TryGetValue(key, out var name))
{
name = await NameOf(reference, owner, id);
_names[key] = name;
}

return string.IsNullOrEmpty(name) ? null : name;
}

//the name in the snapshot of the referenced record, or of the element of its list
//the name in the snapshot of the referenced record, or of the element of its list; null when there is none
private async Task<string> NameOf(AuditReferenceAttribute reference, string owner, string id)
{
var recordId = reference.List == null ? id : owner;
Expand All @@ -267,11 +306,28 @@ private async Task<string> NameOf(AuditReferenceAttribute reference, string owne

//only the field holding the name is read, not the whole record
var record = reference.List == null
? await reader.Read(reference.Target, recordId, reference.Key, NameProperty(reference.Target))
: await reader.Read(reference.Target, recordId, null, reference.List);
? await Record(reference.Target, recordId, reference.Key, NameProperty(reference.Target))
: await Record(reference.Target, recordId, null, reference.List);
var path = reference.List == null
? NameProperty(reference.Target)
: $"{reference.List}[id={id}].{NameProperty(ElementType(reference.Target.GetProperty(reference.List)!.PropertyType)!)}";
return record != null && record.TryGetValue(path, out var name) && name != "" ? name : null;
}

//each record is read once for all the entries; while Prefetch collects, it is only noted
private async Task<IDictionary<string, string>> Record(Type target, string value, string key, string field)
{
var recordKey = new RecordKey(target, key, field, value);
if (_wanted != null)
{
_wanted.Add(recordKey);
return null;
}

if (!_records.TryGetValue(recordKey, out var record))
_records[recordKey] = record = await reader.Read(target, value, key, field);
return record;
}

private readonly record struct RecordKey(Type Target, string Key, string Field, string Value);
}
48 changes: 31 additions & 17 deletions src/Core/Grand.Infrastructure/Audit/AuditTracker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,9 @@ private static async Task<List<AuditEntry>> BuildEntries(
Dictionary<Type, Func<IReadOnlyCollection<string>, Task<IDictionary<string, IDictionary<string, string>>>>> loaders,
AuditReferences references)
{
var entries = new List<AuditEntry>();
//an entry of null: its changes could not be computed
var built = new List<(Type Type, string Id, AuditEntry Entry, IDictionary<string, string> Before,
IDictionary<string, string> After)>();
foreach (var group in pending.GroupBy(p => p.Key.Type))
{
var toRead = group.Where(p => p.Value.AfterHint == null).Select(p => p.Key.Id).ToList();
Expand All @@ -281,32 +283,44 @@ private static async Task<List<AuditEntry>> BuildEntries(
foreach (var item in group)
{
var after = item.Value.AfterHint ?? (read.TryGetValue(item.Key.Id, out var stored) ? stored : null);
AuditEntry entry;
try
{
entry = Build(group.Key, item.Key.Id, item.Value, after);
var entry = Build(group.Key, item.Key.Id, item.Value, after);
if (entry != null)
{
await references.NameElements(group.Key, entry.Changes, item.Value.Before, after);
await references.Describe(group.Key, entry.Changes, item.Value.Before, after);
await references.LinkOwner(group.Key, entry, after ?? item.Value.Before);
}
built.Add((group.Key, item.Key.Id, entry, item.Value.Before, after));
}
catch (Exception)
{
entry = new AuditEntry {
Kind = after == null ? AuditKind.Delete : AuditKind.Update,
EntityType = group.Key.Name,
EntityId = item.Key.Id,
Details = "changes could not be computed"
};
built.Add((group.Key, item.Key.Id, null, item.Value.Before, after));
}

if (entry != null)
entries.Add(entry);
}
}

//the names of all entries in a few reads, instead of one read per referenced record
await references.Prefetch(built.Where(b => b.Entry != null).Select(b => (b.Type, b.Entry, b.Before, b.After)));

var entries = new List<AuditEntry>();
foreach (var item in built)
{
var entry = item.Entry;
if (entry != null)
try
{
await references.DescribeEntry(item.Type, entry, item.Before, item.After);
}
catch (Exception)
{
entry = null;
}
Comment on lines +311 to +314

entries.Add(entry ?? new AuditEntry {
Kind = item.After == null ? AuditKind.Delete : AuditKind.Update,
EntityType = item.Type.Name,
EntityId = item.Id,
Details = "changes could not be computed"
});
}

return entries;
}

Expand Down
Loading
Loading