Skip to content

CSHARP-5916: ExpressionNotSupportedException when a $set stage expression references a member of a captured constant#1908

Open
sanych-sun wants to merge 4 commits intomongodb:mainfrom
sanych-sun:csharp5916
Open

CSHARP-5916: ExpressionNotSupportedException when a $set stage expression references a member of a captured constant#1908
sanych-sun wants to merge 4 commits intomongodb:mainfrom
sanych-sun:csharp5916

Conversation

@sanych-sun
Copy link
Member

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.

…sion references a member of a captured constant
@sanych-sun sanych-sun requested a review from a team as a code owner March 10, 2026 17:13
@sanych-sun sanych-sun added the bug Fixes issues or unintended behavior. label Mar 10, 2026
@sanych-sun sanych-sun requested review from adelinowona, Copilot, damieng and papafe and removed request for papafe March 10, 2026 17:13
}
SerializerFinder.FindSerializers(expression, translationOptions, nodeSerializers);

var context = TranslationContext.Create(translationOptions, nodeSerializers); // do not partially evaluate expression
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And the constant returned from the partial evaluator gets the serializer finder run on it right?

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 $set stage translation flow to invoke serializer finding after preprocessing/partial evaluation (via new ExpressionToSetStageTranslator.Translate signature).
  • Updated TranslationContext.Create overload to accept IEnumerable<(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.

Comment on lines +153 to +154
var context = TranslationContext.Create(valueExpression, initialSerializers, translationOptions);

Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +140 to +167
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);
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +140 to +167
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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is now called before the other paths? Kind of hard to follow in a PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Fixes issues or unintended behavior.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants