diff --git a/src/Marten/Exceptions/MartenNotSupportedException.cs b/src/Marten/Exceptions/MartenNotSupportedException.cs new file mode 100644 index 0000000000..fd49e0e311 --- /dev/null +++ b/src/Marten/Exceptions/MartenNotSupportedException.cs @@ -0,0 +1,3 @@ +namespace Marten.Exceptions; + +public sealed class MartenNotSupportedException(string message) : MartenException(message); diff --git a/src/Marten/Linq/Parsing/JsonPathCreator.cs b/src/Marten/Linq/Parsing/JsonPathCreator.cs new file mode 100644 index 0000000000..1afb35fec5 --- /dev/null +++ b/src/Marten/Linq/Parsing/JsonPathCreator.cs @@ -0,0 +1,124 @@ +#nullable enable +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Linq.Expressions; +using System.Text; +using JasperFx.Core.Reflection; +using Marten.Exceptions; +using Marten.Util; + +namespace Marten.Linq.Parsing; + +public sealed class JsonPathCreator(ISerializer serializer) : ExpressionVisitor +{ + private readonly StringBuilder _jsonPathBuilder = new(); + private readonly Stack _fieldNames = new(); + private readonly HashSet _memberIfInUnary = []; + private readonly HashSet _memberIfInBinary = []; + private static readonly Dictionary LogicalOperators = new() + { + [ExpressionType.Equal] = "==", + [ExpressionType.NotEqual] = "!=", + [ExpressionType.GreaterThan] = ">", + [ExpressionType.GreaterThanOrEqual] = ">=", + [ExpressionType.LessThan] = "<", + [ExpressionType.LessThanOrEqual] = "<=", + [ExpressionType.And] = "&&", + [ExpressionType.AndAlso] = "&&", + [ExpressionType.Or] = "||", + [ExpressionType.OrElse] = "||" + }; + + + public string Build(Expression expression) + { + Visit(expression); + + var jsonPath = $"$ ? ({_jsonPathBuilder})"; + _jsonPathBuilder.Clear(); + + return jsonPath; + } + + protected override Expression VisitUnary(UnaryExpression node) + { + if (node.NodeType == ExpressionType.Not) + { + if (node.Operand is MemberExpression operandMember) + _memberIfInUnary.Add(operandMember); + + Visit(node.Operand); + } + + return node; + } + + protected override Expression VisitBinary(BinaryExpression node) + { + if (node.Left is MemberExpression leftMember) + _memberIfInBinary.Add(leftMember); + + if (node.Right is MemberExpression rightMember) + _memberIfInBinary.Add(rightMember); + + Visit(node.Left); + _jsonPathBuilder.Append($" {LogicalOperators[node.NodeType]} "); + Visit(node.Right); + + return node; + } + + protected override Expression VisitMember(MemberExpression node) + { + if (node.Expression is { NodeType: ExpressionType.Constant or ExpressionType.MemberAccess}) + { + _fieldNames.Push(node.Member.Name); + Visit(node.Expression); + } + else + { + _jsonPathBuilder.Append($"@.{node.Member.Name.FormatCase(serializer.Casing)}"); + + if (!_memberIfInBinary.Contains(node) && node.Type == typeof(bool)) + _jsonPathBuilder.Append($" {LogicalOperators[ExpressionType.Equal]} {(_memberIfInUnary.Contains(node) ? "false" : "true")}"); + } + + return node; + } + + protected override Expression VisitConstant(ConstantExpression node) + { + var value = GetValue(node.Value); + _jsonPathBuilder.Append(GetFormatedValue(value)); + + return node; + } + + protected override Expression VisitMethodCall(MethodCallExpression node) + => throw new MartenNotSupportedException("Calling a method is not supported"); + + private string GetFormatedValue(object? value) + => serializer.ToJson(value); + + private object? GetValue(object? input) + { + if (input is null) + return null; + + var type = input.GetType(); + + if (!type.IsClass || type == typeof(string)) + return input; + + var fieldName = _fieldNames.Pop(); + var fieldInfo = type.GetField(fieldName); + + var value = fieldInfo is not null + ? fieldInfo.GetValue(input) + : type.GetProperty(fieldName)?.GetValue(input); + + return GetValue(value); + } +} diff --git a/src/Marten/Patching/IPatchExpression.cs b/src/Marten/Patching/IPatchExpression.cs index 56f9dd8071..661d02829a 100644 --- a/src/Marten/Patching/IPatchExpression.cs +++ b/src/Marten/Patching/IPatchExpression.cs @@ -110,6 +110,17 @@ public interface IPatchExpression /// IPatchExpression AppendIfNotExists(Expression>> expression, TElement element); + /// + /// Append an element to the end of a child collection on the persisted + /// document if the element does not already exist by predicate + /// + /// + /// + /// + /// + /// + IPatchExpression AppendIfNotExists(Expression>> expression, TElement element, Expression> predicate); + /// /// Insert an element at the designated index to a child collection on the persisted document /// @@ -131,6 +142,18 @@ public interface IPatchExpression /// IPatchExpression InsertIfNotExists(Expression>> expression, TElement element, int? index = null); + /// + /// Insert an element at the designated index to a child collection on the persisted document + /// if the value does not already exist by predicate + /// + /// + /// + /// + /// + /// + /// + IPatchExpression InsertIfNotExists(Expression>> expression, TElement element, Expression> predicate, int? index = null); + /// /// Remove element from a child collection on the persisted document /// @@ -141,6 +164,16 @@ public interface IPatchExpression /// IPatchExpression Remove(Expression>> expression, TElement element, RemoveAction action = RemoveAction.RemoveFirst); + /// + /// Remove element from a child collection by predicate on the persisted document + /// + /// + /// + /// + /// + /// + IPatchExpression Remove(Expression>> expression, Expression> predicate, RemoveAction action = RemoveAction.RemoveFirst); + /// /// Rename a property or field in the persisted JSON document /// diff --git a/src/Marten/Patching/PatchExpression.cs b/src/Marten/Patching/PatchExpression.cs index c0fae6e92c..72988d5ace 100644 --- a/src/Marten/Patching/PatchExpression.cs +++ b/src/Marten/Patching/PatchExpression.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Linq.Expressions; +using System.Text.Json; using JasperFx.Core; using Marten.Internal.Sessions; using Marten.Linq.Parsing; @@ -159,6 +161,20 @@ public IPatchExpression AppendIfNotExists(Expression AppendIfNotExists(Expression>> expression, TElement element, Expression> predicate) + { + var patch = new Dictionary(); + patch.Add("type", "append_if_not_exists"); + patch.Add("value", element); + patch.Add("expression", toPathExpression(predicate)); + patch.Add("path", toPath(expression)); + + var possiblyPolymorphic = element!.GetType() != typeof(TElement); + _patchSet.Add(new PatchData(Items: patch, possiblyPolymorphic)); + + return this; + } + public IPatchExpression Insert(Expression>> expression, TElement element, int? index = null) { var patch = new Dictionary(); @@ -192,6 +208,23 @@ public IPatchExpression InsertIfNotExists(Expression InsertIfNotExists(Expression>> expression, TElement element, Expression> predicate, int? index = null) + { + var patch = new Dictionary(); + patch.Add("type", "insert_if_not_exists"); + patch.Add("value", element); + patch.Add("expression", toPathExpression(predicate)); + patch.Add("path", toPath(expression)); + if (index.HasValue) + { + patch.Add("index", index); + } + + var possiblyPolymorphic = element!.GetType() != typeof(TElement); + _patchSet.Add(new PatchData(Items: patch, possiblyPolymorphic)); + return this; + } + public IPatchExpression Remove(Expression>> expression, TElement element, RemoveAction action = RemoveAction.RemoveFirst) { var patch = new Dictionary(); @@ -205,6 +238,18 @@ public IPatchExpression Remove(Expression Remove(Expression>> expression, Expression> predicate, RemoveAction action = RemoveAction.RemoveFirst) + { + var patch = new Dictionary(); + patch.Add("type", "remove"); + patch.Add("expression", toPathExpression(predicate)); + patch.Add("path", toPath(expression)); + patch.Add("action", (int)action); + + _patchSet.Add(new PatchData(Items: patch, PossiblyPolymorphic: false)); + return this; + } + public IPatchExpression Rename(string oldName, Expression> expression) { var patch = new Dictionary(); @@ -268,5 +313,8 @@ private string toPath(Expression expression) return visitor.Members.Select(x => x.Name.FormatCase(_session.Serializer.Casing)).Join("."); } + private string toPathExpression(Expression expression) + => new JsonPathCreator(_session.Serializer).Build(expression); + private DbObjectName PatchFunction => new PostgresqlObjectName(_session.Options.DatabaseSchemaName, "mt_jsonb_patch"); } diff --git a/src/Marten/Schema/SQL/mt_jsonb_append.sql b/src/Marten/Schema/SQL/mt_jsonb_append.sql index 625a7556fc..8e4eb1f700 100644 --- a/src/Marten/Schema/SQL/mt_jsonb_append.sql +++ b/src/Marten/Schema/SQL/mt_jsonb_append.sql @@ -1,4 +1,4 @@ -CREATE OR REPLACE FUNCTION {databaseSchema}.mt_jsonb_append(jsonb, text[], jsonb, boolean) +CREATE OR REPLACE FUNCTION {databaseSchema}.mt_jsonb_append(jsonb, text[], jsonb, boolean, jsonb default null) RETURNS jsonb LANGUAGE plpgsql AS $function$ @@ -7,6 +7,7 @@ DECLARE location ALIAS FOR $2; val ALIAS FOR $3; if_not_exists ALIAS FOR $4; + patch_expression ALIAS FOR $5; tmp_value jsonb; BEGIN tmp_value = retval #> location; @@ -14,9 +15,11 @@ BEGIN CASE WHEN NOT if_not_exists THEN retval = jsonb_set(retval, location, tmp_value || val, FALSE); - WHEN jsonb_typeof(val) = 'object' AND NOT tmp_value @> jsonb_build_array(val) THEN + WHEN patch_expression IS NULL AND jsonb_typeof(val) = 'object' AND NOT tmp_value @> jsonb_build_array(val) THEN retval = jsonb_set(retval, location, tmp_value || val, FALSE); - WHEN jsonb_typeof(val) <> 'object' AND NOT tmp_value @> val THEN + WHEN patch_expression IS NULL AND jsonb_typeof(val) <> 'object' AND NOT tmp_value @> val THEN + retval = jsonb_set(retval, location, tmp_value || val, FALSE); + WHEN patch_expression IS NOT NULL AND jsonb_typeof(patch_expression) = 'array' AND jsonb_array_length(patch_expression) = 0 THEN retval = jsonb_set(retval, location, tmp_value || val, FALSE); ELSE NULL; END CASE; diff --git a/src/Marten/Schema/SQL/mt_jsonb_insert.sql b/src/Marten/Schema/SQL/mt_jsonb_insert.sql index 8b9cd250a4..65901c76ba 100644 --- a/src/Marten/Schema/SQL/mt_jsonb_insert.sql +++ b/src/Marten/Schema/SQL/mt_jsonb_insert.sql @@ -1,4 +1,4 @@ -CREATE OR REPLACE FUNCTION {databaseSchema}.mt_jsonb_insert(jsonb, text[], jsonb, integer, boolean) +CREATE OR REPLACE FUNCTION {databaseSchema}.mt_jsonb_insert(jsonb, text[], jsonb, integer, boolean, jsonb default null) RETURNS jsonb LANGUAGE plpgsql AS $function$ @@ -8,6 +8,7 @@ DECLARE val ALIAS FOR $3; elm_index ALIAS FOR $4; if_not_exists ALIAS FOR $5; + patch_expression ALIAS FOR $6; tmp_value jsonb; BEGIN tmp_value = retval #> location; @@ -18,9 +19,11 @@ BEGIN CASE WHEN NOT if_not_exists THEN retval = jsonb_insert(retval, location || elm_index::text, val); - WHEN jsonb_typeof(val) = 'object' AND NOT tmp_value @> jsonb_build_array(val) THEN + WHEN patch_expression IS NULL AND jsonb_typeof(val) = 'object' AND NOT tmp_value @> jsonb_build_array(val) THEN retval = jsonb_insert(retval, location || elm_index::text, val); - WHEN jsonb_typeof(val) <> 'object' AND NOT tmp_value @> val THEN + WHEN patch_expression IS NULL AND jsonb_typeof(val) <> 'object' AND NOT tmp_value @> val THEN + retval = jsonb_insert(retval, location || elm_index::text, val); + WHEN patch_expression IS NOT NULL AND jsonb_typeof(patch_expression) = 'array' AND jsonb_array_length(patch_expression) = 0 THEN retval = jsonb_insert(retval, location || elm_index::text, val); ELSE NULL; END CASE; diff --git a/src/Marten/Schema/SQL/mt_jsonb_patch.sql b/src/Marten/Schema/SQL/mt_jsonb_patch.sql index 0fe1d04591..c531871c7a 100644 --- a/src/Marten/Schema/SQL/mt_jsonb_patch.sql +++ b/src/Marten/Schema/SQL/mt_jsonb_patch.sql @@ -7,36 +7,42 @@ DECLARE patchset ALIAS FOR $2; patch jsonb; patch_path text[]; + patch_expression jsonb; value jsonb; BEGIN FOR patch IN SELECT * from jsonb_array_elements(patchset) LOOP patch_path = {databaseSchema}.mt_jsonb_path_to_array((patch->>'path')::text, '\.'); + patch_expression = null; + IF (patch->>'type') IN ('remove', 'append_if_not_exists', 'insert_if_not_exists') AND (patch->>'expression') IS NOT NULL THEN + patch_expression = jsonb_path_query_array(retval->(patch->>'path'), (patch->>'expression')::jsonpath); + END IF; + CASE patch->>'type' WHEN 'set' THEN - retval = jsonb_set(retval, patch_path,(patch->'value')::jsonb, TRUE); - WHEN 'delete' THEN + retval = jsonb_set(retval, patch_path, (patch->'value')::jsonb, TRUE); + WHEN 'delete' THEN retval = retval#-patch_path; - WHEN 'append' THEN - retval = {databaseSchema}.mt_jsonb_append(retval, patch_path,(patch->'value')::jsonb, FALSE); - WHEN 'append_if_not_exists' THEN - retval = {databaseSchema}.mt_jsonb_append(retval, patch_path,(patch->'value')::jsonb, TRUE); - WHEN 'insert' THEN - retval = {databaseSchema}.mt_jsonb_insert(retval, patch_path,(patch->'value')::jsonb,(patch->>'index')::integer, FALSE); - WHEN 'insert_if_not_exists' THEN - retval = {databaseSchema}.mt_jsonb_insert(retval, patch_path,(patch->'value')::jsonb,(patch->>'index')::integer, TRUE); - WHEN 'remove' THEN - retval = {databaseSchema}.mt_jsonb_remove(retval, patch_path,(patch->'value')::jsonb); - WHEN 'duplicate' THEN - retval = {databaseSchema}.mt_jsonb_duplicate(retval, patch_path,(patch->'targets')::jsonb); - WHEN 'rename' THEN - retval = {databaseSchema}.mt_jsonb_move(retval, patch_path,(patch->>'to')::text); - WHEN 'increment' THEN - retval = {databaseSchema}.mt_jsonb_increment(retval, patch_path,(patch->>'increment')::numeric); - WHEN 'increment_float' THEN - retval = {databaseSchema}.mt_jsonb_increment(retval, patch_path,(patch->>'increment')::numeric); - ELSE NULL; + WHEN 'append' THEN + retval = {databaseSchema}.mt_jsonb_append(retval, patch_path, (patch->'value')::jsonb, FALSE); + WHEN 'append_if_not_exists' THEN + retval = {databaseSchema}.mt_jsonb_append(retval, patch_path, (patch->'value')::jsonb, TRUE, patch_expression); + WHEN 'insert' THEN + retval = {databaseSchema}.mt_jsonb_insert(retval, patch_path, (patch->'value')::jsonb, (patch->>'index')::integer, FALSE); + WHEN 'insert_if_not_exists' THEN + retval = {databaseSchema}.mt_jsonb_insert(retval, patch_path, (patch->'value')::jsonb, (patch->>'index')::integer, TRUE, patch_expression); + WHEN 'remove' THEN + retval = {databaseSchema}.mt_jsonb_remove(retval, patch_path, COALESCE(patch_expression, (patch->'value')::jsonb)); + WHEN 'duplicate' THEN + retval = {databaseSchema}.mt_jsonb_duplicate(retval, patch_path, (patch->'targets')::jsonb); + WHEN 'rename' THEN + retval = {databaseSchema}.mt_jsonb_move(retval, patch_path, (patch->>'to')::text); + WHEN 'increment' THEN + retval = {databaseSchema}.mt_jsonb_increment(retval, patch_path, (patch->>'increment')::numeric); + WHEN 'increment_float' THEN + retval = {databaseSchema}.mt_jsonb_increment(retval, patch_path, (patch->>'increment')::numeric); + ELSE NULL; END CASE; END LOOP; RETURN retval; diff --git a/src/Marten/Schema/SQL/mt_jsonb_remove.sql b/src/Marten/Schema/SQL/mt_jsonb_remove.sql index 6b2859e744..d1be9df039 100644 --- a/src/Marten/Schema/SQL/mt_jsonb_remove.sql +++ b/src/Marten/Schema/SQL/mt_jsonb_remove.sql @@ -7,12 +7,23 @@ DECLARE location ALIAS FOR $2; val ALIAS FOR $3; tmp_value jsonb; + tmp_remove jsonb; + patch_remove jsonb; BEGIN tmp_value = retval #> location; IF tmp_value IS NOT NULL AND jsonb_typeof(tmp_value) = 'array' THEN - tmp_value =(SELECT jsonb_agg(elem) - FROM jsonb_array_elements(tmp_value) AS elem - WHERE elem <> val); + IF jsonb_typeof(val) = 'array' THEN + tmp_remove = val; + ELSE + tmp_remove = jsonb_build_array(val); + END IF; + + FOR patch_remove IN SELECT * FROM jsonb_array_elements(tmp_remove) + LOOP + tmp_value =(SELECT jsonb_agg(elem) + FROM jsonb_array_elements(tmp_value) AS elem + WHERE elem <> patch_remove); + END LOOP; IF tmp_value IS NULL THEN tmp_value = '[]'::jsonb; diff --git a/src/PatchingTests/Patching/patching_api.cs b/src/PatchingTests/Patching/patching_api.cs index 5f16b9abfb..258bdff513 100644 --- a/src/PatchingTests/Patching/patching_api.cs +++ b/src/PatchingTests/Patching/patching_api.cs @@ -8,6 +8,7 @@ using Marten; using Marten.Events; using Marten.Events.Projections; +using Marten.Exceptions; using Marten.Patching; using Marten.Services; using Marten.Storage; @@ -18,7 +19,6 @@ using Weasel.Core; using Weasel.Postgresql.SqlGeneration; using Xunit; -using Xunit.Abstractions; namespace PatchingTests.Patching; @@ -395,6 +395,42 @@ public async Task append_if_not_exists_complex_element() } } + [Fact] + public async Task append_if_not_exists_complex_element_by_predicate() + { + var target = Target.Random(true); + var initialCount = target.Children.Length; + + var child = Target.Random(); + var child2 = Target.Random(); + + theSession.Store(target); + await theSession.SaveChangesAsync(); + theSession.Patch(target.Id).Append(x => x.Children, child); + await theSession.SaveChangesAsync(); + theSession.Patch(target.Id).AppendIfNotExists(x => x.Children, child, x => x.Id == child.Id); + await theSession.SaveChangesAsync(); + + using (var query = theStore.QuerySession()) + { + var target2 = query.Load(target.Id); + target2.Children.Length.ShouldBe(initialCount + 1); + + target2.Children.Last().Id.ShouldBe(child.Id); + } + + theSession.Patch(target.Id).AppendIfNotExists(x => x.Children, child2, x => x.Id == child2.Id); + await theSession.SaveChangesAsync(); + + using (var query = theStore.QuerySession()) + { + var target2 = query.Load(target.Id); + target2.Children.Length.ShouldBe(initialCount + 2); + + target2.Children.Last().Id.ShouldBe(child2.Id); + } + } + [Fact] public async Task insert_first_to_a_primitive_array() { @@ -560,6 +596,51 @@ public async Task insert_if_not_exists_last_complex_element() } } + [Fact] + public async Task insert_if_not_exists_last_complex_element_by_predicate() + { + var target = Target.Random(true); + var initialCount = target.Children.Length; + + var child = Target.Random(); + var child2 = Target.Random(); + theSession.Store(target); + await theSession.SaveChangesAsync(); + + theSession.Patch(target.Id).Insert(x => x.Children, child); + await theSession.SaveChangesAsync(); + + using (var query = theStore.QuerySession()) + { + var target2 = query.Load(target.Id); + target2.Children.Length.ShouldBe(initialCount + 1); + + target2.Children.Last().Id.ShouldBe(child.Id); + } + + theSession.Patch(target.Id).InsertIfNotExists(x => x.Children, child, x => x.Id == child.Id); + await theSession.SaveChangesAsync(); + + using (var query = theStore.QuerySession()) + { + var target2 = query.Load(target.Id); + target2.Children.Length.ShouldBe(initialCount + 1); + + target2.Children.Last().Id.ShouldBe(child.Id); + } + + theSession.Patch(target.Id).InsertIfNotExists(x => x.Children, child2, x => x.Id == child2.Id); + await theSession.SaveChangesAsync(); + + using (var query = theStore.QuerySession()) + { + var target2 = query.Load(target.Id); + target2.Children.Length.ShouldBe(initialCount + 2); + + target2.Children.Last().Id.ShouldBe(child2.Id); + } + } + [Fact] public async Task rename_shallow_prop() { @@ -701,6 +782,74 @@ public async Task remove_complex_element() #endregion + #region sample_patching_remove_complex_element_by_predicate + + [Fact] + public async Task remove_complex_element_by_predicate() + { + var target = Target.Random(); + target.Children = [Target.Random(), Target.Random(), Target.Random(), Target.Random()]; + var initialCount = target.Children.Length; + + var random = new Random(); + var child = target.Children[random.Next(0, initialCount)]; + + theSession.Store(target); + await theSession.SaveChangesAsync(); + + theSession.Patch(target.Id).Remove(x => x.Children, x => x.Id == child.Id); + await theSession.SaveChangesAsync(); + + await using var query = theStore.QuerySession(); + var target2 = query.Load(target.Id); + target2.Children.Length.ShouldBe(initialCount - 1); + target2.Children.ShouldNotContain(t => t.Id == child.Id); + } + + [Fact] + public async Task remove_complex_elements_by_predicate() + { + var target = Target.Random(); + target.Children = [Target.Random(), Target.Random(), Target.Random(), Target.Random()]; + var initialCount = target.Children.Length; + + var random = new Random(); + var child = target.Children[random.Next(0, initialCount)]; + + theSession.Store(target); + await theSession.SaveChangesAsync(); + + theSession.Patch(target.Id).Remove(x => x.Children, x => x.Id != child.Id); + await theSession.SaveChangesAsync(); + + await using var query = theStore.QuerySession(); + var target2 = query.Load(target.Id); + target2.Children.Length.ShouldBe(1); + target2.Children.ShouldContain(t => t.Id == child.Id); + } + + #endregion + + [Fact] + public async Task throw_exception_if_a_method_call_is_used_for_remove_complex_element_by_predicate() + { + var target = Target.Random(); + target.Children = [Target.Random(), Target.Random(), Target.Random(), Target.Random()]; + var initialCount = target.Children.Length; + + var random = new Random(); + var child = target.Children[random.Next(0, initialCount)]; + + theSession.Store(target); + await theSession.SaveChangesAsync(); + + var call = () => theSession + .Patch(target.Id) + .Remove(x => x.Children, x => x.Id.ToString() == child.Id.ToString()); + + call.ShouldThrow("Calling a method is not supported"); + } + [Fact] public async Task delete_redundant_property() {