Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CSHARP-5552: Add support for $convert in LINQ #1659

Open
wants to merge 81 commits into
base: main
Choose a base branch
from

Conversation

papafe
Copy link
Contributor

@papafe papafe commented Apr 1, 2025

No description provided.

return (T)constantExpression.Value;
}

private static BsonType GetBsonType(Type type) //TODO Do we have this kind of info somewhere else...?
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Do we have this kind of mapping somewhere else in our code?

Copy link
Contributor

Choose a reason for hiding this comment

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

I can't find any.

There probably is nowhere else in the code where we need to map a C# type to a BsonType like this. Everywhere else it is up to the serializer to determine how the C# type is represented.

This is an unusual case because we decide to let the user express the C# return type using the TTo generic type parameter, and we let the C# type imply the BsonType.

@papafe papafe requested a review from rstam April 3, 2025 13:52
@papafe papafe marked this pull request as ready for review April 3, 2025 16:11
@papafe papafe requested a review from a team as a code owner April 3, 2025 16:11
toValue != null &&
onError == null &&
onNull == null)
if (onError == null && onNull == null && subType == null && format == null && byteOrder == null &&
Copy link
Contributor

Choose a reason for hiding this comment

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

Consider formatting with one condition per line to make it easier to read:

if (to is AstConstantExpression toConstantExpression &&
    (toConstantExpression.Value as BsonString)?.Value is { } toValue &&
    subType == null &&                                                 
    byteOrder == null &&                                               
    format == null &&                                                  
    onError == null &&                                                 
    onNull == null)                                                    

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

@@ -14,7 +14,6 @@
*/

using System.Linq.Expressions;
using MongoDB.Bson.IO;
Copy link
Contributor

Choose a reason for hiding this comment

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

Unnecessary diff.

Copy link
Contributor Author

@papafe papafe Apr 7, 2025

Choose a reason for hiding this comment

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

I noticed it while I was looking at the file. It's a very small change, do we really need to open another PR to fix this?
This is valid also for the other small changes unrelated to the PR.

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not suggesting we need another PR for this... I'm just saying we shouldn't modify files that have nothing at all to do with this PR.

This file can be "cleaned" up some future day when we are doing some work that actually involves this file.

Copy link
Contributor

Choose a reason for hiding this comment

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

What is your current thinking on this unnecessary diff? Can it be removed from this PR?

/// <summary>
/// Represents the options parameter for <see cref="Mql.Convert{TFrom, TTo}(TFrom, ConvertOptions{TTo})"/>.
/// </summary>
public abstract class ConvertOptions
Copy link
Contributor

Choose a reason for hiding this comment

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

This should probably be in a its own file called ConvertOptions.cs.

Along with ByteOrder (unless you think ByteOrder should be in its own file).

return new TranslatedExpression(expression, ast, serializer);
}

private static void ExtractOptionsFromConstantExpression(ConstantExpression constantExpression, out ByteOrder? byteOrder, out string format, out AstExpression onErrorAst, out AstExpression onNullAst, out BsonBinarySubType? subType)
Copy link
Contributor

Choose a reason for hiding this comment

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

See suggested TranslateOptions above.

byteOrder = options.ByteOrder;
}

private static void ExtractOptionsFromMemberInitExpression(MemberInitExpression memberInitExpression, TranslationContext context, out ByteOrder? byteOrder, out string format, out AstExpression onErrorAst, out AstExpression onNullAst, out BsonBinarySubType? subType)
Copy link
Contributor

Choose a reason for hiding this comment

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

See suggested TranslateOptions above.

}
}

private static T GetConstantValue<T>(Expression expression, string fieldName)
Copy link
Contributor

Choose a reason for hiding this comment

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

Use existing expression.GetConstantValue<TValue> instead.

return (T)constantExpression.Value;
}

private static BsonType GetBsonType(Type type)
Copy link
Contributor

Choose a reason for hiding this comment

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

Rename to TranslateToType and return two things,, the BsonType and the toSerializer.

private static (BsonType ToBsonType, IBsonSerializer ToSerialzier) TranslateToType(Expression expression, Type toType)
{                                                                                                                     
    return Type.GetTypeCode(Nullable.GetUnderlyingType(toType) ?? toType) switch                                      
    {                                                                                                                 
        TypeCode.Boolean => (BsonType.Boolean, BooleanSerializer.Instance),                                           
        TypeCode.Byte => (BsonType.Int32, ByteSerializer.Instance),                                                   
        TypeCode.Char => (BsonType.String, StringSerializer.Instance),                                                
        TypeCode.DateTime => (BsonType.DateTime, DateTimeSerializer.Instance),                                        
        TypeCode.Decimal => (BsonType.Decimal128, DecimalSerializer.Instance),                                        
        TypeCode.Double => (BsonType.Double, DoubleSerializer.Instance),                                              
        TypeCode.Int16 => (BsonType.Int32, Int16Serializer.Instance),                                                 
        TypeCode.Int32 => (BsonType.Int32, Int32Serializer.Instance),                                                 
        TypeCode.Int64 => (BsonType.Int64, Int64Serializer.Instance),                                                 
        TypeCode.SByte => (BsonType.Int32, SByteSerializer.Instance),                                                 
        TypeCode.Single => (BsonType.Double, DoubleSerializer.Instance),                                              
        TypeCode.String => (BsonType.String, StringSerializer.Instance),                                              
        TypeCode.UInt16 => (BsonType.Int32, UInt16Serializer.Instance),                                               
        TypeCode.UInt32 => (BsonType.Int64, Int32Serializer.Instance),                                                
        TypeCode.UInt64 => (BsonType.Decimal128, UInt64Serializer.Instance),                                          
                                                                                                                      
        _ when toType == typeof(byte[]) => (BsonType.Binary, ByteArraySerializer.Instance),                           
        _ when toType == typeof(BsonBinaryData) => (BsonType.Binary, BsonBinaryDataSerializer.Instance),              
        _ when toType == typeof(Decimal128) => (BsonType.Decimal128, Decimal128Serializer.Instance),                  
        _ when toType == typeof(Guid) => (BsonType.Binary, GuidSerializer.StandardInstance),                          
        _ when toType == typeof(ObjectId) => (BsonType.ObjectId, ObjectIdSerializer.Instance),                        
                                                                                                                      
        _ => throw new ExpressionNotSupportedException(expression, because: $"{toType} is not a valid TTo for Convert"
    };                                                                                                                
}                             

This will require adding a few missing XyzSerializer.Instance properties. Follow the existing pattern from serializers that already have an Instance property.

Copy link
Contributor Author

@papafe papafe Apr 7, 2025

Choose a reason for hiding this comment

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

Wouldn't it be convenient here to use the serializers registered in StandardSerializers? It seems that that's the perfect class for what we're trying to do here, as it takes care also of nullable value types. Of course we'd need to specify the serializers that are not in that list.

@@ -14,7 +14,6 @@
*/

using System.Linq.Expressions;
using MongoDB.Bson.IO;
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not suggesting we need another PR for this... I'm just saying we shouldn't modify files that have nothing at all to do with this PR.

This file can be "cleaned" up some future day when we are doing some work that actually involves this file.

private bool _onErrorWasSet;
private TTo _onNull;
private bool _onNullWasSet;
private readonly IBsonSerializer _serializer;
Copy link
Contributor

Choose a reason for hiding this comment

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

Looks like you forgot to remove the serializer here.

_ => throw new ExpressionNotSupportedException(expression, because: $"{toType} is not a valid TTo for Convert")
};

return (bsonType, StandardSerializers.GetSerializer(toType));
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't like the idea of using the StandardSerializers here.

We are working with a defined set of of toType serializers that may or may not be the same as the StandardSerializers. In fact they are not, as you discovered by having to handle some cases specially.

I liked your original switch where you handled all cases in one switch, just modified to return the serializer also:

private static (BsonType ToBsonType, IBsonSerializer ToSerialzier) TranslateToType(Expression expression, Type toType)
{
    return Type.GetTypeCode(Nullable.GetUnderlyingType(toType) ?? toType) switch
    {
        TypeCode.Boolean => (BsonType.Boolean, BooleanSerializer.Instance),
        TypeCode.Byte => (BsonType.Int32, ByteSerializer.Instance),
        TypeCode.Char => (BsonType.String, StringSerializer.Instance),
        TypeCode.DateTime => (BsonType.DateTime, DateTimeSerializer.Instance),
        TypeCode.Decimal => (BsonType.Decimal128, DecimalSerializer.Instance),
        TypeCode.Double => (BsonType.Double, DoubleSerializer.Instance),
        TypeCode.Int16 => (BsonType.Int32, Int16Serializer.Instance),
        TypeCode.Int32 => (BsonType.Int32, Int32Serializer.Instance),
        TypeCode.Int64 => (BsonType.Int64, Int64Serializer.Instance),
        TypeCode.SByte => (BsonType.Int32, SByteSerializer.Instance),
        TypeCode.Single => (BsonType.Double, DoubleSerializer.Instance),
        TypeCode.String => (BsonType.String, StringSerializer.Instance),
        TypeCode.UInt16 => (BsonType.Int32, UInt16Serializer.Instance),
        TypeCode.UInt32 => (BsonType.Int64, Int32Serializer.Instance),
        TypeCode.UInt64 => (BsonType.Decimal128, UInt64Serializer.Instance),
                                                                            
        _ when toType == typeof(byte[]) => (BsonType.Binary, ByteArraySerializer.Instance),
        _ when toType == typeof(BsonBinaryData) => (BsonType.Binary, BsonBinaryDataSerializer.Instance),
        _ when toType == typeof(Decimal128) => (BsonType.Decimal128, Decimal128Serializer.Instance),
        _ when toType == typeof(Guid) => (BsonType.Binary, GuidSerializer.StandardInstance),
        _ when toType == typeof(ObjectId) => (BsonType.ObjectId, ObjectIdSerializer.Instance),
                                                                                              
        _ => throw new ExpressionNotSupportedException(expression, because: $"{toType} is not a valid TTo for Convert")
    };                                                                                                                 
}                                                                                                                      

