From a285da247d2776005573ada765bf47b6a1a89147 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:22:04 +0200 Subject: [PATCH 01/10] CSHARP-5656: Investigate changes in SERVER-107499: Support (de)serialization between BSON and EJSON --- src/MongoDB.Driver/Core/Misc/Feature.cs | 12 ++ src/MongoDB.Driver/DeserializeEJsonOptions.cs | 55 ++++++ .../Linq3Implementation/Ast/AstNodeType.cs | 2 + .../AstDeserializeEJsonExpression.cs | 69 ++++++++ .../Ast/Expressions/AstExpression.cs | 15 ++ .../AstSerializeEJsonExpression.cs | 75 ++++++++ .../Ast/Visitors/AstNodeVisitor.cs | 10 ++ .../Reflection/MqlMethod.cs | 6 + .../SerializerFinderVisitMethodCall.cs | 36 ++++ ...essionToAggregationExpressionTranslator.cs | 2 + ...MethodToAggregationExpressionTranslator.cs | 113 ++++++++++++ ...MethodToAggregationExpressionTranslator.cs | 128 ++++++++++++++ src/MongoDB.Driver/Mql.cs | 26 +++ src/MongoDB.Driver/SerializeEJsonOptions.cs | 66 +++++++ .../Integration/MqlDeserializeEJsonTests.cs | 149 ++++++++++++++++ .../Integration/MqlSerializeEJsonTests.cs | 167 ++++++++++++++++++ ...dToAggregationExpressionTranslatorTests.cs | 62 +++++++ ...dToAggregationExpressionTranslatorTests.cs | 78 ++++++++ 18 files changed, 1071 insertions(+) create mode 100644 src/MongoDB.Driver/DeserializeEJsonOptions.cs create mode 100644 src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Expressions/AstDeserializeEJsonExpression.cs create mode 100644 src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Expressions/AstSerializeEJsonExpression.cs create mode 100644 src/MongoDB.Driver/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodTranslators/DeserializeEJsonMethodToAggregationExpressionTranslator.cs create mode 100644 src/MongoDB.Driver/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodTranslators/SerializeEJsonMethodToAggregationExpressionTranslator.cs create mode 100644 src/MongoDB.Driver/SerializeEJsonOptions.cs create mode 100644 tests/MongoDB.Driver.Tests/Linq/Integration/MqlDeserializeEJsonTests.cs create mode 100644 tests/MongoDB.Driver.Tests/Linq/Integration/MqlSerializeEJsonTests.cs create mode 100644 tests/MongoDB.Driver.Tests/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodTranslators/DeserializeEJsonMethodToAggregationExpressionTranslatorTests.cs create mode 100644 tests/MongoDB.Driver.Tests/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodTranslators/SerializeEJsonMethodToAggregationExpressionTranslatorTests.cs diff --git a/src/MongoDB.Driver/Core/Misc/Feature.cs b/src/MongoDB.Driver/Core/Misc/Feature.cs index a201eec9cbf..7b5719602ed 100644 --- a/src/MongoDB.Driver/Core/Misc/Feature.cs +++ b/src/MongoDB.Driver/Core/Misc/Feature.cs @@ -59,6 +59,7 @@ public class Feature private static readonly Feature __dateFromStringFormatArgument = new Feature("DateFromStringFormatArgument", WireVersion.Server40); private static readonly Feature __dateOperatorsNewIn50 = new Feature("DateOperatorsNewIn50", WireVersion.Server50); private static readonly Feature __densifyStage = new Feature("DensifyStage", WireVersion.Server51); + private static readonly Feature __deserializeEJsonOperator = new Feature("DeserializeEJsonOperator", WireVersion.Server83); private static readonly Feature __documentsStage = new Feature("DocumentsStage", WireVersion.Server51); private static readonly Feature __directConnectionSetting = new Feature("DirectConnectionSetting", WireVersion.Server44); private static readonly Feature __electionIdPriorityInSDAM = new Feature("ElectionIdPriorityInSDAM ", WireVersion.Server60); @@ -99,6 +100,7 @@ public class Feature private static readonly Feature __setWindowFields = new Feature("SetWindowFields", WireVersion.Server50); private static readonly Feature __setWindowFieldsLocf = new Feature("SetWindowFieldsLocf", WireVersion.Server52); private static readonly Feature __shardedTransactions = new Feature("ShardedTransactions", WireVersion.Server42); + private static readonly Feature __serializeEJsonOperator = new Feature("SerializeEJsonOperator", WireVersion.Server83); private static readonly Feature __sigmoidOperator = new Feature("SigmoidOperator", WireVersion.Server81); private static readonly Feature __similarityFunctions = new Feature("SimilarityFunctions", WireVersion.Server82); private static readonly Feature __snapshotReads = new Feature("SnapshotReads", WireVersion.Server50, notSupportedMessage: "Snapshot reads require MongoDB 5.0 or later"); @@ -280,6 +282,11 @@ public class Feature /// public static Feature DensifyStage => __densifyStage; + /// + /// Gets the $deserializeEJSON operator feature. + /// + public static Feature DeserializeEJsonOperator => __deserializeEJsonOperator; + /// /// Gets the documents stage feature. /// @@ -468,6 +475,11 @@ public class Feature /// public static Feature ServerReturnsRetryableWriteErrorLabel => __serverReturnsRetryableWriteErrorLabel; + /// + /// Gets the $serializeEJSON operator feature. + /// + public static Feature SerializeEJsonOperator => __serializeEJsonOperator; + /// /// Gets the $set stage feature. /// diff --git a/src/MongoDB.Driver/DeserializeEJsonOptions.cs b/src/MongoDB.Driver/DeserializeEJsonOptions.cs new file mode 100644 index 00000000000..dc23ffeba92 --- /dev/null +++ b/src/MongoDB.Driver/DeserializeEJsonOptions.cs @@ -0,0 +1,55 @@ +/* Copyright 2010-present MongoDB Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +namespace MongoDB.Driver +{ + /// + /// Represents the options parameter for . + /// + public abstract class DeserializeEJsonOptions + { + internal abstract bool OnErrorWasSet(out object onError); + } + + /// + /// Represents the options parameter for . + /// This class allows to set 'onError'. + /// + /// The type of 'onError'. + public class DeserializeEJsonOptions : DeserializeEJsonOptions + { + private TOutput _onError; + private bool _onErrorWasSet; + + /// + /// The onError parameter. + /// + public TOutput OnError + { + get => _onError; + set + { + _onError = value; + _onErrorWasSet = true; + } + } + + internal override bool OnErrorWasSet(out object onError) + { + onError = _onError; + return _onErrorWasSet; + } + } +} diff --git a/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/AstNodeType.cs b/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/AstNodeType.cs index ace59a917e0..0984441ebdd 100644 --- a/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/AstNodeType.cs +++ b/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/AstNodeType.cs @@ -40,6 +40,7 @@ internal enum AstNodeType CountStage, CurrentOpStage, CustomAccumulatorExpression, + DeserializeEJsonExpression, DateAddExpression, DateDiffExpression, DateFromIsoWeekPartsExpression, @@ -130,6 +131,7 @@ internal enum AstNodeType ReplaceWithStage, RTrimExpression, SampleStage, + SerializeEJsonExpression, SetStage, SetWindowFieldsStage, ShiftWindowExpression, diff --git a/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Expressions/AstDeserializeEJsonExpression.cs b/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Expressions/AstDeserializeEJsonExpression.cs new file mode 100644 index 00000000000..508bc76e7cb --- /dev/null +++ b/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Expressions/AstDeserializeEJsonExpression.cs @@ -0,0 +1,69 @@ +/* Copyright 2010-present MongoDB Inc. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using MongoDB.Bson; +using MongoDB.Driver.Core.Misc; +using MongoDB.Driver.Linq.Linq3Implementation.Ast.Visitors; + +namespace MongoDB.Driver.Linq.Linq3Implementation.Ast.Expressions +{ + internal sealed class AstDeserializeEJsonExpression : AstExpression + { + private readonly AstExpression _input; + private readonly AstExpression _onError; + + public AstDeserializeEJsonExpression( + AstExpression input, + AstExpression onError = null) + { + _input = Ensure.IsNotNull(input, nameof(input)); + _onError = onError; + } + + public AstExpression Input => _input; + public override AstNodeType NodeType => AstNodeType.DeserializeEJsonExpression; + public AstExpression OnError => _onError; + + public override AstNode Accept(AstNodeVisitor visitor) + { + return visitor.VisitDeserializeEJsonExpression(this); + } + + public override BsonValue Render() + { + return new BsonDocument + { + { "$deserializeEJSON", new BsonDocument + { + { "input", _input.Render() }, + { "onError", () => _onError.Render(), _onError != null } + } + } + }; + } + + public AstDeserializeEJsonExpression Update( + AstExpression input, + AstExpression onError) + { + if (input == _input && onError == _onError) + { + return this; + } + + return new AstDeserializeEJsonExpression(input, onError); + } + } +} diff --git a/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Expressions/AstExpression.cs b/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Expressions/AstExpression.cs index 54462a0aa4d..5406c92bbec 100644 --- a/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Expressions/AstExpression.cs +++ b/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Expressions/AstExpression.cs @@ -303,6 +303,13 @@ public static AstExpression Convert( return new AstConvertExpression(input, to, subType, byteOrder, format, onError, onNull); } + public static AstExpression DeserializeEJson( + AstExpression input, + AstExpression onError = null) + { + return new AstDeserializeEJsonExpression(input, onError); + } + public static AstExpression DateAdd( AstExpression startDate, AstExpression unit, @@ -778,6 +785,14 @@ public static AstExpression SetUnion(params AstExpression[] args) return new AstNaryExpression(AstNaryOperator.SetUnion, args); } + public static AstExpression SerializeEJson( + AstExpression input, + AstExpression relaxed = null, + AstExpression onError = null) + { + return new AstSerializeEJsonExpression(input, relaxed, onError); + } + public static AstExpression ShiftWindowExpression(AstExpression arg, int by, AstExpression defaultValue) { return new AstShiftWindowExpression(arg, by, defaultValue); diff --git a/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Expressions/AstSerializeEJsonExpression.cs b/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Expressions/AstSerializeEJsonExpression.cs new file mode 100644 index 00000000000..146e8857cb5 --- /dev/null +++ b/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Expressions/AstSerializeEJsonExpression.cs @@ -0,0 +1,75 @@ +/* Copyright 2010-present MongoDB Inc. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using MongoDB.Bson; +using MongoDB.Driver.Core.Misc; +using MongoDB.Driver.Linq.Linq3Implementation.Ast.Visitors; + +namespace MongoDB.Driver.Linq.Linq3Implementation.Ast.Expressions +{ + internal sealed class AstSerializeEJsonExpression : AstExpression + { + private readonly AstExpression _input; + private readonly AstExpression _onError; + private readonly AstExpression _relaxed; + + public AstSerializeEJsonExpression( + AstExpression input, + AstExpression relaxed = null, + AstExpression onError = null) + { + _input = Ensure.IsNotNull(input, nameof(input)); + _relaxed = relaxed; + _onError = onError; + } + + public AstExpression Input => _input; + public override AstNodeType NodeType => AstNodeType.SerializeEJsonExpression; + public AstExpression OnError => _onError; + public AstExpression Relaxed => _relaxed; + + public override AstNode Accept(AstNodeVisitor visitor) + { + return visitor.VisitSerializeEJsonExpression(this); + } + + public override BsonValue Render() + { + return new BsonDocument + { + { "$serializeEJSON", new BsonDocument + { + { "input", _input.Render() }, + { "relaxed", () => _relaxed.Render(), _relaxed != null }, + { "onError", () => _onError.Render(), _onError != null } + } + } + }; + } + + public AstSerializeEJsonExpression Update( + AstExpression input, + AstExpression relaxed, + AstExpression onError) + { + if (input == _input && relaxed == _relaxed && onError == _onError) + { + return this; + } + + return new AstSerializeEJsonExpression(input, relaxed, onError); + } + } +} diff --git a/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Visitors/AstNodeVisitor.cs b/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Visitors/AstNodeVisitor.cs index 733cdb8c7bd..444f97c7195 100644 --- a/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Visitors/AstNodeVisitor.cs +++ b/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Visitors/AstNodeVisitor.cs @@ -234,6 +234,11 @@ public virtual AstNode VisitCountStage(AstCountStage node) return node; } + public virtual AstNode VisitDeserializeEJsonExpression(AstDeserializeEJsonExpression node) + { + return node.Update(VisitAndConvert(node.Input), VisitAndConvert(node.OnError)); + } + public virtual AstNode VisitCurrentOpStage(AstCurrentOpStage node) { return node; @@ -699,6 +704,11 @@ public virtual AstNode VisitSetWindowFieldsStage(AstSetWindowFieldsStage node) return node.Update(VisitAndConvert(node.PartitionBy), node.SortBy, VisitAndConvert(node.Output)); } + public virtual AstNode VisitSerializeEJsonExpression(AstSerializeEJsonExpression node) + { + return node.Update(VisitAndConvert(node.Input), VisitAndConvert(node.Relaxed), VisitAndConvert(node.OnError)); + } + public virtual AstNode VisitShiftWindowExpression(AstShiftWindowExpression node) { return node.Update(VisitAndConvert(node.Arg), node.By, VisitAndConvert(node.DefaultValue)); diff --git a/src/MongoDB.Driver/Linq/Linq3Implementation/Reflection/MqlMethod.cs b/src/MongoDB.Driver/Linq/Linq3Implementation/Reflection/MqlMethod.cs index 3a935cd8908..bf21d38a6f3 100644 --- a/src/MongoDB.Driver/Linq/Linq3Implementation/Reflection/MqlMethod.cs +++ b/src/MongoDB.Driver/Linq/Linq3Implementation/Reflection/MqlMethod.cs @@ -28,6 +28,7 @@ internal static class MqlMethod private static readonly MethodInfo __constantWithSerializer; private static readonly MethodInfo __convert; private static readonly MethodInfo __dateFromString; + private static readonly MethodInfo __deserializeEJson; private static readonly MethodInfo __dateFromStringWithFormat; private static readonly MethodInfo __dateFromStringWithFormatAndTimezone; private static readonly MethodInfo __dateFromStringWithFormatAndTimezoneAndOnErrorAndOnNull; @@ -35,6 +36,7 @@ internal static class MqlMethod private static readonly MethodInfo __field; private static readonly MethodInfo __isMissing; private static readonly MethodInfo __isNullOrMissing; + private static readonly MethodInfo __serializeEJson; private static readonly MethodInfo __sigmoid; private static readonly MethodInfo __subtype; @@ -52,6 +54,7 @@ static MqlMethod() __constantWithSerializer = ReflectionInfo.Method((object value, IBsonSerializer serializer) => Mql.Constant(value, serializer)); __convert = ReflectionInfo.Method((object value, ConvertOptions options) => Mql.Convert(value, options)); __dateFromString = ReflectionInfo.Method((string dateStringl) => Mql.DateFromString(dateStringl)); + __deserializeEJson = ReflectionInfo.Method((object value, DeserializeEJsonOptions options) => Mql.DeserializeEJson(value, options)); __dateFromStringWithFormat = ReflectionInfo.Method((string dateString, string format) => Mql.DateFromString(dateString, format)); __dateFromStringWithFormatAndTimezone = ReflectionInfo.Method((string dateString, string format, string timezone) => Mql.DateFromString(dateString, format, timezone)); __dateFromStringWithFormatAndTimezoneAndOnErrorAndOnNull = ReflectionInfo.Method((string dateString, string format, string timezone, DateTime? onError, DateTime? onNull) => Mql.DateFromString(dateString, format, timezone, onError, onNull)); @@ -59,6 +62,7 @@ static MqlMethod() __field = ReflectionInfo.Method((object container, string fieldName, IBsonSerializer serializer) => Mql.Field(container, fieldName, serializer)); __isMissing = ReflectionInfo.Method((object field) => Mql.IsMissing(field)); __isNullOrMissing = ReflectionInfo.Method((object field) => Mql.IsNullOrMissing(field)); + __serializeEJson = ReflectionInfo.Method((object value, SerializeEJsonOptions options) => Mql.SerializeEJson(value, options)); __sigmoid = ReflectionInfo.Method((double value) => Mql.Sigmoid(value)); __subtype = ReflectionInfo.Method((object value) => Mql.Subtype(value)); @@ -107,6 +111,7 @@ static MqlMethod() public static MethodInfo ConstantWithSerializer => __constantWithSerializer; public static MethodInfo Convert => __convert; public static MethodInfo DateFromString => __dateFromString; + public static MethodInfo DeserializeEJson => __deserializeEJson; public static MethodInfo DateFromStringWithFormat => __dateFromStringWithFormat; public static MethodInfo DateFromStringWithFormatAndTimezone => __dateFromStringWithFormatAndTimezone; public static MethodInfo DateFromStringWithFormatAndTimezoneAndOnErrorAndOnNull => __dateFromStringWithFormatAndTimezoneAndOnErrorAndOnNull; @@ -114,6 +119,7 @@ static MqlMethod() public static MethodInfo Field => __field; public static MethodInfo IsMissing => __isMissing; public static MethodInfo IsNullOrMissing => __isNullOrMissing; + public static MethodInfo SerializeEJson => __serializeEJson; public static MethodInfo Sigmoid => __sigmoid; public static MethodInfo Subtype => __subtype; diff --git a/src/MongoDB.Driver/Linq/Linq3Implementation/SerializerFinders/SerializerFinderVisitMethodCall.cs b/src/MongoDB.Driver/Linq/Linq3Implementation/SerializerFinders/SerializerFinderVisitMethodCall.cs index d7afec2bf46..a326dd2caf8 100644 --- a/src/MongoDB.Driver/Linq/Linq3Implementation/SerializerFinders/SerializerFinderVisitMethodCall.cs +++ b/src/MongoDB.Driver/Linq/Linq3Implementation/SerializerFinders/SerializerFinderVisitMethodCall.cs @@ -102,6 +102,7 @@ void DeduceMethodCallSerializers() case "Create": DeduceCreateMethodSerializers(); break; case "DateFromString": DeduceDateFromStringMethodSerializers(); break; case "DefaultIfEmpty": DeduceDefaultIfEmptyMethodSerializers(); break; + case "DeserializeEJson": DeduceDeserializeEJsonMethodSerializers(); break; case "DegreesToRadians": DeduceDegreesToRadiansMethodSerializers(); break; case "Densify": DeduceDensifyMethodSerializers(); break; case "Distinct": DeduceDistinctMethodSerializers(); break; @@ -137,6 +138,7 @@ void DeduceMethodCallSerializers() case "Select": DeduceSelectMethodSerializers(); break; case "SelectMany": DeduceSelectManySerializers(); break; case "SequenceEqual": DeduceSequenceEqualMethodSerializers(); break; + case "SerializeEJson": DeduceSerializeEJsonMethodSerializers(); break; case "SetEquals": DeduceSetEqualsMethodSerializers(); break; case "SetWindowFields": DeduceSetWindowFieldsMethodSerializers(); break; case "Shift": DeduceShiftMethodSerializers(); break; @@ -2633,6 +2635,40 @@ void DeduceSubtypeMethodSerializers() } } + void DeduceSerializeEJsonMethodSerializers() + { + if (method.Is(MqlMethod.SerializeEJson)) + { + if (IsNotKnown(node)) + { + var outputType = method.GetGenericArguments()[1]; + var outputSerializer = BsonSerializer.LookupSerializer(outputType); + AddNodeSerializer(node, outputSerializer); + } + } + else + { + DeduceUnknownMethodSerializer(); + } + } + + void DeduceDeserializeEJsonMethodSerializers() + { + if (method.Is(MqlMethod.DeserializeEJson)) + { + if (IsNotKnown(node)) + { + var outputType = method.GetGenericArguments()[1]; + var outputSerializer = BsonSerializer.LookupSerializer(outputType); + AddNodeSerializer(node, outputSerializer); + } + } + else + { + DeduceUnknownMethodSerializer(); + } + } + void DeduceSkipOrTakeMethodSerializers() { if (method.IsOneOf(EnumerableOrQueryableMethod.SkipOrTakeOverloads)) diff --git a/src/MongoDB.Driver/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodCallExpressionToAggregationExpressionTranslator.cs b/src/MongoDB.Driver/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodCallExpressionToAggregationExpressionTranslator.cs index 7dd42095e6b..698f68fc10f 100644 --- a/src/MongoDB.Driver/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodCallExpressionToAggregationExpressionTranslator.cs +++ b/src/MongoDB.Driver/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodCallExpressionToAggregationExpressionTranslator.cs @@ -44,6 +44,7 @@ public static TranslatedExpression Translate(TranslationContext context, MethodC case "Create": return CreateMethodToAggregationExpressionTranslator.Translate(context, expression); case "DateFromString": return DateFromStringMethodToAggregationExpressionTranslator.Translate(context, expression); case "DefaultIfEmpty": return DefaultIfEmptyMethodToAggregationExpressionTranslator.Translate(context, expression); + case "DeserializeEJson": return DeserializeEJsonMethodToAggregationExpressionTranslator.Translate(context, expression); case "DenseRank": return DenseRankMethodToAggregationExpressionTranslator.Translate(context, expression); case "Derivative": return DerivativeMethodToAggregationExpressionTranslator.Translate(context, expression); case "Distinct": return DistinctMethodToAggregationExpressionTranslator.Translate(context, expression); @@ -77,6 +78,7 @@ public static TranslatedExpression Translate(TranslationContext context, MethodC case "Select": return SelectMethodToAggregationExpressionTranslator.Translate(context, expression); case "SelectMany": return SelectManyMethodToAggregationExpressionTranslator.Translate(context, expression); case "SequenceEqual": return SequenceEqualMethodToAggregationExpressionTranslator.Translate(context, expression); + case "SerializeEJson": return SerializeEJsonMethodToAggregationExpressionTranslator.Translate(context, expression); case "SetEquals": return SetEqualsMethodToAggregationExpressionTranslator.Translate(context, expression); case "Shift": return ShiftMethodToAggregationExpressionTranslator.Translate(context, expression); case "Sigmoid": return SigmoidMethodToAggregationExpressionTranslator.Translate(context, expression); diff --git a/src/MongoDB.Driver/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodTranslators/DeserializeEJsonMethodToAggregationExpressionTranslator.cs b/src/MongoDB.Driver/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodTranslators/DeserializeEJsonMethodToAggregationExpressionTranslator.cs new file mode 100644 index 00000000000..cb35f8b2a1f --- /dev/null +++ b/src/MongoDB.Driver/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodTranslators/DeserializeEJsonMethodToAggregationExpressionTranslator.cs @@ -0,0 +1,113 @@ +/* Copyright 2010-present MongoDB Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System.Linq.Expressions; +using MongoDB.Bson.Serialization; +using MongoDB.Driver.Linq.Linq3Implementation.Ast.Expressions; +using MongoDB.Driver.Linq.Linq3Implementation.ExtensionMethods; +using MongoDB.Driver.Linq.Linq3Implementation.Misc; +using MongoDB.Driver.Linq.Linq3Implementation.Reflection; + +namespace MongoDB.Driver.Linq.Linq3Implementation.Translators.ExpressionToAggregationExpressionTranslators.MethodTranslators +{ + internal class DeserializeEJsonMethodToAggregationExpressionTranslator + { + public static TranslatedExpression Translate(TranslationContext context, MethodCallExpression expression) + { + var method = expression.Method; + var arguments = expression.Arguments; + + if (!method.Is(MqlMethod.DeserializeEJson)) + { + throw new ExpressionNotSupportedException(expression); + } + + var valueExpression = arguments[0]; + var optionsExpression = arguments[1]; + + var valueTranslation = ExpressionToAggregationExpressionTranslator.Translate(context, valueExpression); + var outputSerializer = context.GetSerializer(expression); + var onErrorAst = TranslateOptions(context, expression, optionsExpression, outputSerializer); + + var ast = AstExpression.DeserializeEJson(valueTranslation.Ast, onErrorAst); + return new TranslatedExpression(expression, ast, outputSerializer); + } + + private static AstExpression TranslateOptions( + TranslationContext context, + Expression expression, + Expression optionsExpression, + IBsonSerializer outputSerializer) + { + return optionsExpression switch + { + ConstantExpression constantExpression => TranslateOptions(constantExpression, outputSerializer), + MemberInitExpression memberInitExpression => TranslateOptions(context, expression, memberInitExpression, outputSerializer), + _ => throw new ExpressionNotSupportedException(optionsExpression, containingExpression: expression, because: "the options argument must be either a constant or a member initialization expression.") + }; + } + + private static AstExpression TranslateOptions( + ConstantExpression optionsExpression, + IBsonSerializer outputSerializer) + { + var options = (DeserializeEJsonOptions)optionsExpression.Value; + + AstExpression onErrorAst = null; + if (options != null) + { + if (options.OnErrorWasSet(out var onErrorValue)) + { + var serializedOnErrorValue = SerializationHelper.SerializeValue(outputSerializer, onErrorValue); + onErrorAst = AstExpression.Constant(serializedOnErrorValue); + } + } + + return onErrorAst; + } + + private static AstExpression TranslateOptions( + TranslationContext context, + Expression expression, + MemberInitExpression optionsExpression, + IBsonSerializer outputSerializer) + { + TranslatedExpression onErrorTranslation = null; + + foreach (var binding in optionsExpression.Bindings) + { + if (binding is not MemberAssignment memberAssignment) + { + throw new ExpressionNotSupportedException(optionsExpression, containingExpression: expression, because: "only member assignment is supported"); + } + + var memberName = memberAssignment.Member.Name; + var memberExpression = memberAssignment.Expression; + + switch (memberName) + { + case nameof(DeserializeEJsonOptions.OnError): + onErrorTranslation = ExpressionToAggregationExpressionTranslator.Translate(context, memberExpression); + SerializationHelper.EnsureSerializerIsCompatible(memberExpression, containingExpression: expression, onErrorTranslation.Serializer, expectedSerializer: outputSerializer); + break; + default: + throw new ExpressionNotSupportedException(memberExpression, because: $"memberName {memberName} is invalid"); + } + } + + return onErrorTranslation?.Ast; + } + } +} diff --git a/src/MongoDB.Driver/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodTranslators/SerializeEJsonMethodToAggregationExpressionTranslator.cs b/src/MongoDB.Driver/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodTranslators/SerializeEJsonMethodToAggregationExpressionTranslator.cs new file mode 100644 index 00000000000..d236b4d5f21 --- /dev/null +++ b/src/MongoDB.Driver/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodTranslators/SerializeEJsonMethodToAggregationExpressionTranslator.cs @@ -0,0 +1,128 @@ +/* Copyright 2010-present MongoDB Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System.Linq.Expressions; +using MongoDB.Bson.Serialization; +using MongoDB.Driver.Linq.Linq3Implementation.Ast.Expressions; +using MongoDB.Driver.Linq.Linq3Implementation.ExtensionMethods; +using MongoDB.Driver.Linq.Linq3Implementation.Misc; +using MongoDB.Driver.Linq.Linq3Implementation.Reflection; + +namespace MongoDB.Driver.Linq.Linq3Implementation.Translators.ExpressionToAggregationExpressionTranslators.MethodTranslators +{ + internal class SerializeEJsonMethodToAggregationExpressionTranslator + { + public static TranslatedExpression Translate(TranslationContext context, MethodCallExpression expression) + { + var method = expression.Method; + var arguments = expression.Arguments; + + if (!method.Is(MqlMethod.SerializeEJson)) + { + throw new ExpressionNotSupportedException(expression); + } + + var valueExpression = arguments[0]; + var optionsExpression = arguments[1]; + + var valueTranslation = ExpressionToAggregationExpressionTranslator.Translate(context, valueExpression); + var outputSerializer = context.GetSerializer(expression); + var (relaxedAst, onErrorAst) = TranslateOptions(context, expression, optionsExpression, outputSerializer); + + var ast = AstExpression.SerializeEJson(valueTranslation.Ast, relaxedAst, onErrorAst); + return new TranslatedExpression(expression, ast, outputSerializer); + } + + private static (AstExpression relaxedAst, AstExpression onErrorAst) + TranslateOptions( + TranslationContext context, + Expression expression, + Expression optionsExpression, + IBsonSerializer outputSerializer) + { + return optionsExpression switch + { + ConstantExpression constantExpression => TranslateOptions(constantExpression, outputSerializer), + MemberInitExpression memberInitExpression => TranslateOptions(context, expression, memberInitExpression, outputSerializer), + _ => throw new ExpressionNotSupportedException(optionsExpression, containingExpression: expression, because: "the options argument must be either a constant or a member initialization expression.") + }; + } + + private static (AstExpression relaxedAst, AstExpression onErrorAst) + TranslateOptions( + ConstantExpression optionsExpression, + IBsonSerializer outputSerializer) + { + var options = (SerializeEJsonOptions)optionsExpression.Value; + + AstExpression relaxedAst = null; + AstExpression onErrorAst = null; + if (options != null) + { + if (options.Relaxed.HasValue) + { + relaxedAst = AstExpression.Constant(options.Relaxed.Value); + } + + if (options.OnErrorWasSet(out var onErrorValue)) + { + var serializedOnErrorValue = SerializationHelper.SerializeValue(outputSerializer, onErrorValue); + onErrorAst = AstExpression.Constant(serializedOnErrorValue); + } + } + + return (relaxedAst, onErrorAst); + } + + private static (AstExpression relaxedAst, AstExpression onErrorAst) + TranslateOptions( + TranslationContext context, + Expression expression, + MemberInitExpression optionsExpression, + IBsonSerializer outputSerializer) + { + AstExpression relaxedAst = null; + TranslatedExpression onErrorTranslation = null; + + foreach (var binding in optionsExpression.Bindings) + { + if (binding is not MemberAssignment memberAssignment) + { + throw new ExpressionNotSupportedException(optionsExpression, containingExpression: expression, because: "only member assignment is supported"); + } + + var memberName = memberAssignment.Member.Name; + var memberExpression = memberAssignment.Expression; + + switch (memberName) + { + case nameof(SerializeEJsonOptions.Relaxed): + var relaxedExpression = memberExpression.IsConvert(out var unwrapped) ? unwrapped : memberExpression; + var relaxedValue = relaxedExpression.GetConstantValue(expression); + relaxedAst = AstExpression.Constant(relaxedValue); + break; + case nameof(SerializeEJsonOptions.OnError): + onErrorTranslation = ExpressionToAggregationExpressionTranslator.Translate(context, memberExpression); + SerializationHelper.EnsureSerializerIsCompatible(memberExpression, containingExpression: expression, onErrorTranslation.Serializer, expectedSerializer: outputSerializer); + break; + default: + throw new ExpressionNotSupportedException(memberExpression, because: $"memberName {memberName} is invalid"); + } + } + + return (relaxedAst, onErrorTranslation?.Ast); + } + } +} diff --git a/src/MongoDB.Driver/Mql.cs b/src/MongoDB.Driver/Mql.cs index af56a058295..845fc6c9aa5 100644 --- a/src/MongoDB.Driver/Mql.cs +++ b/src/MongoDB.Driver/Mql.cs @@ -64,6 +64,19 @@ public static TTo Convert(TFrom value, ConvertOptions options) throw CustomLinqExtensionMethodHelper.CreateNotSupportedException(); } + /// + /// Deserializes Extended JSON values back to native BSON types using the $deserializeEJSON aggregation operator. + /// + /// The type of the input value. + /// The type of the output value. + /// The value to deserialize. + /// The deserialization options. + /// The deserialized value. + public static TOutput DeserializeEJson(TInput value, DeserializeEJsonOptions options = null) + { + throw CustomLinqExtensionMethodHelper.CreateNotSupportedException(); + } + /// /// Converts a string to a DateTime using the $dateFromString aggregation operator. /// @@ -178,6 +191,19 @@ public static double Sigmoid(double value) throw CustomLinqExtensionMethodHelper.CreateNotSupportedException(); } + /// + /// Serializes BSON values to their Extended JSON v2 representation using the $serializeEJSON aggregation operator. + /// + /// The type of the input value. + /// The type of the output value. + /// The value to serialize. + /// The serialization options. + /// The serialized value. + public static TOutput SerializeEJson(TInput value, SerializeEJsonOptions options = null) + { + throw CustomLinqExtensionMethodHelper.CreateNotSupportedException(); + } + /// /// Translated to the "$similarityDotProduct" operator in MQL to measure the similarity between two vectors. /// diff --git a/src/MongoDB.Driver/SerializeEJsonOptions.cs b/src/MongoDB.Driver/SerializeEJsonOptions.cs new file mode 100644 index 00000000000..4b1ba99685a --- /dev/null +++ b/src/MongoDB.Driver/SerializeEJsonOptions.cs @@ -0,0 +1,66 @@ +/* Copyright 2010-present MongoDB Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +namespace MongoDB.Driver +{ + /// + /// Represents the options parameter for . + /// + public abstract class SerializeEJsonOptions + { + private bool? _relaxed; + + /// + /// The relaxed parameter. When true, produces relaxed Extended JSON format. When false, produces canonical format. Defaults to true. + /// + public bool? Relaxed + { + get => _relaxed; + set => _relaxed = value; + } + + internal abstract bool OnErrorWasSet(out object onError); + } + + /// + /// Represents the options parameter for . + /// This class allows to set 'onError'. + /// + /// The type of 'onError'. + public class SerializeEJsonOptions : SerializeEJsonOptions + { + private TOutput _onError; + private bool _onErrorWasSet; + + /// + /// The onError parameter. + /// + public TOutput OnError + { + get => _onError; + set + { + _onError = value; + _onErrorWasSet = true; + } + } + + internal override bool OnErrorWasSet(out object onError) + { + onError = _onError; + return _onErrorWasSet; + } + } +} diff --git a/tests/MongoDB.Driver.Tests/Linq/Integration/MqlDeserializeEJsonTests.cs b/tests/MongoDB.Driver.Tests/Linq/Integration/MqlDeserializeEJsonTests.cs new file mode 100644 index 00000000000..727036dc2fa --- /dev/null +++ b/tests/MongoDB.Driver.Tests/Linq/Integration/MqlDeserializeEJsonTests.cs @@ -0,0 +1,149 @@ +/* Copyright 2010-present MongoDB Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using MongoDB.Bson; +using MongoDB.Driver.Core.Misc; +using MongoDB.Driver.TestHelpers; +using Xunit; + +namespace MongoDB.Driver.Tests.Linq.Integration; + +public class MqlDeserializeEJsonTests : LinqIntegrationTest +{ + public MqlDeserializeEJsonTests(ClassFixture fixture) + : base(fixture, server => server.Supports(Feature.DeserializeEJsonOperator)) + { + } + + [Fact] + public void DeserializeEJson_should_convert_numberLong_wrapper_to_native_long() + { + var collection = Fixture.Collection; + var queryable = collection.AsQueryable() + .Where(d => d.Id == 1) + .Select(d => Mql.DeserializeEJson(d.EJsonValue, null)); + + var renderedStages = Translate(collection, queryable); + AssertStages( + renderedStages, + "{ '$match' : { '_id' : 1 } }", + "{ '$project' : { '_v' : { '$deserializeEJSON' : { 'input' : '$EJsonValue' } }, '_id' : 0 } }"); + + // { "$numberLong": "123" } should become NumberLong(123) + var result = queryable.Single(); + result.Should().Be(BsonValue.Create(123L)); + } + + [Fact] + public void DeserializeEJson_should_convert_numberInt_wrapper_to_native_int() + { + var collection = Fixture.Collection; + var queryable = collection.AsQueryable() + .Where(d => d.Id == 2) + .Select(d => Mql.DeserializeEJson(d.Value, null)); + + var renderedStages = Translate(collection, queryable); + AssertStages( + renderedStages, + "{ '$match' : { '_id' : 2 } }", + "{ '$project' : { '_v' : { '$deserializeEJSON' : { 'input' : '$Value' } }, '_id' : 0 } }"); + + // { "$numberInt": "42" } should become Int32(42) + var result = queryable.Single(); + result.Should().Be(BsonValue.Create(42)); + } + + [Fact] + public void DeserializeEJson_should_pass_through_plain_values() + { + var collection = Fixture.Collection; + var queryable = collection.AsQueryable() + .Where(d => d.Id == 3) + .Select(d => Mql.DeserializeEJson(d.Value, null)); + + var renderedStages = Translate(collection, queryable); + AssertStages( + renderedStages, + "{ '$match' : { '_id' : 3 } }", + "{ '$project' : { '_v' : { '$deserializeEJSON' : { 'input' : '$Value' } }, '_id' : 0 } }"); + + // A plain string without EJSON wrappers should pass through unchanged + var result = queryable.Single(); + result.Should().Be(BsonValue.Create("hello")); + } + + [Fact] + public void DeserializeEJson_should_convert_document_with_wrapped_fields() + { + var collection = Fixture.Collection; + var queryable = collection.AsQueryable() + .Where(d => d.Id == 4) + .Select(d => Mql.DeserializeEJson(d.EJsonValue, null)); + + var renderedStages = Translate(collection, queryable); + AssertStages( + renderedStages, + "{ '$match' : { '_id' : 4 } }", + "{ '$project' : { '_v' : { '$deserializeEJSON' : { 'input' : '$EJsonValue' } }, '_id' : 0 } }"); + + // { "a": { "$numberInt": "1" }, "b": { "$numberLong": "2" } } + // should become { a: 1, b: NumberLong(2) } + var result = queryable.Single(); + result["a"].Should().Be(BsonValue.Create(1)); + result["b"].Should().Be(BsonValue.Create(2L)); + } + + [Fact] + public void DeserializeEJson_with_onError_should_return_fallback_on_invalid_input() + { + var collection = Fixture.Collection; + // Id == 5 has invalid EJSON: { "$numberLong": "not_a_number" } + var queryable = collection.AsQueryable() + .Where(d => d.Id == 5) + .Select(d => Mql.DeserializeEJson(d.EJsonValue, new DeserializeEJsonOptions { OnError = "fallback" })); + + var renderedStages = Translate(collection, queryable); + AssertStages( + renderedStages, + "{ '$match' : { '_id' : 5 } }", + "{ '$project' : { '_v' : { '$deserializeEJSON' : { 'input' : '$EJsonValue', 'onError' : 'fallback' } }, '_id' : 0 } }"); + + // Invalid EJSON should trigger onError and return "fallback" + var result = queryable.Single(); + result.Should().Be(BsonValue.Create("fallback")); + } + + public class C + { + public int Id { get; set; } + public BsonDocument EJsonValue { get; set; } + public BsonValue Value { get; set; } + } + + public sealed class ClassFixture : MongoCollectionFixture + { + protected override IEnumerable InitialData => + [ + new() { Id = 1, EJsonValue = new BsonDocument("$numberLong", "123") }, + new() { Id = 2, Value = new BsonDocument("$numberInt", "42") }, + new() { Id = 3, Value = BsonValue.Create("hello") }, + new() { Id = 4, EJsonValue = new BsonDocument { { "a", new BsonDocument("$numberInt", "1") }, { "b", new BsonDocument("$numberLong", "2") } } }, + new() { Id = 5, EJsonValue = new BsonDocument("$numberLong", "not_a_number") }, + ]; + } +} diff --git a/tests/MongoDB.Driver.Tests/Linq/Integration/MqlSerializeEJsonTests.cs b/tests/MongoDB.Driver.Tests/Linq/Integration/MqlSerializeEJsonTests.cs new file mode 100644 index 00000000000..6f3fc8b5999 --- /dev/null +++ b/tests/MongoDB.Driver.Tests/Linq/Integration/MqlSerializeEJsonTests.cs @@ -0,0 +1,167 @@ +/* Copyright 2010-present MongoDB Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using MongoDB.Bson; +using MongoDB.Driver.Core.Misc; +using MongoDB.Driver.TestHelpers; +using Xunit; + +namespace MongoDB.Driver.Tests.Linq.Integration; + +public class MqlSerializeEJsonTests : LinqIntegrationTest +{ + public MqlSerializeEJsonTests(ClassFixture fixture) + : base(fixture, server => server.Supports(Feature.SerializeEJsonOperator)) + { + } + + [Fact] + public void SerializeEJson_with_no_options_should_use_relaxed_format() + { + var collection = Fixture.Collection; + var queryable = collection.AsQueryable() + .Where(d => d.Id == 1) + .Select(d => Mql.SerializeEJson(d.IntValue, null)); + + var renderedStages = Translate(collection, queryable); + AssertStages( + renderedStages, + "{ '$match' : { '_id' : 1 } }", + "{ '$project' : { '_v' : { '$serializeEJSON' : { 'input' : '$IntValue' } }, '_id' : 0 } }"); + + // Default is relaxed: int stays as int (no wrapper) + var result = queryable.Single(); + result.Should().Be(BsonValue.Create(42)); + } + + [Fact] + public void SerializeEJson_with_relaxed_false_should_produce_canonical_format() + { + var collection = Fixture.Collection; + var queryable = collection.AsQueryable() + .Where(d => d.Id == 1) + .Select(d => Mql.SerializeEJson(d.IntValue, new SerializeEJsonOptions { Relaxed = false })); + + var renderedStages = Translate(collection, queryable); + AssertStages( + renderedStages, + "{ '$match' : { '_id' : 1 } }", + "{ '$project' : { '_v' : { '$serializeEJSON' : { 'input' : '$IntValue', 'relaxed' : false } }, '_id' : 0 } }"); + + // Canonical: int gets wrapped as { "$numberInt": "42" } + // Can't use BsonDocument.Parse here because it interprets EJSON wrappers + var result = queryable.Single(); + result.Should().Be(new BsonDocument("$numberInt", "42")); + } + + [Fact] + public void SerializeEJson_with_relaxed_true() + { + var collection = Fixture.Collection; + var queryable = collection.AsQueryable() + .Where(d => d.Id == 1) + .Select(d => Mql.SerializeEJson(d.IntValue, new SerializeEJsonOptions { Relaxed = true })); + + var renderedStages = Translate(collection, queryable); + AssertStages( + renderedStages, + "{ '$match' : { '_id' : 1 } }", + "{ '$project' : { '_v' : { '$serializeEJSON' : { 'input' : '$IntValue', 'relaxed' : true } }, '_id' : 0 } }"); + + // Relaxed: int stays as int + var result = queryable.Single(); + result.Should().Be(BsonValue.Create(42)); + } + + [Fact] + public void SerializeEJson_with_document_input_canonical() + { + var collection = Fixture.Collection; + var queryable = collection.AsQueryable() + .Where(d => d.Id == 2) + .Select(d => Mql.SerializeEJson(d.Document, new SerializeEJsonOptions { Relaxed = false })); + + var renderedStages = Translate(collection, queryable); + AssertStages( + renderedStages, + "{ '$match' : { '_id' : 2 } }", + "{ '$project' : { '_v' : { '$serializeEJSON' : { 'input' : '$Document', 'relaxed' : false } }, '_id' : 0 } }"); + + // Document { a: 1 } in canonical should become { a: { "$numberInt": "1" } } + // Can't use BsonDocument.Parse here because it interprets EJSON wrappers + var result = queryable.Single(); + result.Should().Be(new BsonDocument("a", new BsonDocument("$numberInt", "1"))); + } + + [Fact] + public void SerializeEJson_with_long_value_canonical() + { + var collection = Fixture.Collection; + var queryable = collection.AsQueryable() + .Where(d => d.Id == 3) + .Select(d => Mql.SerializeEJson(d.LongValue, new SerializeEJsonOptions { Relaxed = false })); + + var renderedStages = Translate(collection, queryable); + AssertStages( + renderedStages, + "{ '$match' : { '_id' : 3 } }", + "{ '$project' : { '_v' : { '$serializeEJSON' : { 'input' : '$LongValue', 'relaxed' : false } }, '_id' : 0 } }"); + + // Can't use BsonDocument.Parse here because it interprets EJSON wrappers + var result = queryable.Single(); + result.Should().Be(new BsonDocument("$numberLong", "100")); + } + + // $serializeEJSON only errors on BSON depth/size limit violations, which are impractical to + // trigger in a test. This test verifies the server accepts the onError option without errors. + [Fact] + public void SerializeEJson_with_onError_should_return_serialized_value_when_no_error() + { + var collection = Fixture.Collection; + var queryable = collection.AsQueryable() + .Where(d => d.Id == 1) + .Select(d => Mql.SerializeEJson(d.IntValue, new SerializeEJsonOptions { Relaxed = false, OnError = "fallback" })); + + var renderedStages = Translate(collection, queryable); + AssertStages( + renderedStages, + "{ '$match' : { '_id' : 1 } }", + "{ '$project' : { '_v' : { '$serializeEJSON' : { 'input' : '$IntValue', 'relaxed' : false, 'onError' : 'fallback' } }, '_id' : 0 } }"); + + var result = queryable.Single(); + result.Should().Be(new BsonDocument("$numberInt", "42")); + } + + public class C + { + public int Id { get; set; } + public int IntValue { get; set; } + public long LongValue { get; set; } + public BsonDocument Document { get; set; } + } + + public sealed class ClassFixture : MongoCollectionFixture + { + protected override IEnumerable InitialData => + [ + new() { Id = 1, IntValue = 42 }, + new() { Id = 2, Document = new BsonDocument("a", 1) }, + new() { Id = 3, LongValue = 100L }, + ]; + } +} diff --git a/tests/MongoDB.Driver.Tests/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodTranslators/DeserializeEJsonMethodToAggregationExpressionTranslatorTests.cs b/tests/MongoDB.Driver.Tests/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodTranslators/DeserializeEJsonMethodToAggregationExpressionTranslatorTests.cs new file mode 100644 index 00000000000..b01be9542b8 --- /dev/null +++ b/tests/MongoDB.Driver.Tests/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodTranslators/DeserializeEJsonMethodToAggregationExpressionTranslatorTests.cs @@ -0,0 +1,62 @@ +/* Copyright 2010-present MongoDB Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System.Collections.Generic; +using System.Linq.Expressions; +using FluentAssertions; +using MongoDB.Bson; +using MongoDB.Driver.Linq; +using MongoDB.Driver.Linq.Linq3Implementation.Translators.ExpressionToAggregationExpressionTranslators.MethodTranslators; +using Xunit; + +namespace MongoDB.Driver.Tests.Linq.Linq3Implementation.Translators.ExpressionToAggregationExpressionTranslators.MethodTranslators; + +public class DeserializeEJsonMethodToAggregationExpressionTranslatorTests +{ + [Theory] + [MemberData(nameof(SupportedTestCases))] + public void Translate_should_produce_proper_ast(LambdaExpression expression, string expectedAst) + { + var translationContext = TestHelpers.CreateTranslationContext(expression); + var translation = DeserializeEJsonMethodToAggregationExpressionTranslator.Translate(translationContext, (MethodCallExpression)expression.Body); + + translation.Ast.Render().Should().Be(BsonDocument.Parse(expectedAst)); + } + + public static IEnumerable SupportedTestCases = + [ + // No options (defaults) + [ + TestHelpers.MakeLambda(model => Mql.DeserializeEJson(model.Document, null)), + "{ $deserializeEJSON : { input : { $getField : { field : 'Document', input : '$$ROOT' } } } }" + ], + // With BsonValue input + [ + TestHelpers.MakeLambda(model => Mql.DeserializeEJson(model.Value, null)), + "{ $deserializeEJSON : { input : { $getField : { field : 'Value', input : '$$ROOT' } } } }" + ], + // With onError + [ + TestHelpers.MakeLambda(model => Mql.DeserializeEJson(model.Document, new DeserializeEJsonOptions { OnError = "fallback" })), + "{ $deserializeEJSON : { input : { $getField : { field : 'Document', input : '$$ROOT' } }, onError : 'fallback' } }" + ], + ]; + + public class MyModel + { + public BsonDocument Document { get; set; } + public BsonValue Value { get; set; } + } +} diff --git a/tests/MongoDB.Driver.Tests/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodTranslators/SerializeEJsonMethodToAggregationExpressionTranslatorTests.cs b/tests/MongoDB.Driver.Tests/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodTranslators/SerializeEJsonMethodToAggregationExpressionTranslatorTests.cs new file mode 100644 index 00000000000..8edefac0723 --- /dev/null +++ b/tests/MongoDB.Driver.Tests/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodTranslators/SerializeEJsonMethodToAggregationExpressionTranslatorTests.cs @@ -0,0 +1,78 @@ +/* Copyright 2010-present MongoDB Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System.Collections.Generic; +using System.Linq.Expressions; +using FluentAssertions; +using MongoDB.Bson; +using MongoDB.Driver.Linq; +using MongoDB.Driver.Linq.Linq3Implementation.Translators.ExpressionToAggregationExpressionTranslators.MethodTranslators; +using Xunit; + +namespace MongoDB.Driver.Tests.Linq.Linq3Implementation.Translators.ExpressionToAggregationExpressionTranslators.MethodTranslators; + +public class SerializeEJsonMethodToAggregationExpressionTranslatorTests +{ + [Theory] + [MemberData(nameof(SupportedTestCases))] + public void Translate_should_produce_proper_ast(LambdaExpression expression, string expectedAst) + { + var translationContext = TestHelpers.CreateTranslationContext(expression); + var translation = SerializeEJsonMethodToAggregationExpressionTranslator.Translate(translationContext, (MethodCallExpression)expression.Body); + + translation.Ast.Render().Should().Be(BsonDocument.Parse(expectedAst)); + } + + public static IEnumerable SupportedTestCases = + [ + // No options (defaults) + [ + TestHelpers.MakeLambda(model => Mql.SerializeEJson(model.IntValue, null)), + "{ $serializeEJSON : { input : { $getField : { field : 'IntValue', input : '$$ROOT' } } } }" + ], + // With relaxed = false (canonical) + [ + TestHelpers.MakeLambda(model => Mql.SerializeEJson(model.IntValue, new SerializeEJsonOptions { Relaxed = false })), + "{ $serializeEJSON : { input : { $getField : { field : 'IntValue', input : '$$ROOT' } }, relaxed : false } }" + ], + // With relaxed = true + [ + TestHelpers.MakeLambda(model => Mql.SerializeEJson(model.IntValue, new SerializeEJsonOptions { Relaxed = true })), + "{ $serializeEJSON : { input : { $getField : { field : 'IntValue', input : '$$ROOT' } }, relaxed : true } }" + ], + // With string input + [ + TestHelpers.MakeLambda(model => Mql.SerializeEJson(model.StringValue, null)), + "{ $serializeEJSON : { input : { $getField : { field : 'StringValue', input : '$$ROOT' } } } }" + ], + // With BsonDocument input + [ + TestHelpers.MakeLambda(model => Mql.SerializeEJson(model.Document, null)), + "{ $serializeEJSON : { input : { $getField : { field : 'Document', input : '$$ROOT' } } } }" + ], + // With relaxed and onError + [ + TestHelpers.MakeLambda(model => Mql.SerializeEJson(model.IntValue, new SerializeEJsonOptions { Relaxed = false, OnError = "error" })), + "{ $serializeEJSON : { input : { $getField : { field : 'IntValue', input : '$$ROOT' } }, relaxed : false, onError : 'error' } }" + ], + ]; + + public class MyModel + { + public int IntValue { get; set; } + public string StringValue { get; set; } + public BsonDocument Document { get; set; } + } +} From 699598533d33e53cb616752dc733a075ab966258 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Wed, 1 Apr 2026 15:00:26 +0200 Subject: [PATCH 02/10] Small fix --- .../Linq/Integration/MqlSerializeEJsonTests.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/MongoDB.Driver.Tests/Linq/Integration/MqlSerializeEJsonTests.cs b/tests/MongoDB.Driver.Tests/Linq/Integration/MqlSerializeEJsonTests.cs index 6f3fc8b5999..b8049be3a31 100644 --- a/tests/MongoDB.Driver.Tests/Linq/Integration/MqlSerializeEJsonTests.cs +++ b/tests/MongoDB.Driver.Tests/Linq/Integration/MqlSerializeEJsonTests.cs @@ -64,7 +64,6 @@ public void SerializeEJson_with_relaxed_false_should_produce_canonical_format() "{ '$project' : { '_v' : { '$serializeEJSON' : { 'input' : '$IntValue', 'relaxed' : false } }, '_id' : 0 } }"); // Canonical: int gets wrapped as { "$numberInt": "42" } - // Can't use BsonDocument.Parse here because it interprets EJSON wrappers var result = queryable.Single(); result.Should().Be(new BsonDocument("$numberInt", "42")); } @@ -103,7 +102,6 @@ public void SerializeEJson_with_document_input_canonical() "{ '$project' : { '_v' : { '$serializeEJSON' : { 'input' : '$Document', 'relaxed' : false } }, '_id' : 0 } }"); // Document { a: 1 } in canonical should become { a: { "$numberInt": "1" } } - // Can't use BsonDocument.Parse here because it interprets EJSON wrappers var result = queryable.Single(); result.Should().Be(new BsonDocument("a", new BsonDocument("$numberInt", "1"))); } @@ -122,7 +120,6 @@ public void SerializeEJson_with_long_value_canonical() "{ '$match' : { '_id' : 3 } }", "{ '$project' : { '_v' : { '$serializeEJSON' : { 'input' : '$LongValue', 'relaxed' : false } }, '_id' : 0 } }"); - // Can't use BsonDocument.Parse here because it interprets EJSON wrappers var result = queryable.Single(); result.Should().Be(new BsonDocument("$numberLong", "100")); } From 24a2d6e567259d10aa3c17d4a07e8e98374ccd80 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:44:20 +0200 Subject: [PATCH 03/10] Added small test --- .../Integration/MqlDeserializeEJsonTests.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/MongoDB.Driver.Tests/Linq/Integration/MqlDeserializeEJsonTests.cs b/tests/MongoDB.Driver.Tests/Linq/Integration/MqlDeserializeEJsonTests.cs index 727036dc2fa..d022ca47d2f 100644 --- a/tests/MongoDB.Driver.Tests/Linq/Integration/MqlDeserializeEJsonTests.cs +++ b/tests/MongoDB.Driver.Tests/Linq/Integration/MqlDeserializeEJsonTests.cs @@ -108,6 +108,24 @@ public void DeserializeEJson_should_convert_document_with_wrapped_fields() result["b"].Should().Be(BsonValue.Create(2L)); } + [Fact] + public void DeserializeEJson_with_string_output_should_return_native_string() + { + var collection = Fixture.Collection; + var queryable = collection.AsQueryable() + .Where(d => d.Id == 3) + .Select(d => Mql.DeserializeEJson(d.Value, null)); + + var renderedStages = Translate(collection, queryable); + AssertStages( + renderedStages, + "{ '$match' : { '_id' : 3 } }", + "{ '$project' : { '_v' : { '$deserializeEJSON' : { 'input' : '$Value' } }, '_id' : 0 } }"); + + var result = queryable.Single(); + result.Should().Be("hello"); + } + [Fact] public void DeserializeEJson_with_onError_should_return_fallback_on_invalid_input() { From d40ed654663c6d8d10c662787127b2a81d1ff7dc Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:20:23 +0200 Subject: [PATCH 04/10] Ordering and comment correction. --- .../Linq3Implementation/Ast/AstNodeType.cs | 2 +- .../Ast/Visitors/AstNodeVisitor.cs | 20 +++++++++---------- .../Reflection/MqlMethod.cs | 6 +++--- src/MongoDB.Driver/SerializeEJsonOptions.cs | 2 +- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/AstNodeType.cs b/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/AstNodeType.cs index 0984441ebdd..0e2cc6e6d55 100644 --- a/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/AstNodeType.cs +++ b/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/AstNodeType.cs @@ -40,7 +40,6 @@ internal enum AstNodeType CountStage, CurrentOpStage, CustomAccumulatorExpression, - DeserializeEJsonExpression, DateAddExpression, DateDiffExpression, DateFromIsoWeekPartsExpression, @@ -53,6 +52,7 @@ internal enum AstNodeType DateTruncExpression, DensifyStage, DerivativeOrIntegralWindowExpression, + DeserializeEJsonExpression, DocumentsStage, ElemMatchFilterOperation, ExistsFilterOperation, diff --git a/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Visitors/AstNodeVisitor.cs b/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Visitors/AstNodeVisitor.cs index 444f97c7195..768acb2c9f5 100644 --- a/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Visitors/AstNodeVisitor.cs +++ b/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Visitors/AstNodeVisitor.cs @@ -234,11 +234,6 @@ public virtual AstNode VisitCountStage(AstCountStage node) return node; } - public virtual AstNode VisitDeserializeEJsonExpression(AstDeserializeEJsonExpression node) - { - return node.Update(VisitAndConvert(node.Input), VisitAndConvert(node.OnError)); - } - public virtual AstNode VisitCurrentOpStage(AstCurrentOpStage node) { return node; @@ -309,6 +304,11 @@ public virtual AstNode VisitDerivativeOrIntegralWindowExpression(AstDerivativeOr return node.Update(node.Operator, VisitAndConvert(node.Arg), node.Unit, node.Window); } + public virtual AstNode VisitDeserializeEJsonExpression(AstDeserializeEJsonExpression node) + { + return node.Update(VisitAndConvert(node.Input), VisitAndConvert(node.OnError)); + } + public virtual AstNode VisitDocumentsStage(AstDocumentsStage node) { return node.Update(VisitAndConvert(node.Documents)); @@ -694,6 +694,11 @@ public virtual AstNode VisitSampleStage(AstSampleStage node) return node; } + public virtual AstNode VisitSerializeEJsonExpression(AstSerializeEJsonExpression node) + { + return node.Update(VisitAndConvert(node.Input), VisitAndConvert(node.Relaxed), VisitAndConvert(node.OnError)); + } + public virtual AstNode VisitSetStage(AstSetStage node) { return node.Update(VisitAndConvert(node.Fields)); @@ -704,11 +709,6 @@ public virtual AstNode VisitSetWindowFieldsStage(AstSetWindowFieldsStage node) return node.Update(VisitAndConvert(node.PartitionBy), node.SortBy, VisitAndConvert(node.Output)); } - public virtual AstNode VisitSerializeEJsonExpression(AstSerializeEJsonExpression node) - { - return node.Update(VisitAndConvert(node.Input), VisitAndConvert(node.Relaxed), VisitAndConvert(node.OnError)); - } - public virtual AstNode VisitShiftWindowExpression(AstShiftWindowExpression node) { return node.Update(VisitAndConvert(node.Arg), node.By, VisitAndConvert(node.DefaultValue)); diff --git a/src/MongoDB.Driver/Linq/Linq3Implementation/Reflection/MqlMethod.cs b/src/MongoDB.Driver/Linq/Linq3Implementation/Reflection/MqlMethod.cs index bf21d38a6f3..2e691b6ea34 100644 --- a/src/MongoDB.Driver/Linq/Linq3Implementation/Reflection/MqlMethod.cs +++ b/src/MongoDB.Driver/Linq/Linq3Implementation/Reflection/MqlMethod.cs @@ -28,10 +28,10 @@ internal static class MqlMethod private static readonly MethodInfo __constantWithSerializer; private static readonly MethodInfo __convert; private static readonly MethodInfo __dateFromString; - private static readonly MethodInfo __deserializeEJson; private static readonly MethodInfo __dateFromStringWithFormat; private static readonly MethodInfo __dateFromStringWithFormatAndTimezone; private static readonly MethodInfo __dateFromStringWithFormatAndTimezoneAndOnErrorAndOnNull; + private static readonly MethodInfo __deserializeEJson; private static readonly MethodInfo __exists; private static readonly MethodInfo __field; private static readonly MethodInfo __isMissing; @@ -54,10 +54,10 @@ static MqlMethod() __constantWithSerializer = ReflectionInfo.Method((object value, IBsonSerializer serializer) => Mql.Constant(value, serializer)); __convert = ReflectionInfo.Method((object value, ConvertOptions options) => Mql.Convert(value, options)); __dateFromString = ReflectionInfo.Method((string dateStringl) => Mql.DateFromString(dateStringl)); - __deserializeEJson = ReflectionInfo.Method((object value, DeserializeEJsonOptions options) => Mql.DeserializeEJson(value, options)); __dateFromStringWithFormat = ReflectionInfo.Method((string dateString, string format) => Mql.DateFromString(dateString, format)); __dateFromStringWithFormatAndTimezone = ReflectionInfo.Method((string dateString, string format, string timezone) => Mql.DateFromString(dateString, format, timezone)); __dateFromStringWithFormatAndTimezoneAndOnErrorAndOnNull = ReflectionInfo.Method((string dateString, string format, string timezone, DateTime? onError, DateTime? onNull) => Mql.DateFromString(dateString, format, timezone, onError, onNull)); + __deserializeEJson = ReflectionInfo.Method((object value, DeserializeEJsonOptions options) => Mql.DeserializeEJson(value, options)); __exists = ReflectionInfo.Method((object field) => Mql.Exists(field)); __field = ReflectionInfo.Method((object container, string fieldName, IBsonSerializer serializer) => Mql.Field(container, fieldName, serializer)); __isMissing = ReflectionInfo.Method((object field) => Mql.IsMissing(field)); @@ -111,10 +111,10 @@ static MqlMethod() public static MethodInfo ConstantWithSerializer => __constantWithSerializer; public static MethodInfo Convert => __convert; public static MethodInfo DateFromString => __dateFromString; - public static MethodInfo DeserializeEJson => __deserializeEJson; public static MethodInfo DateFromStringWithFormat => __dateFromStringWithFormat; public static MethodInfo DateFromStringWithFormatAndTimezone => __dateFromStringWithFormatAndTimezone; public static MethodInfo DateFromStringWithFormatAndTimezoneAndOnErrorAndOnNull => __dateFromStringWithFormatAndTimezoneAndOnErrorAndOnNull; + public static MethodInfo DeserializeEJson => __deserializeEJson; public static MethodInfo Exists => __exists; public static MethodInfo Field => __field; public static MethodInfo IsMissing => __isMissing; diff --git a/src/MongoDB.Driver/SerializeEJsonOptions.cs b/src/MongoDB.Driver/SerializeEJsonOptions.cs index 4b1ba99685a..10809c4224e 100644 --- a/src/MongoDB.Driver/SerializeEJsonOptions.cs +++ b/src/MongoDB.Driver/SerializeEJsonOptions.cs @@ -23,7 +23,7 @@ public abstract class SerializeEJsonOptions private bool? _relaxed; /// - /// The relaxed parameter. When true, produces relaxed Extended JSON format. When false, produces canonical format. Defaults to true. + /// The relaxed parameter. When true, produces relaxed Extended JSON format. When false, produces canonical format. The server defaults to true when this is not specified. /// public bool? Relaxed { From 56582eb8fb7fccf99163d17f20ceb193bb402c0d Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:25:52 +0200 Subject: [PATCH 05/10] Ordering --- src/MongoDB.Driver/Core/Misc/Feature.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MongoDB.Driver/Core/Misc/Feature.cs b/src/MongoDB.Driver/Core/Misc/Feature.cs index 7b5719602ed..666b0fa46e6 100644 --- a/src/MongoDB.Driver/Core/Misc/Feature.cs +++ b/src/MongoDB.Driver/Core/Misc/Feature.cs @@ -96,11 +96,11 @@ public class Feature private static readonly Feature __scramSha256Authentication = new Feature("ScramSha256Authentication", WireVersion.Server40); private static readonly Feature __serverReturnsResumableChangeStreamErrorLabel = new Feature("ServerReturnsResumableChangeStreamErrorLabel", WireVersion.Server44); private static readonly Feature __serverReturnsRetryableWriteErrorLabel = new Feature("ServerReturnsRetryableWriteErrorLabel", WireVersion.Server44); + private static readonly Feature __serializeEJsonOperator = new Feature("SerializeEJsonOperator", WireVersion.Server83); private static readonly Feature __setStage = new Feature("SetStage", WireVersion.Server42); private static readonly Feature __setWindowFields = new Feature("SetWindowFields", WireVersion.Server50); private static readonly Feature __setWindowFieldsLocf = new Feature("SetWindowFieldsLocf", WireVersion.Server52); private static readonly Feature __shardedTransactions = new Feature("ShardedTransactions", WireVersion.Server42); - private static readonly Feature __serializeEJsonOperator = new Feature("SerializeEJsonOperator", WireVersion.Server83); private static readonly Feature __sigmoidOperator = new Feature("SigmoidOperator", WireVersion.Server81); private static readonly Feature __similarityFunctions = new Feature("SimilarityFunctions", WireVersion.Server82); private static readonly Feature __snapshotReads = new Feature("SnapshotReads", WireVersion.Server50, notSupportedMessage: "Snapshot reads require MongoDB 5.0 or later"); From 69eab7942012297b90ac8679cb21a1df210eb906 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:29:34 +0200 Subject: [PATCH 06/10] Fixed ordering 2 --- .../Ast/Expressions/AstExpression.cs | 30 ++++---- .../SerializerFinderVisitMethodCall.cs | 70 +++++++++---------- ...essionToAggregationExpressionTranslator.cs | 2 +- src/MongoDB.Driver/Mql.cs | 46 ++++++------ 4 files changed, 74 insertions(+), 74 deletions(-) diff --git a/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Expressions/AstExpression.cs b/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Expressions/AstExpression.cs index 5406c92bbec..7291d46d57a 100644 --- a/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Expressions/AstExpression.cs +++ b/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Expressions/AstExpression.cs @@ -303,13 +303,6 @@ public static AstExpression Convert( return new AstConvertExpression(input, to, subType, byteOrder, format, onError, onNull); } - public static AstExpression DeserializeEJson( - AstExpression input, - AstExpression onError = null) - { - return new AstDeserializeEJsonExpression(input, onError); - } - public static AstExpression DateAdd( AstExpression startDate, AstExpression unit, @@ -386,6 +379,13 @@ public static AstExpression DerivativeOrIntegralWindowExpression(AstDerivativeOr return new AstDerivativeOrIntegralWindowExpression(@operator, arg, unit, window); } + public static AstExpression DeserializeEJson( + AstExpression input, + AstExpression onError = null) + { + return new AstDeserializeEJsonExpression(input, onError); + } + public static AstExpression Divide(AstExpression arg1, AstExpression arg2) { if (arg1.IsConstant(out var constant1) && arg2.IsConstant(out var constant2)) @@ -750,6 +750,14 @@ public static AstExpression RTrim(AstExpression input, AstExpression chars = nul return new AstRTrimExpression(input, chars); } + public static AstExpression SerializeEJson( + AstExpression input, + AstExpression relaxed = null, + AstExpression onError = null) + { + return new AstSerializeEJsonExpression(input, relaxed, onError); + } + public static AstExpression SetDifference(AstExpression arg1, AstExpression arg2) { return new AstBinaryExpression(AstBinaryOperator.SetDifference, arg1, arg2); @@ -785,14 +793,6 @@ public static AstExpression SetUnion(params AstExpression[] args) return new AstNaryExpression(AstNaryOperator.SetUnion, args); } - public static AstExpression SerializeEJson( - AstExpression input, - AstExpression relaxed = null, - AstExpression onError = null) - { - return new AstSerializeEJsonExpression(input, relaxed, onError); - } - public static AstExpression ShiftWindowExpression(AstExpression arg, int by, AstExpression defaultValue) { return new AstShiftWindowExpression(arg, by, defaultValue); diff --git a/src/MongoDB.Driver/Linq/Linq3Implementation/SerializerFinders/SerializerFinderVisitMethodCall.cs b/src/MongoDB.Driver/Linq/Linq3Implementation/SerializerFinders/SerializerFinderVisitMethodCall.cs index a326dd2caf8..2877deb902d 100644 --- a/src/MongoDB.Driver/Linq/Linq3Implementation/SerializerFinders/SerializerFinderVisitMethodCall.cs +++ b/src/MongoDB.Driver/Linq/Linq3Implementation/SerializerFinders/SerializerFinderVisitMethodCall.cs @@ -102,9 +102,9 @@ void DeduceMethodCallSerializers() case "Create": DeduceCreateMethodSerializers(); break; case "DateFromString": DeduceDateFromStringMethodSerializers(); break; case "DefaultIfEmpty": DeduceDefaultIfEmptyMethodSerializers(); break; - case "DeserializeEJson": DeduceDeserializeEJsonMethodSerializers(); break; case "DegreesToRadians": DeduceDegreesToRadiansMethodSerializers(); break; case "Densify": DeduceDensifyMethodSerializers(); break; + case "DeserializeEJson": DeduceDeserializeEJsonMethodSerializers(); break; case "Distinct": DeduceDistinctMethodSerializers(); break; case "DocumentNumber": DeduceDocumentNumberMethodSerializers(); break; case "Documents": DeduceDocumentsMethodSerializers(); break; @@ -1167,6 +1167,23 @@ void DeduceDensifyMethodSerializers() } } + void DeduceDeserializeEJsonMethodSerializers() + { + if (method.Is(MqlMethod.DeserializeEJson)) + { + if (IsNotKnown(node)) + { + var outputType = method.GetGenericArguments()[1]; + var outputSerializer = BsonSerializer.LookupSerializer(outputType); + AddNodeSerializer(node, outputSerializer); + } + } + else + { + DeduceUnknownMethodSerializer(); + } + } + void DeduceDistinctMethodSerializers() { if (method.IsOneOf(EnumerableMethod.Distinct, QueryableMethod.Distinct)) @@ -2388,6 +2405,23 @@ void DeduceSequenceEqualMethodSerializers() } } + void DeduceSerializeEJsonMethodSerializers() + { + if (method.Is(MqlMethod.SerializeEJson)) + { + if (IsNotKnown(node)) + { + var outputType = method.GetGenericArguments()[1]; + var outputSerializer = BsonSerializer.LookupSerializer(outputType); + AddNodeSerializer(node, outputSerializer); + } + } + else + { + DeduceUnknownMethodSerializer(); + } + } + void DeduceSetEqualsMethodSerializers() { if (ISetMethod.IsSetEqualsMethod(method)) @@ -2635,40 +2669,6 @@ void DeduceSubtypeMethodSerializers() } } - void DeduceSerializeEJsonMethodSerializers() - { - if (method.Is(MqlMethod.SerializeEJson)) - { - if (IsNotKnown(node)) - { - var outputType = method.GetGenericArguments()[1]; - var outputSerializer = BsonSerializer.LookupSerializer(outputType); - AddNodeSerializer(node, outputSerializer); - } - } - else - { - DeduceUnknownMethodSerializer(); - } - } - - void DeduceDeserializeEJsonMethodSerializers() - { - if (method.Is(MqlMethod.DeserializeEJson)) - { - if (IsNotKnown(node)) - { - var outputType = method.GetGenericArguments()[1]; - var outputSerializer = BsonSerializer.LookupSerializer(outputType); - AddNodeSerializer(node, outputSerializer); - } - } - else - { - DeduceUnknownMethodSerializer(); - } - } - void DeduceSkipOrTakeMethodSerializers() { if (method.IsOneOf(EnumerableOrQueryableMethod.SkipOrTakeOverloads)) diff --git a/src/MongoDB.Driver/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodCallExpressionToAggregationExpressionTranslator.cs b/src/MongoDB.Driver/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodCallExpressionToAggregationExpressionTranslator.cs index 698f68fc10f..c53b746dc6f 100644 --- a/src/MongoDB.Driver/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodCallExpressionToAggregationExpressionTranslator.cs +++ b/src/MongoDB.Driver/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodCallExpressionToAggregationExpressionTranslator.cs @@ -44,9 +44,9 @@ public static TranslatedExpression Translate(TranslationContext context, MethodC case "Create": return CreateMethodToAggregationExpressionTranslator.Translate(context, expression); case "DateFromString": return DateFromStringMethodToAggregationExpressionTranslator.Translate(context, expression); case "DefaultIfEmpty": return DefaultIfEmptyMethodToAggregationExpressionTranslator.Translate(context, expression); - case "DeserializeEJson": return DeserializeEJsonMethodToAggregationExpressionTranslator.Translate(context, expression); case "DenseRank": return DenseRankMethodToAggregationExpressionTranslator.Translate(context, expression); case "Derivative": return DerivativeMethodToAggregationExpressionTranslator.Translate(context, expression); + case "DeserializeEJson": return DeserializeEJsonMethodToAggregationExpressionTranslator.Translate(context, expression); case "Distinct": return DistinctMethodToAggregationExpressionTranslator.Translate(context, expression); case "DocumentNumber": return DocumentNumberMethodToAggregationExpressionTranslator.Translate(context, expression); case "Equals": return EqualsMethodToAggregationExpressionTranslator.Translate(context, expression); diff --git a/src/MongoDB.Driver/Mql.cs b/src/MongoDB.Driver/Mql.cs index 845fc6c9aa5..1b5f77ced16 100644 --- a/src/MongoDB.Driver/Mql.cs +++ b/src/MongoDB.Driver/Mql.cs @@ -64,19 +64,6 @@ public static TTo Convert(TFrom value, ConvertOptions options) throw CustomLinqExtensionMethodHelper.CreateNotSupportedException(); } - /// - /// Deserializes Extended JSON values back to native BSON types using the $deserializeEJSON aggregation operator. - /// - /// The type of the input value. - /// The type of the output value. - /// The value to deserialize. - /// The deserialization options. - /// The deserialized value. - public static TOutput DeserializeEJson(TInput value, DeserializeEJsonOptions options = null) - { - throw CustomLinqExtensionMethodHelper.CreateNotSupportedException(); - } - /// /// Converts a string to a DateTime using the $dateFromString aggregation operator. /// @@ -134,6 +121,19 @@ public static DateTime DateFromString( throw CustomLinqExtensionMethodHelper.CreateNotSupportedException(); } + /// + /// Deserializes Extended JSON values back to native BSON types using the $deserializeEJSON aggregation operator. + /// + /// The type of the input value. + /// The type of the output value. + /// The value to deserialize. + /// The deserialization options. + /// The deserialized value. + public static TOutput DeserializeEJson(TInput value, DeserializeEJsonOptions options = null) + { + throw CustomLinqExtensionMethodHelper.CreateNotSupportedException(); + } + /// /// Tests whether a field exists. /// @@ -181,16 +181,6 @@ public static bool IsNullOrMissing(TField field) throw CustomLinqExtensionMethodHelper.CreateNotSupportedException(); } - /// - /// Transforms a real-valued input into a value between 0 and 1 using the $sigmoid operator. - /// - /// The input value. - /// The transformed value. - public static double Sigmoid(double value) - { - throw CustomLinqExtensionMethodHelper.CreateNotSupportedException(); - } - /// /// Serializes BSON values to their Extended JSON v2 representation using the $serializeEJSON aggregation operator. /// @@ -204,6 +194,16 @@ public static TOutput SerializeEJson(TInput value, SerializeEJs throw CustomLinqExtensionMethodHelper.CreateNotSupportedException(); } + /// + /// Transforms a real-valued input into a value between 0 and 1 using the $sigmoid operator. + /// + /// The input value. + /// The transformed value. + public static double Sigmoid(double value) + { + throw CustomLinqExtensionMethodHelper.CreateNotSupportedException(); + } + /// /// Translated to the "$similarityDotProduct" operator in MQL to measure the similarity between two vectors. /// From f577f29123ba91cebe4d7ef839727d6a72caffe4 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:42:01 +0200 Subject: [PATCH 07/10] Small fix --- src/MongoDB.Driver/DeserializeEJsonOptions.cs | 2 +- ...erializeEJsonMethodToAggregationExpressionTranslator.cs | 7 +++++-- src/MongoDB.Driver/SerializeEJsonOptions.cs | 2 +- ...izeEJsonMethodToAggregationExpressionTranslatorTests.cs | 5 +++++ 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/MongoDB.Driver/DeserializeEJsonOptions.cs b/src/MongoDB.Driver/DeserializeEJsonOptions.cs index dc23ffeba92..e989d4afce4 100644 --- a/src/MongoDB.Driver/DeserializeEJsonOptions.cs +++ b/src/MongoDB.Driver/DeserializeEJsonOptions.cs @@ -25,7 +25,7 @@ public abstract class DeserializeEJsonOptions /// /// Represents the options parameter for . - /// This class allows to set 'onError'. + /// This class allows setting 'onError'. /// /// The type of 'onError'. public class DeserializeEJsonOptions : DeserializeEJsonOptions diff --git a/src/MongoDB.Driver/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodTranslators/SerializeEJsonMethodToAggregationExpressionTranslator.cs b/src/MongoDB.Driver/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodTranslators/SerializeEJsonMethodToAggregationExpressionTranslator.cs index d236b4d5f21..6ba1d0fd235 100644 --- a/src/MongoDB.Driver/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodTranslators/SerializeEJsonMethodToAggregationExpressionTranslator.cs +++ b/src/MongoDB.Driver/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodTranslators/SerializeEJsonMethodToAggregationExpressionTranslator.cs @@ -110,8 +110,11 @@ private static (AstExpression relaxedAst, AstExpression onErrorAst) { case nameof(SerializeEJsonOptions.Relaxed): var relaxedExpression = memberExpression.IsConvert(out var unwrapped) ? unwrapped : memberExpression; - var relaxedValue = relaxedExpression.GetConstantValue(expression); - relaxedAst = AstExpression.Constant(relaxedValue); + var relaxedValue = relaxedExpression.GetConstantValue(expression); + if (relaxedValue.HasValue) + { + relaxedAst = AstExpression.Constant(relaxedValue.Value); + } break; case nameof(SerializeEJsonOptions.OnError): onErrorTranslation = ExpressionToAggregationExpressionTranslator.Translate(context, memberExpression); diff --git a/src/MongoDB.Driver/SerializeEJsonOptions.cs b/src/MongoDB.Driver/SerializeEJsonOptions.cs index 10809c4224e..734d4000681 100644 --- a/src/MongoDB.Driver/SerializeEJsonOptions.cs +++ b/src/MongoDB.Driver/SerializeEJsonOptions.cs @@ -36,7 +36,7 @@ public bool? Relaxed /// /// Represents the options parameter for . - /// This class allows to set 'onError'. + /// This class allows setting 'onError'. /// /// The type of 'onError'. public class SerializeEJsonOptions : SerializeEJsonOptions diff --git a/tests/MongoDB.Driver.Tests/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodTranslators/SerializeEJsonMethodToAggregationExpressionTranslatorTests.cs b/tests/MongoDB.Driver.Tests/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodTranslators/SerializeEJsonMethodToAggregationExpressionTranslatorTests.cs index 8edefac0723..705c94cf0b6 100644 --- a/tests/MongoDB.Driver.Tests/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodTranslators/SerializeEJsonMethodToAggregationExpressionTranslatorTests.cs +++ b/tests/MongoDB.Driver.Tests/Linq/Linq3Implementation/Translators/ExpressionToAggregationExpressionTranslators/MethodTranslators/SerializeEJsonMethodToAggregationExpressionTranslatorTests.cs @@ -62,6 +62,11 @@ public void Translate_should_produce_proper_ast(LambdaExpression expression, str TestHelpers.MakeLambda(model => Mql.SerializeEJson(model.Document, null)), "{ $serializeEJSON : { input : { $getField : { field : 'Document', input : '$$ROOT' } } } }" ], + // With relaxed = null (not specified, should not emit relaxed field) + [ + TestHelpers.MakeLambda(model => Mql.SerializeEJson(model.IntValue, new SerializeEJsonOptions { Relaxed = null })), + "{ $serializeEJSON : { input : { $getField : { field : 'IntValue', input : '$$ROOT' } } } }" + ], // With relaxed and onError [ TestHelpers.MakeLambda(model => Mql.SerializeEJson(model.IntValue, new SerializeEJsonOptions { Relaxed = false, OnError = "error" })), From 0f24a56eb46b4318be13e8d0fd86f24c3a4a0f83 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Wed, 15 Apr 2026 18:13:16 +0200 Subject: [PATCH 08/10] Using file-scoped namespace --- .../AstDeserializeEJsonExpression.cs | 73 +++++++++---------- 1 file changed, 35 insertions(+), 38 deletions(-) diff --git a/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Expressions/AstDeserializeEJsonExpression.cs b/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Expressions/AstDeserializeEJsonExpression.cs index 508bc76e7cb..deef39e418d 100644 --- a/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Expressions/AstDeserializeEJsonExpression.cs +++ b/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Expressions/AstDeserializeEJsonExpression.cs @@ -17,53 +17,50 @@ using MongoDB.Driver.Core.Misc; using MongoDB.Driver.Linq.Linq3Implementation.Ast.Visitors; -namespace MongoDB.Driver.Linq.Linq3Implementation.Ast.Expressions +namespace MongoDB.Driver.Linq.Linq3Implementation.Ast.Expressions; + +internal sealed class AstDeserializeEJsonExpression : AstExpression { - internal sealed class AstDeserializeEJsonExpression : AstExpression - { - private readonly AstExpression _input; - private readonly AstExpression _onError; + private readonly AstExpression _input; + private readonly AstExpression _onError; - public AstDeserializeEJsonExpression( - AstExpression input, - AstExpression onError = null) - { - _input = Ensure.IsNotNull(input, nameof(input)); - _onError = onError; - } + public AstDeserializeEJsonExpression( + AstExpression input, + AstExpression onError = null) + { + _input = Ensure.IsNotNull(input, nameof(input)); + _onError = onError; + } - public AstExpression Input => _input; - public override AstNodeType NodeType => AstNodeType.DeserializeEJsonExpression; - public AstExpression OnError => _onError; + public AstExpression Input => _input; + public override AstNodeType NodeType => AstNodeType.DeserializeEJsonExpression; + public AstExpression OnError => _onError; - public override AstNode Accept(AstNodeVisitor visitor) - { - return visitor.VisitDeserializeEJsonExpression(this); - } + public override AstNode Accept(AstNodeVisitor visitor) => + visitor.VisitDeserializeEJsonExpression(this); - public override BsonValue Render() + public override BsonValue Render() + { + return new BsonDocument { - return new BsonDocument - { - { "$deserializeEJSON", new BsonDocument - { - { "input", _input.Render() }, - { "onError", () => _onError.Render(), _onError != null } - } + { "$deserializeEJSON", new BsonDocument + { + { "input", _input.Render() }, + { "onError", () => _onError.Render(), _onError != null } } - }; - } - - public AstDeserializeEJsonExpression Update( - AstExpression input, - AstExpression onError) - { - if (input == _input && onError == _onError) - { - return this; } + }; + } - return new AstDeserializeEJsonExpression(input, onError); + public AstDeserializeEJsonExpression Update( + AstExpression input, + AstExpression onError) + { + if (input == _input && onError == _onError) + { + return this; } + + return new AstDeserializeEJsonExpression(input, onError); } } From f2d0754f5260463e7bbfbbc30ebf2767820f7be1 Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Thu, 16 Apr 2026 18:28:44 +0200 Subject: [PATCH 09/10] Moved to file scoped namespace --- .../AstSerializeEJsonExpression.cs | 85 +++++++++---------- 1 file changed, 41 insertions(+), 44 deletions(-) diff --git a/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Expressions/AstSerializeEJsonExpression.cs b/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Expressions/AstSerializeEJsonExpression.cs index 146e8857cb5..c058ae11f8e 100644 --- a/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Expressions/AstSerializeEJsonExpression.cs +++ b/src/MongoDB.Driver/Linq/Linq3Implementation/Ast/Expressions/AstSerializeEJsonExpression.cs @@ -17,59 +17,56 @@ using MongoDB.Driver.Core.Misc; using MongoDB.Driver.Linq.Linq3Implementation.Ast.Visitors; -namespace MongoDB.Driver.Linq.Linq3Implementation.Ast.Expressions +namespace MongoDB.Driver.Linq.Linq3Implementation.Ast.Expressions; + +internal sealed class AstSerializeEJsonExpression : AstExpression { - internal sealed class AstSerializeEJsonExpression : AstExpression - { - private readonly AstExpression _input; - private readonly AstExpression _onError; - private readonly AstExpression _relaxed; + private readonly AstExpression _input; + private readonly AstExpression _onError; + private readonly AstExpression _relaxed; - public AstSerializeEJsonExpression( - AstExpression input, - AstExpression relaxed = null, - AstExpression onError = null) - { - _input = Ensure.IsNotNull(input, nameof(input)); - _relaxed = relaxed; - _onError = onError; - } + public AstSerializeEJsonExpression( + AstExpression input, + AstExpression relaxed = null, + AstExpression onError = null) + { + _input = Ensure.IsNotNull(input, nameof(input)); + _relaxed = relaxed; + _onError = onError; + } - public AstExpression Input => _input; - public override AstNodeType NodeType => AstNodeType.SerializeEJsonExpression; - public AstExpression OnError => _onError; - public AstExpression Relaxed => _relaxed; + public AstExpression Input => _input; + public override AstNodeType NodeType => AstNodeType.SerializeEJsonExpression; + public AstExpression OnError => _onError; + public AstExpression Relaxed => _relaxed; - public override AstNode Accept(AstNodeVisitor visitor) - { - return visitor.VisitSerializeEJsonExpression(this); - } + public override AstNode Accept(AstNodeVisitor visitor) => + visitor.VisitSerializeEJsonExpression(this); - public override BsonValue Render() + public override BsonValue Render() + { + return new BsonDocument { - return new BsonDocument - { - { "$serializeEJSON", new BsonDocument - { - { "input", _input.Render() }, - { "relaxed", () => _relaxed.Render(), _relaxed != null }, - { "onError", () => _onError.Render(), _onError != null } - } + { "$serializeEJSON", new BsonDocument + { + { "input", _input.Render() }, + { "relaxed", () => _relaxed.Render(), _relaxed != null }, + { "onError", () => _onError.Render(), _onError != null } } - }; - } - - public AstSerializeEJsonExpression Update( - AstExpression input, - AstExpression relaxed, - AstExpression onError) - { - if (input == _input && relaxed == _relaxed && onError == _onError) - { - return this; } + }; + } - return new AstSerializeEJsonExpression(input, relaxed, onError); + public AstSerializeEJsonExpression Update( + AstExpression input, + AstExpression relaxed, + AstExpression onError) + { + if (input == _input && relaxed == _relaxed && onError == _onError) + { + return this; } + + return new AstSerializeEJsonExpression(input, relaxed, onError); } } From 6179004b0227e7ed3e7c1088549cc55cf09ec81b Mon Sep 17 00:00:00 2001 From: Ferdinando Papale <4850119+papafe@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:13:47 +0200 Subject: [PATCH 10/10] Added tests --- .../SerializerFinders/MqlEJsonTests.cs | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 tests/MongoDB.Driver.Tests/Linq/Linq3Implementation/SerializerFinders/MqlEJsonTests.cs diff --git a/tests/MongoDB.Driver.Tests/Linq/Linq3Implementation/SerializerFinders/MqlEJsonTests.cs b/tests/MongoDB.Driver.Tests/Linq/Linq3Implementation/SerializerFinders/MqlEJsonTests.cs new file mode 100644 index 00000000000..e59360a3a20 --- /dev/null +++ b/tests/MongoDB.Driver.Tests/Linq/Linq3Implementation/SerializerFinders/MqlEJsonTests.cs @@ -0,0 +1,156 @@ +/* Copyright 2010-present MongoDB Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System.Linq.Expressions; +using FluentAssertions; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; +using MongoDB.Driver.Linq.Linq3Implementation.SerializerFinders; +using Xunit; + +namespace MongoDB.Driver.Tests.Linq.Linq3Implementation.SerializerFinders; + +public class MqlEJsonTests +{ + [Fact] + public void SerializerFinder_should_resolve_mql_deserialize_ejson() + { + var expression = TestHelpers.MakeLambda(model => Mql.DeserializeEJson(model.StringField, null)); + var serializerMap = TestHelpers.CreateSerializerMap(expression); + + SerializerFinder.FindSerializers(expression.Body, null, serializerMap); + + serializerMap.IsKnown(expression.Body, out _).Should().BeTrue(); + serializerMap.GetSerializer(expression.Body).Should().BeOfType(); + } + + [Fact] + public void SerializerFinder_should_resolve_mql_serialize_ejson() + { + var expression = TestHelpers.MakeLambda(model => Mql.SerializeEJson(model.IntField, null)); + var serializerMap = TestHelpers.CreateSerializerMap(expression); + + SerializerFinder.FindSerializers(expression.Body, null, serializerMap); + + serializerMap.IsKnown(expression.Body, out _).Should().BeTrue(); + serializerMap.GetSerializer(expression.Body).Should().BeOfType(); + } + + [Fact] + public void SerializerFinder_should_resolve_mql_deserialize_ejson_in_member_init() + { + var expression = TestHelpers.MakeLambda( + model => new OutputModel { Document = Mql.DeserializeEJson(model.StringField, null) }); + var serializerMap = TestHelpers.CreateSerializerMap(expression); + + SerializerFinder.FindSerializers(expression.Body, null, serializerMap); + + var memberInit = (MemberInitExpression)expression.Body; + var memberAssignment = (MemberAssignment)memberInit.Bindings[0]; + var deserializeExpr = memberAssignment.Expression; + + serializerMap.IsKnown(deserializeExpr, out _).Should().BeTrue(); + serializerMap.GetSerializer(deserializeExpr).Should().BeOfType(); + } + + [Fact] + public void SerializerFinder_should_resolve_mql_serialize_ejson_in_member_init() + { + var expression = TestHelpers.MakeLambda( + model => new OutputModel { Document = Mql.SerializeEJson(model.IntField, null) }); + var serializerMap = TestHelpers.CreateSerializerMap(expression); + + SerializerFinder.FindSerializers(expression.Body, null, serializerMap); + + var memberInit = (MemberInitExpression)expression.Body; + var memberAssignment = (MemberAssignment)memberInit.Bindings[0]; + var serializeExpr = memberAssignment.Expression; + + serializerMap.IsKnown(serializeExpr, out _).Should().BeTrue(); + serializerMap.GetSerializer(serializeExpr).Should().BeOfType(); + } + + [Fact] + public void SerializerFinder_should_use_custom_serializer_from_parent_for_mql_deserialize_ejson() + { + var expression = TestHelpers.MakeLambda( + model => new OutputModel { Document = Mql.DeserializeEJson(model.StringField, null) }); + var serializerMap = TestHelpers.CreateSerializerMap(expression); + + // Pre-assign a custom serializer to the MemberInit node (simulating parent context) + var customDocSerializer = new CustomBsonDocumentSerializer(); + var outputSerializer = CreateOutputModelSerializer(customDocSerializer); + serializerMap.AddSerializer(expression.Body, outputSerializer); + + SerializerFinder.FindSerializers(expression.Body, null, serializerMap); + + var memberInit = (MemberInitExpression)expression.Body; + var memberAssignment = (MemberAssignment)memberInit.Bindings[0]; + var deserializeExpr = memberAssignment.Expression; + + serializerMap.GetSerializer(deserializeExpr).Should().BeSameAs(customDocSerializer); + } + + [Fact] + public void SerializerFinder_should_use_custom_serializer_from_parent_for_mql_serialize_ejson() + { + var expression = TestHelpers.MakeLambda( + model => new OutputModel { Document = Mql.SerializeEJson(model.IntField, null) }); + var serializerMap = TestHelpers.CreateSerializerMap(expression); + + // Pre-assign a custom serializer to the MemberInit node (simulating parent context) + var customDocSerializer = new CustomBsonDocumentSerializer(); + var outputSerializer = CreateOutputModelSerializer(customDocSerializer); + serializerMap.AddSerializer(expression.Body, outputSerializer); + + SerializerFinder.FindSerializers(expression.Body, null, serializerMap); + + var memberInit = (MemberInitExpression)expression.Body; + var memberAssignment = (MemberAssignment)memberInit.Bindings[0]; + var serializeExpr = memberAssignment.Expression; + + serializerMap.GetSerializer(serializeExpr).Should().BeSameAs(customDocSerializer); + } + + private static IBsonSerializer CreateOutputModelSerializer(IBsonSerializer documentMemberSerializer) + { + var classMap = new BsonClassMap(); + classMap.AutoMap(); + classMap.GetMemberMap(nameof(OutputModel.Document)).SetSerializer(documentMemberSerializer); + classMap.Freeze(); + return new BsonClassMapSerializer(classMap); + } + + private class MyModel + { + public string StringField { get; set; } + public int IntField { get; set; } + } + + private class OutputModel + { + public BsonDocument Document { get; set; } + } + + private class CustomBsonDocumentSerializer : SerializerBase + { + public override BsonDocument Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) + => BsonDocumentSerializer.Instance.Deserialize(context, args); + + public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, BsonDocument value) + => BsonDocumentSerializer.Instance.Serialize(context, args, value); + } +}