CSHARP-5916: ExpressionNotSupportedException when a $set stage expression references a member of a captured constant#1908
CSHARP-5916: ExpressionNotSupportedException when a $set stage expression references a member of a captured constant#1908sanych-sun wants to merge 4 commits intomongodb:mainfrom
Conversation
…sion references a member of a captured constant
| } | ||
| SerializerFinder.FindSerializers(expression, translationOptions, nodeSerializers); | ||
|
|
||
| var context = TranslationContext.Create(translationOptions, nodeSerializers); // do not partially evaluate expression |
There was a problem hiding this comment.
We should not run partial evaluator too early, it is postponed and basically executed per member (see ExpressionToSetStageTranslator). PartialEvaluator could rewrite part of the expression, so we should not run SerializerFinder here too.
There was a problem hiding this comment.
And the constant returned from the partial evaluator gets the serializer finder run on it right?
There was a problem hiding this comment.
Pull request overview
Adjusts LINQ3 $set stage translation to avoid ExpressionNotSupportedException when a $set expression references a member of a captured constant, by ensuring serializer discovery happens after partial evaluation of the relevant expressions.
Changes:
- Reworked
$setstage translation flow to invoke serializer finding after preprocessing/partial evaluation (via newExpressionToSetStageTranslator.Translatesignature). - Updated
TranslationContext.Createoverload to acceptIEnumerable<(Expression, IBsonSerializer)>and made the internal factory overload private. - Added an integration test covering conditional nested object assignment in a pipeline update.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| tests/MongoDB.Driver.Tests/Linq/Linq3Implementation/Jira/CSharp5916Tests.cs | Adds regression test reproducing and validating the $set translation scenario. |
| src/MongoDB.Driver/Linq/LinqProviderAdapter.cs | Switches $set translation to new translator entrypoint (context construction moved/hidden). |
| src/MongoDB.Driver/Linq/Linq3Implementation/Translators/TranslationContext.cs | Tweaks Create overload signature to accept IEnumerable and narrows internal factory visibility. |
| src/MongoDB.Driver/Linq/Linq3Implementation/Translators/ExpressionToSetStageTranslators/ExpressionToSetStageTranslator.cs | Reworks computed-field translation to preprocess before serializer finding and to build translation context differently. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
...Implementation/Translators/ExpressionToSetStageTranslators/ExpressionToSetStageTranslator.cs
Outdated
Show resolved
Hide resolved
...Implementation/Translators/ExpressionToSetStageTranslators/ExpressionToSetStageTranslator.cs
Outdated
Show resolved
Hide resolved
| var context = TranslationContext.Create(valueExpression, initialSerializers, translationOptions); | ||
|
|
There was a problem hiding this comment.
CreateComputedField creates a new TranslationContext (and runs SerializerFinder) per assigned member. If a $set stage initializes many members this becomes repeated work. Consider preprocessing the relevant expressions and building a single TranslationContext for the whole $set lambda, then reusing it across fields to keep translation cost closer to O(1) per field.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| valueExpression = LinqExpressionPreprocessor.Preprocess(valueExpression); | ||
| var elementName = member.Name; | ||
|
|
||
| var rootDocumentParameter = rootExpression.Parameters.Single(); | ||
| var initialSerializers = new List<(Expression Node, IBsonSerializer Serializer)> { (rootDocumentParameter, documentSerializer) }; | ||
| if (documentSerializer.TryGetMemberSerializationInfo(member.Name, out var serializationInfo)) | ||
| { | ||
| elementName = serializationInfo.ElementName; | ||
| var memberSerializer = serializationInfo.Serializer; | ||
|
|
||
| if (valueExpression is ConstantExpression constantValueExpression) | ||
| { | ||
| var value = constantValueExpression.Value; | ||
| var serializedValue = SerializationHelper.SerializeValue(memberSerializer, value); | ||
| valueAst = AstExpression.Constant(serializedValue); | ||
| var serializedValue = SerializationHelper.SerializeValue(serializationInfo.Serializer, value); | ||
|
|
||
| return AstExpression.ComputedField(elementName, AstExpression.Constant(serializedValue)); | ||
| } | ||
| else | ||
|
|
||
| if (valueExpression.Type == serializationInfo.Serializer.ValueType) | ||
| { | ||
| var valueTranslation = ExpressionToAggregationExpressionTranslator.Translate(context, valueExpression); | ||
| ThrowIfMemberAndValueSerializersAreNotCompatible(valueExpression, memberSerializer, valueTranslation.Serializer); | ||
| valueAst = valueTranslation.Ast; | ||
| initialSerializers.Add((valueExpression, serializationInfo.Serializer)); | ||
| } | ||
| } | ||
| else | ||
|
|
||
| var context = TranslationContext.Create(valueExpression, initialSerializers, translationOptions); | ||
| var symbol = context.CreateRootSymbol(rootDocumentParameter, documentSerializer); | ||
| context = context.WithSymbol(symbol); | ||
|
|
||
| var valueTranslation = ExpressionToAggregationExpressionTranslator.Translate(context, valueExpression); |
There was a problem hiding this comment.
CreateComputedField preprocesses and then constructs a new TranslationContext (including SerializerFinder.FindSerializers) for every assigned member. Compared to the previous implementation (single serializer-finder pass for the whole $set lambda), this can become O(N) serializer-finder runs for N fields and may noticeably slow translation for updates that set many fields. Consider preprocessing the full $set lambda once up-front, then running SerializerFinder once and reusing a single TranslationContext (or at least reusing a shared serializer map) across all computed fields while still ensuring the serializer-finder runs after partial evaluation.
There was a problem hiding this comment.
We cannot have the shared SerializerMap, because as a part of LinqExpressionPreprocessor (which is done for each member expression) we are doing partial evaluation which can rewrite the expression what means pre-created serializerMap could be outdated after the LinqExpressionPreprocessor.
There was a problem hiding this comment.
Canb't we just pre-process without the serializer map and then run the serializer on the constant node resulting from the preprocessor/partial evaluator? The partial evaluator shouldn't need access.to serializers.
| valueExpression = LinqExpressionPreprocessor.Preprocess(valueExpression); | ||
| var elementName = member.Name; | ||
|
|
||
| var rootDocumentParameter = rootExpression.Parameters.Single(); | ||
| var initialSerializers = new List<(Expression Node, IBsonSerializer Serializer)> { (rootDocumentParameter, documentSerializer) }; | ||
| if (documentSerializer.TryGetMemberSerializationInfo(member.Name, out var serializationInfo)) | ||
| { | ||
| elementName = serializationInfo.ElementName; | ||
| var memberSerializer = serializationInfo.Serializer; | ||
|
|
||
| if (valueExpression is ConstantExpression constantValueExpression) | ||
| { | ||
| var value = constantValueExpression.Value; | ||
| var serializedValue = SerializationHelper.SerializeValue(memberSerializer, value); | ||
| valueAst = AstExpression.Constant(serializedValue); | ||
| var serializedValue = SerializationHelper.SerializeValue(serializationInfo.Serializer, value); | ||
|
|
||
| return AstExpression.ComputedField(elementName, AstExpression.Constant(serializedValue)); | ||
| } | ||
| else | ||
|
|
||
| if (valueExpression.Type == serializationInfo.Serializer.ValueType) | ||
| { | ||
| var valueTranslation = ExpressionToAggregationExpressionTranslator.Translate(context, valueExpression); | ||
| ThrowIfMemberAndValueSerializersAreNotCompatible(valueExpression, memberSerializer, valueTranslation.Serializer); | ||
| valueAst = valueTranslation.Ast; | ||
| initialSerializers.Add((valueExpression, serializationInfo.Serializer)); | ||
| } | ||
| } | ||
| else | ||
|
|
||
| var context = TranslationContext.Create(valueExpression, initialSerializers, translationOptions); | ||
| var symbol = context.CreateRootSymbol(rootDocumentParameter, documentSerializer); | ||
| context = context.WithSymbol(symbol); | ||
|
|
||
| var valueTranslation = ExpressionToAggregationExpressionTranslator.Translate(context, valueExpression); |
There was a problem hiding this comment.
Canb't we just pre-process without the serializer map and then run the serializer on the constant node resulting from the preprocessor/partial evaluator? The partial evaluator shouldn't need access.to serializers.
| } | ||
| SerializerFinder.FindSerializers(expression, translationOptions, nodeSerializers); | ||
|
|
||
| var context = TranslationContext.Create(translationOptions, nodeSerializers); // do not partially evaluate expression |
There was a problem hiding this comment.
And the constant returned from the partial evaluator gets the serializer finder run on it right?
| for (var i = 0; i < members.Count; i++) | ||
| { | ||
| var member = members[i]; | ||
| var valueExpression = LinqExpressionPreprocessor.Preprocess(arguments[i]); |
There was a problem hiding this comment.
So the pre-process doesn't happen here any more, it happens up front before all this processing?
| { | ||
| string elementName; | ||
| AstExpression valueAst; | ||
| valueExpression = LinqExpressionPreprocessor.Preprocess(valueExpression); |
There was a problem hiding this comment.
So this is now called before the other paths? Kind of hard to follow in a PR.
The root cause of the issue was in the fact that for $set translation we postponed partial evaluator execution and used to run SerializerFinder actually before the partial evaluator. Because of that fact we could run into a situation when serializer finder resolved some serializers, but partial evaluator has rewritten the expression so part of the expression will not be covered by serializer map anymore.
In the PR I've rewritten $set translation to run the serializer finder just after the partial evaluator.