Copy link
Contributor Author

@papafe papafe Apr 8, 2025

Choose a reason for hiding this comment

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

I'll go back to this, but we still need to take care of nullable types that were already registered in StandardSerializers. This also means that we'll need to create the nullable serializers here from scratch, as we don't have static instances we can use.

Copy link
Contributor

Choose a reason for hiding this comment

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

I forgot that TTo could be nullable. Does that change what you want to do?

My initial concern with using StandardSerializers was that I wasn't 100% sure what "Standard" meant and whether it would always result in the right thing being done (for Convert).

I was also concerned that some of the serializers needed by Convert might not belong in the "Standard" set.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's true that some of the serializers need by convert are not in the "Standard" set, but it seems that it covered most of the "basic" types. It seems that StandardSerializers' methods are used by ConstantExpressionToAggregationExpressionTranslator to serialize the constant value, so I thought this could be a similar thing.
Anyways, I think the current solution makes sense, the only small annoyance is that we don't have static "Instances" of the nullable serializers, but I think it's a small thing.

@papafe papafe requested a review from rstam April 8, 2025 15:58
{
var underlyingType = Nullable.GetUnderlyingType(toType);
var isNullable = underlyingType != null;
var effectiveType = underlyingType ?? toType;
Copy link
Contributor

Choose a reason for hiding this comment

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

I very much dislike calling Nullable.GetUnderlyingType when we do NOT know if the type is nullable or not. It seems like this method should throw an exception when called with a non-nullable type. I know it does not, but that seems more like a quirk and an implementation error on Microsoft's part, one that we should not rely on.

I suggest:

var isNullable = toType.IsNullable();
var valueType = isNullable ? Nullable.GetUnderlyingType(toType) : toType;

effectiveType is not a terrible name, but we tend to use valueType when talking about nullable values.

var isNullable = underlyingType != null;
var effectiveType = underlyingType ?? toType;

(BsonType, IBsonSerializer) result = Type.GetTypeCode(effectiveType) switch
Copy link
Contributor

Choose a reason for hiding this comment

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

I was trying to figure out why you had to use this odd result variable here...

The reason is that the switch expression doesn't have a "best" result type that applies to all cases, and the compiler needs some guidance. The declaration of the result variable is one way to provide that guidance.

Consider this alternative that doesn't require the intermediate result variable:

var (bsonType, valueSerializer) = (ValueTuple<BsonType, IBsonSerializer>)(Type.GetTypeCode(valueType) switch
{
    ....
});

where casting the result of the switch expression to the ValueTuple provides the guidance to the compiler.

Alternatively, if you were to cast even ONE of the results to IBsonSerializer that would work also, but it is a bit esoteric to do it that way:

TypeCode.Boolean => (BsonType.Boolean, (IBsonSerializer)BooleanSerializer.Instance),

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's cool, I didn't think about it.

@@ -14,7 +14,6 @@
*/

using System.Linq.Expressions;
using MongoDB.Bson.IO;
Copy link
Contributor

Choose a reason for hiding this comment

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

What is your current thinking on this unnecessary diff? Can it be removed from this PR?

set => _subType = value;
}

internal abstract bool OnErrorWasSet(out object onError);
Copy link
Member

Choose a reason for hiding this comment

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

Should it be named TryGetOnError?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think it would make sense.

@sanych-sun sanych-sun self-requested a review April 10, 2025 19:02
Copy link
Member

@sanych-sun sanych-sun left a comment

Choose a reason for hiding this comment

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

Please check evergreen also. There are some test failures related to the changes.


internal abstract bool TryGetOnError(out object onError);

internal abstract bool TryGetOnNull(out object onNull);
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not convinced this name change is a good one.

Not every method that returns a bool should be named TryXyz.

In my opinion methods should only be named TryXyz when they have a chance of failure, but we want to return false instead of throwing an exception.

In this case there is no chance of failure. We merely want to know whether OnError or OnNull was set, and if so to what.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants