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
127 changes: 127 additions & 0 deletions src/FluentCMS.Core/Content/Hooks/ContentChanges.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
using System.Collections;
using System.Text.Json;
using System.Text.Json.Nodes;
using FluentCMS.Core.Content.Validation;
using FluentCMS.Schema;

namespace FluentCMS.Core.Content.Hooks;

public static class ContentChanges
{
private static readonly IReadOnlyDictionary<string, object?> NoValues = new Dictionary<string, object?>();

// The paths of the fields whose values differ, in the form validation reports: seo.metaTitle, sections[2].heading, tags[1].
// A missing field counts as null, so a create (no previous state) lists the fields that hold a value. Lists of the same
// length are compared by index; a list whose length changed is reported as the list itself. Components and blocks are
// compared field by field whether they are dictionaries, JSON objects or instances of a component or block class;
// JSON values compare as the scalars they hold. Every other value compares by value as a schema stores it (a Ref by its
// id) or, failing that, by Equals (RichText, MediaRef).
public static IReadOnlyList<string> Between(IReadOnlyDictionary<string, object?>? previous, IReadOnlyDictionary<string, object?> current)
{
ArgumentNullException.ThrowIfNull(current);

var paths = new List<string>();
CompareMaps(previous ?? NoValues, current, "", paths);
return paths;
}

private static void CompareMaps(IReadOnlyDictionary<string, object?> previous, IReadOnlyDictionary<string, object?> current, string prefix, List<string> paths)
{
foreach (var (name, value) in current)
{
previous.TryGetValue(name, out var before);
CompareValues(before, value, Join(prefix, name), paths);
}

foreach (var (name, value) in previous)
{
if (!current.ContainsKey(name))
{
CompareValues(value, null, Join(prefix, name), paths);
}
}
}

private static void CompareValues(object? before, object? after, string path, List<string> paths)
{
before = Plain(before);
after = Plain(after);

if (IsInstance(before) && IsInstance(after) && before!.GetType() != after!.GetType())
{
paths.Add(path);
}
else if (AsMap(before) is { } beforeMap && AsMap(after) is { } afterMap)
{
CompareMaps(beforeMap, afterMap, path, paths);
}
else if (AsList(before) is { } beforeList && AsList(after) is { } afterList)
{
if (beforeList.Count != afterList.Count)
{
paths.Add(path);
return;
}

for (var index = 0; index < afterList.Count; index++)
{
CompareValues(beforeList[index], afterList[index], $"{path}[{index}]", paths);
}
}
else if (!Equals(FieldValue.Normalize(before), FieldValue.Normalize(after)))
{
paths.Add(path);
}
}

private static string Join(string prefix, string name) => prefix.Length == 0 ? name : $"{prefix}.{name}";

// JSON as plain values: dictionaries, lists, strings, booleans, longs and decimals.
private static object? Plain(object? value) => value switch
{
JsonElement { ValueKind: JsonValueKind.Undefined } => null,
JsonElement element => Plain(JsonNode.Parse(element.GetRawText())),
JsonObject node => node.ToDictionary(property => property.Key, property => Plain(property.Value), StringComparer.Ordinal),
JsonArray node => node.Select(Plain).ToList(),
JsonValue node => PlainScalar(node),
_ => value,
};

private static object? PlainScalar(JsonValue node)
{
switch (node.GetValueKind())
{
case JsonValueKind.True:
return true;
case JsonValueKind.False:
return false;
case JsonValueKind.Number:
if (node.TryGetValue(out long whole))
{
return whole;
}

return node.TryGetValue(out decimal number) ? number : node.ToJsonString();
case JsonValueKind.String:
return node.TryGetValue(out string? text) ? text : node.ToJsonString();
default:
return null;
}
}

private static bool IsInstance(object? value) =>
value is not null && (Attribute.IsDefined(value.GetType(), typeof(BlockAttribute)) || Attribute.IsDefined(value.GetType(), typeof(ComponentAttribute)));

private static IReadOnlyDictionary<string, object?>? AsMap(object? value) => value switch
{
IReadOnlyDictionary<string, object?> map => map,
IDictionary<string, object?> map => new Dictionary<string, object?>(map),
_ when IsInstance(value) => FieldValue.ReadInstance(value),
_ => null,
};

private static List<object?>? AsList(object? value) =>
value is IEnumerable and not string and not IReadOnlyDictionary<string, object?> and not IDictionary<string, object?>
? [.. ((IEnumerable)value).Cast<object?>()]
: null;
}
31 changes: 31 additions & 0 deletions src/FluentCMS.Core/Content/Hooks/ContentCopy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System.Text.Json.Nodes;
using FluentCMS.Content;

namespace FluentCMS.Core.Content.Hooks;

// A copy of an entry that no hook can use to reach the original: documents, dictionaries, object lists and JSON are copied
// all the way down. Immutable values (strings, numbers, references, rich text, block lists) are shared.
internal static class ContentCopy
{
public static ContentDocument Of(ContentDocument document)
{
var copy = new ContentDocument();
foreach (var (name, value) in document)
{
copy[name] = Value(value);
}

return copy;
}

private static object? Value(object? value) => value switch
{
ContentDocument document => Of(document),
JsonNode node => node.DeepClone(),
IDictionary<string, object?> map => map.ToDictionary(item => item.Key, item => Value(item.Value), StringComparer.Ordinal),
IReadOnlyDictionary<string, object?> map => map.ToDictionary(item => item.Key, item => Value(item.Value), StringComparer.Ordinal),
List<object?> list => list.ConvertAll(Value),
object?[] array => Array.ConvertAll(array, Value),
_ => value,
};
}
18 changes: 18 additions & 0 deletions src/FluentCMS.Core/Content/Hooks/HookKind.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace FluentCMS.Core.Content.Hooks;

public enum HookKind
{
BeforeValidate,
BeforeCreate,
AfterCreate,
BeforeUpdate,
AfterUpdate,
BeforeDelete,
AfterDelete,
BeforePublish,
AfterPublish,
BeforeUnpublish,
AfterUnpublish,
BeforeRestore,
AfterRestore,
}
202 changes: 202 additions & 0 deletions src/FluentCMS.Core/Content/Hooks/HookPipeline.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
using System.Collections.Concurrent;
using System.Reflection;
using FluentCMS.Content;
using FluentCMS.Hooks;
using FluentCMS.Schema;

namespace FluentCMS.Core.Content.Hooks;

// Runs the hooks of one kind for one entry. Hooks for ContentDocument (every type) and for the content class of the type,
// and for its base classes, form one sequence ordered by [HookOrder], then by IHookTiebreaker rank. Hooks that still tie keep
// the order of resolution: all-types hooks first, then base classes before derived ones, each in registration order.
// The first hook that cancels throws HookRejectedException and the hooks after it do not run.
// The changed paths are those between previous and entry, computed again before each before hook so that a hook sees the
// changes of the hooks before it; an operation that changes no field passes the stored state as previous.
// Only before hooks change the entry: an after hook works on a copy.
public sealed class HookPipeline(IServiceProvider services)
{
private static readonly ConcurrentDictionary<Type, int> Orders = new();

public async ValueTask RunAsync(
HookKind kind,
SchemaType type,
OperationContext operation,
ContentDocument entry,
ContentDocument? previous,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(type);
ArgumentNullException.ThrowIfNull(operation);
ArgumentNullException.ThrowIfNull(entry);

var hooks = Resolve(kind, type);
if (hooks.Count == 0)
{
return;
}

var mutable = kind is HookKind.BeforeValidate or HookKind.BeforeCreate or HookKind.BeforeUpdate
or HookKind.BeforeDelete or HookKind.BeforePublish or HookKind.BeforeUnpublish or HookKind.BeforeRestore;
var session = new Session(
type.ClrType,
operation,
mutable ? entry : ContentCopy.Of(entry),
previous,
services.GetService(typeof(IHookEntryMapper)) as IHookEntryMapper);

var first = true;
foreach (var hook in hooks)
{
cancellationToken.ThrowIfCancellationRequested();
if (mutable && !first)
{
session.RefreshChangedPaths();
}

first = false;

await hook.InvokeAsync(session, mutable, cancellationToken);
}
}

private List<BoundHook> Resolve(HookKind kind, SchemaType type)
{
var hooks = new List<BoundHook>();
HookBinder.For(typeof(ContentDocument)).Collect(services, kind, hooks);

if (type.ClrType is { } clrType)
{
var chain = new List<Type>();
for (var current = clrType; current is not null && current != typeof(object); current = current.BaseType)
{
chain.Add(current);
}

for (var index = chain.Count - 1; index >= 0; index--)
{
HookBinder.For(chain[index]).Collect(services, kind, hooks);
}
}

if (hooks.Count < 2)
{
return hooks;
}

var tiebreaker = services.GetService(typeof(IHookTiebreaker)) as IHookTiebreaker;
return [.. hooks
.OrderBy(hook => Orders.GetOrAdd(hook.ImplementationType, static type => type.GetCustomAttribute<HookOrderAttribute>()?.Order ?? 0))
.ThenBy(hook => tiebreaker?.Rank(hook.ImplementationType) ?? 0)];
}

// The entry as the hooks see it: the document, or the content class mapped from it.
private sealed class Session(
Type? clrType,
OperationContext operation,
ContentDocument entry,
ContentDocument? previous,
IHookEntryMapper? mapper)
{
private object? _previousEntry;
private IReadOnlyList<string> _changedPaths = ContentChanges.Between(previous, entry);

public void RefreshChangedPaths() => _changedPaths = ContentChanges.Between(previous, entry);

public HookContext<T> CreateContext<T>(out object? mapped)
{
if (typeof(T) == typeof(ContentDocument))
{
mapped = null;
return new HookContext<T>(operation, (T)(object)entry, (T?)(object?)previous, _changedPaths);
}

var map = RequireMapper();
mapped = map.ToEntry(clrType!, entry);
if (previous is not null)
{
_previousEntry ??= map.ToEntry(clrType!, previous);
}

return new HookContext<T>(operation, (T)mapped, (T?)_previousEntry, _changedPaths);
}

public void WriteBack(object mapped) => RequireMapper().CopyTo(mapped, entry);

private IHookEntryMapper RequireMapper() =>
mapper ?? throw new InvalidOperationException("A hook is registered for a content class, but no IHookEntryMapper is registered to map the entry to it.");
}

private abstract class BoundHook(Type implementationType)
{
public Type ImplementationType { get; } = implementationType;

public abstract ValueTask InvokeAsync(Session session, bool mutable, CancellationToken cancellationToken);
}

private sealed class BoundHook<T>(Type implementationType, Func<HookContext<T>, CancellationToken, ValueTask> call) : BoundHook(implementationType)
{
public override async ValueTask InvokeAsync(Session session, bool mutable, CancellationToken cancellationToken)
{
var context = session.CreateContext<T>(out var mapped);
await call(context, cancellationToken);

if (context.IsCancelled)
{
throw new HookRejectedException(context.CancelCode!, context.CancelMessage!);
}

if (mutable && mapped is not null)
{
session.WriteBack(mapped);
}
}
}

private abstract class HookBinder
{
private static readonly ConcurrentDictionary<Type, HookBinder> Cache = new();

public static HookBinder For(Type entryType) =>
Cache.GetOrAdd(entryType, static type => (HookBinder)Activator.CreateInstance(typeof(HookBinder<>).MakeGenericType(type))!);

public abstract void Collect(IServiceProvider services, HookKind kind, List<BoundHook> into);
}

private sealed class HookBinder<T> : HookBinder
{
public override void Collect(IServiceProvider services, HookKind kind, List<BoundHook> into)
{
switch (kind)
{
case HookKind.BeforeValidate: Add<IBeforeValidateHook<T>>(services, into, hook => hook.BeforeValidateAsync); break;
case HookKind.BeforeCreate: Add<IBeforeCreateHook<T>>(services, into, hook => hook.BeforeCreateAsync); break;
case HookKind.AfterCreate: Add<IAfterCreateHook<T>>(services, into, hook => hook.AfterCreateAsync); break;
case HookKind.BeforeUpdate: Add<IBeforeUpdateHook<T>>(services, into, hook => hook.BeforeUpdateAsync); break;
case HookKind.AfterUpdate: Add<IAfterUpdateHook<T>>(services, into, hook => hook.AfterUpdateAsync); break;
case HookKind.BeforeDelete: Add<IBeforeDeleteHook<T>>(services, into, hook => hook.BeforeDeleteAsync); break;
case HookKind.AfterDelete: Add<IAfterDeleteHook<T>>(services, into, hook => hook.AfterDeleteAsync); break;
case HookKind.BeforePublish: Add<IBeforePublishHook<T>>(services, into, hook => hook.BeforePublishAsync); break;
case HookKind.AfterPublish: Add<IAfterPublishHook<T>>(services, into, hook => hook.AfterPublishAsync); break;
case HookKind.BeforeUnpublish: Add<IBeforeUnpublishHook<T>>(services, into, hook => hook.BeforeUnpublishAsync); break;
case HookKind.AfterUnpublish: Add<IAfterUnpublishHook<T>>(services, into, hook => hook.AfterUnpublishAsync); break;
case HookKind.BeforeRestore: Add<IBeforeRestoreHook<T>>(services, into, hook => hook.BeforeRestoreAsync); break;
case HookKind.AfterRestore: Add<IAfterRestoreHook<T>>(services, into, hook => hook.AfterRestoreAsync); break;
default: throw new ArgumentOutOfRangeException(nameof(kind), kind, null);
}
}

private static void Add<THook>(IServiceProvider services, List<BoundHook> into, Func<THook, Func<HookContext<T>, CancellationToken, ValueTask>> bind)
where THook : notnull
{
if (services.GetService(typeof(IEnumerable<THook>)) is not IEnumerable<THook> hooks)
{
return;
}

foreach (var hook in hooks)
{
into.Add(new BoundHook<T>(hook.GetType(), bind(hook)));
}
}
}
}
13 changes: 13 additions & 0 deletions src/FluentCMS.Core/Content/Hooks/IHookEntryMapper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using FluentCMS.Content;

namespace FluentCMS.Core.Content.Hooks;

// Converts between the ContentDocument the content service works on and the content class a typed hook receives.
// The content service provides it; the pipeline only needs it when a hook is registered for a content class.
public interface IHookEntryMapper
{
object ToEntry(Type clrType, ContentDocument document);

// Writes the values of the entry back into the document and leaves the keys the class does not map alone.
void CopyTo(object entry, ContentDocument document);
}
Loading
Loading