Skip to content

Commit

Permalink
introduce json patch rewriting to convert patches into specific changes.
Browse files Browse the repository at this point in the history
Rewrite changes to Sense.PartOfSpeechId into SetPartOfSpeechChange.
  • Loading branch information
hahn-kev committed Jun 13, 2024
1 parent ea70036 commit fb2c62b
Show file tree
Hide file tree
Showing 5 changed files with 211 additions and 3 deletions.
39 changes: 39 additions & 0 deletions backend/LcmCrdt.Tests/JsonPatchRewriteTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using LcmCrdt.Changes;
using LcmCrdt.Objects;
using SystemTextJsonPatch;

namespace LcmCrdt.Tests;

public class JsonPatchRewriteTests
{
[Fact]
public void RewritePartOfSpeechChangesIntoSetPartOfSpeechChange()
{
var newPartOfSpeechId = Guid.NewGuid();
var patchDocument = new JsonPatchDocument<MiniLcm.Sense>();
patchDocument.Replace(s => s.PartOfSpeechId, newPartOfSpeechId);
patchDocument.Replace(s => s.Gloss["en"], "new gloss");

var changes = Sense.ChangesFromJsonPatch(Guid.NewGuid(), patchDocument).ToArray();

var setPartOfSpeechChange = changes.OfType<SetPartOfSpeechChange>().Should().ContainSingle().Subject;
setPartOfSpeechChange.PartOfSpeechId.Should().Be(newPartOfSpeechId);

var patchChange = changes.OfType<JsonPatchChange<Sense>>().Should().ContainSingle().Subject;
patchChange.PatchDocument.Operations.Should().ContainSingle().Subject.Value.Should().Be("new gloss");
}

[Fact]
public void JsonPatchChangeRewriteDoesNotReturnEmptyPatchChanges()
{
var newPartOfSpeechId = Guid.NewGuid();
var patchDocument = new JsonPatchDocument<MiniLcm.Sense>();
patchDocument.Replace(s => s.PartOfSpeechId, newPartOfSpeechId);

var changes = Sense.ChangesFromJsonPatch(Guid.NewGuid(), patchDocument).ToArray();

var setPartOfSpeechChange = changes.Should().ContainSingle()
.Subject.Should().BeOfType<SetPartOfSpeechChange>().Subject;
setPartOfSpeechChange.PartOfSpeechId.Should().Be(newPartOfSpeechId);
}
}
19 changes: 19 additions & 0 deletions backend/LcmCrdt/Changes/SetPartOfSpeechChange.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Crdt.Changes;
using Crdt.Entities;

namespace LcmCrdt.Changes;

public class SetPartOfSpeechChange(Guid entityId, Guid? partOfSpeechId) : EditChange<Sense>(entityId), ISelfNamedType<SetPartOfSpeechChange>
{
public Guid? PartOfSpeechId { get; } = partOfSpeechId;

public override async ValueTask ApplyChange(Sense entity, ChangeContext context)
{
entity.PartOfSpeechId = PartOfSpeechId switch
{
null => null,
var id when await context.IsObjectDeleted(id.Value) => null,
_ => PartOfSpeechId
};
}
}
4 changes: 2 additions & 2 deletions backend/LcmCrdt/CrdtLexboxApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -215,8 +215,7 @@ await dataModel.AddChanges(ClientId,
Guid senseId,
UpdateObjectInput<MiniLcm.Sense> update)
{
var patchChange = new JsonPatchChange<Sense>(senseId, update.Patch, jsonOptions);
await dataModel.AddChange(ClientId, patchChange);
await dataModel.AddChanges(ClientId, [..Sense.ChangesFromJsonPatch(senseId, update.Patch)]);
return await dataModel.GetLatest<Sense>(senseId) ?? throw new NullReferenceException();
}

Expand Down Expand Up @@ -253,4 +252,5 @@ public UpdateBuilder<T> CreateUpdateBuilder<T>() where T : class
{
return new UpdateBuilder<T>();
}

}
28 changes: 27 additions & 1 deletion backend/LcmCrdt/Objects/Sense.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,33 @@
using Crdt;
using Crdt.Changes;
using Crdt.Db;
using Crdt.Entities;
using LcmCrdt.Changes;
using LcmCrdt.Utils;
using SystemTextJsonPatch;
using SystemTextJsonPatch.Operations;

namespace LcmCrdt.Objects;

public class Sense : MiniLcm.Sense, IObjectBase<Sense>
{
public static IEnumerable<IChange> ChangesFromJsonPatch(Guid senseId, JsonPatchDocument<MiniLcm.Sense> patch)
{
foreach (var rewriteChange in patch.RewriteChanges(s => s.PartOfSpeechId,
(partOfSpeechId, operationType) =>
{
if (operationType == OperationType.Replace)
return new SetPartOfSpeechChange(senseId, partOfSpeechId);
throw new NotSupportedException($"operation {operationType} not supported for part of speech");
}))
{
yield return rewriteChange;
}

if (patch.Operations.Count > 0)
yield return new JsonPatchChange<Sense>(senseId, patch, patch.Options);
}

Guid IObjectBase.Id
{
get => Id;
Expand All @@ -17,13 +39,16 @@ Guid IObjectBase.Id

public Guid[] GetReferences()
{
return [EntryId];
ReadOnlySpan<Guid> pos = PartOfSpeechId.HasValue ? [PartOfSpeechId.Value] : [];
return [EntryId, ..pos];
}

public void RemoveReference(Guid id, Commit commit)
{
if (id == EntryId)
DeletedAt = commit.DateTime;
if (id == PartOfSpeechId)
PartOfSpeechId = null;
}

public IObjectBase Copy()
Expand All @@ -36,6 +61,7 @@ public IObjectBase Copy()
Definition = Definition.Copy(),
Gloss = Gloss.Copy(),
PartOfSpeech = PartOfSpeech,
PartOfSpeechId = PartOfSpeechId,
SemanticDomains = [..SemanticDomains]
};
}
Expand Down
124 changes: 124 additions & 0 deletions backend/LcmCrdt/Utils/JsonPatchRewriter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
using System.Globalization;
using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using Crdt.Changes;
using SystemTextJsonPatch;
using SystemTextJsonPatch.Operations;

namespace LcmCrdt.Utils;

public static class JsonPatchRewriter
{

public static IEnumerable<IChange> RewriteChanges<T, TProp>(this JsonPatchDocument<T> patchDocument,
Expression<Func<T, TProp>> expr, Func<TProp?, OperationType, IChange> changeFactory) where T : class
{
var path = GetPath(expr, null, patchDocument.Options);
foreach (var operation in patchDocument.Operations.ToArray())
{
if (operation.Path == path)
{
patchDocument.Operations.Remove(operation);
yield return changeFactory((TProp?)operation.Value, operation.OperationType);
}
}
}

//won't work until dotnet 9 per https://learn.microsoft.com/en-us/dotnet/api/system.runtime.compilerservices.unsafeaccessorattribute?view=net-8.0#remarks
//this is due to generics, for now we will use the version copied below
private static class JsonPatchAccessors<T> where T : class
{
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "GetPath")]
public static extern string ExpressionToJsonPointer<TProp>(
JsonPatchDocument<T> patchDocument,
Expression<Func<T, TProp>> expr,
string? position);
}


public static string GetPath<TProp, TModel>(Expression<Func<TModel, TProp>> expr, string? position, JsonSerializerOptions options)
{
var segments = GetPathSegments(expr.Body, options);
var path = string.Join("/", segments);
if (position != null)
{
path += "/" + position;
if (segments.Count == 0)
{
return path;
}
}

return "/" + path;
}

private static List<string> GetPathSegments(Expression? expr, JsonSerializerOptions options)
{
if (expr == null || expr.NodeType == ExpressionType.Parameter) return [];
var listOfSegments = new List<string>();
switch (expr.NodeType)
{
case ExpressionType.ArrayIndex:
var binaryExpression = (BinaryExpression)expr;
listOfSegments.AddRange(GetPathSegments(binaryExpression.Left, options));
listOfSegments.Add(binaryExpression.Right.ToString());
return listOfSegments;

case ExpressionType.Call:
var methodCallExpression = (MethodCallExpression)expr;
listOfSegments.AddRange(GetPathSegments(methodCallExpression.Object, options));
listOfSegments.Add(EvaluateExpression(methodCallExpression.Arguments[0]));
return listOfSegments;

case ExpressionType.Convert:
listOfSegments.AddRange(GetPathSegments(((UnaryExpression)expr).Operand, options));
return listOfSegments;

case ExpressionType.MemberAccess:
var memberExpression = (MemberExpression)expr;
listOfSegments.AddRange(GetPathSegments(memberExpression.Expression, options));
// Get property name, respecting JsonProperty attribute
listOfSegments.Add(GetPropertyNameFromMemberExpression(memberExpression, options));
return listOfSegments;

default:
throw new InvalidOperationException($"type of expression not supported {expr}");
}
}

private static string GetPropertyNameFromMemberExpression(MemberExpression memberExpression, JsonSerializerOptions options)
{
var jsonPropertyNameAttr = memberExpression.Member.GetCustomAttribute<JsonPropertyNameAttribute>();

if (jsonPropertyNameAttr != null && !string.IsNullOrEmpty(jsonPropertyNameAttr.Name))
{
return jsonPropertyNameAttr.Name;
}

var memberName = memberExpression.Member.Name;

if (options.PropertyNamingPolicy != null)
{
return options.PropertyNamingPolicy.ConvertName(memberName);
}

return memberName;
}


// Evaluates the value of the key or index which may be an int or a string,
// or some other expression type.
// The expression is converted to a delegate and the result of executing the delegate is returned as a string.
private static string EvaluateExpression(Expression expression)
{
var converted = Expression.Convert(expression, typeof(object));
var fakeParameter = Expression.Parameter(typeof(object), null);
var lambda = Expression.Lambda<Func<object?, object>>(converted, fakeParameter);
var func = lambda.Compile();

return Convert.ToString(func(null), CultureInfo.InvariantCulture) ?? "";
}
}

0 comments on commit fb2c62b

Please sign in to comment.