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-2096: Make EnumRepresentationConvention also affect collections of Enums #1574

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

Conversation

papafe
Copy link
Contributor

@papafe papafe commented Dec 12, 2024

No description provided.

@papafe papafe marked this pull request as ready for review December 12, 2024 16:58
@papafe papafe requested a review from a team as a code owner December 12, 2024 16:58
@papafe papafe requested review from JamesKovacs and rstam and removed request for a team and JamesKovacs December 12, 2024 16:58
/// <summary>
/// Gets a boolean indicating if this convention should be also applied to collections of enums.
/// </summary>
public bool ShouldApplyToCollections => _shouldApplyToCollections;
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this property should be named ShouldApplyToChildSerializers because it applies to child serializers in general and not just collections.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We have a discussion going on slack, but I think it would make sense to keep the name we have now, since we are special casing the NullableSerializer that is the only IChildSerializerConfigurable that is not a collection serializer, and makes this backwards compatible

Copy link
Contributor

Choose a reason for hiding this comment

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

I still think this should be generalized to ShouldApplyToChildSerializers.

For example, while a dictionary clearly has key and value child serializers it's not clear that a dictionary is a collection (depends on how strictly or loosely you want to define a collection).

Has anyone else weighed in on this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No, but we can discuss it during the standup maybe. If we do it though, we need to special case the nullable serializer, otherwise it would be a breaking change.

return null;
}

// private methods
Copy link
Contributor

Choose a reason for hiding this comment

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

Duplicate line

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed

// private methods
private bool IsNullableEnum(Type type)
{
return
type.GetTypeInfo().IsGenericType &&
type.GetGenericTypeDefinition() == typeof(Nullable<>) &&
Nullable.GetUnderlyingType(type).GetTypeInfo().IsEnum;
Nullable.GetUnderlyingType(type)!.GetTypeInfo().IsEnum;
Copy link
Contributor

Choose a reason for hiding this comment

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

There is no longer any need to call GetTypeInfo here (or on line 110). That was a .NET 2.0 thing.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed

/// <summary>
/// Represents a serializer that has a key and a value serializer that configuration attributes can be forwarded to.
/// </summary>
public interface IKeyAndValueSerializerConfigurable : IBsonDictionarySerializer
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not sure if this should be deriving from IBsonDictionarySerializer, or it should be independent from it.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think instead of an interface that is hard coded to apply just to dictionaries we should consider the more general solution of adding a new IMultipleChildSerializerConfigurableSerializer that is like IChildSerializerConfigurable but applies to serializers that have more than one child serializer:

public interface IMultipleChildSerializerConfigurableSerializer
{
    IBsonSerializer[] ChildSerializers { get; }
    IBsonSerializer WithChildSerializers(IBsonSerializer[] childSerializers);
}

Copy link
Contributor Author

@papafe papafe Dec 20, 2024

Choose a reason for hiding this comment

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

This is a good idea!
Done

@papafe papafe requested a review from rstam December 17, 2024 11:01
/// <summary>
/// Gets a boolean indicating if this convention should be also applied to collections of enums.
/// </summary>
public bool ShouldApplyToCollections => _shouldApplyToCollections;
Copy link
Contributor

Choose a reason for hiding this comment

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

I still think this should be generalized to ShouldApplyToChildSerializers.

For example, while a dictionary clearly has key and value child serializers it's not clear that a dictionary is a collection (depends on how strictly or loosely you want to define a collection).

Has anyone else weighed in on this?

/// <summary>
/// Represents a serializer that has a key and a value serializer that configuration attributes can be forwarded to.
/// </summary>
public interface IKeyAndValueSerializerConfigurable : IBsonDictionarySerializer
Copy link
Contributor

Choose a reason for hiding this comment

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

I think instead of an interface that is hard coded to apply just to dictionaries we should consider the more general solution of adding a new IMultipleChildSerializerConfigurableSerializer that is like IChildSerializerConfigurable but applies to serializers that have more than one child serializer:

public interface IMultipleChildSerializerConfigurableSerializer
{
    IBsonSerializer[] ChildSerializers { get; }
    IBsonSerializer WithChildSerializers(IBsonSerializer[] childSerializers);
}

/// - or is a <see cref="Nullable"/> serializer;
/// the method traverses and applies the reconfiguration to its child serializers recursively.
internal static IBsonSerializer ReconfigureSerializer<TSerializer>(IBsonSerializer serializer, Func<TSerializer, IBsonSerializer> reconfigure,
Func<IBsonSerializer, bool> testFunction = null, bool shouldApplyToCollections = true)
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 addition of testFunction. I think the test can be incorporated into the reconfigure function.

But maybe let's get through my other questions first.

Copy link
Contributor

Choose a reason for hiding this comment

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

Also not sure shouldApplyToChildSerializer (or shouldApplyToCollections) needs to be passed in either. If it is false this method shouldn't have been called in the first place. If it is true and this function was called then it always applies to nested children.

Copy link
Contributor Author

@papafe papafe Dec 20, 2024

Choose a reason for hiding this comment

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

I would prefer to keep shouldApplyTo.... If we do so, this method becomes a one-stop method for reconfiguring serializers, including when we want to apply reconfigure to nullable but not child serializers for example. If we keep it out, then we need to keep the code for nullable serializers out, leading to code duplication.
Regarding testFunction, if we incorporate it into reconfigure, then reconfigure would need to return a null value when it does not need to reconfigure the serializer, maybe making the method a little bit less clear. It don't have a super strong opinion on this though

@papafe
Copy link
Contributor Author

papafe commented Dec 20, 2024

@rstam I've done some changes:

  • changed IKeyAndValue... to IMultipleChildren... so it has a broader use
  • I've modified Reconfigure to work with IChidSerializable.., IMultipleChildrenSerializable... and Nullable serializer (always)
  • I've changed the name to TopLevelOnly. Still open to changing the name, but at least I flipped all the booleans so it should not be a problem.

I think the remaining contentious points are about the Reconfigure method: if it should have the testFunction parameter and if it should have the topLevelOnly boolean, but we can discuss in the threads.

@papafe papafe requested a review from rstam December 20, 2024 13:35
Copy link
Contributor

@rstam rstam left a comment

Choose a reason for hiding this comment

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

/// - or is a <see cref="Nullable"/> serializer;
/// the method traverses and applies the reconfiguration to its child serializers recursively.
internal static IBsonSerializer ReconfigureSerializer<TSerializer>(IBsonSerializer serializer, Func<TSerializer, IBsonSerializer> reconfigure,
Func<IBsonSerializer, bool> testFunction = null, bool topLevelOnly = false)
Copy link
Contributor

Choose a reason for hiding this comment

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

This method has too many knobs. It has 4:

  1. TSerializer generic type
  2. reconfigure Func (which might return null)
  3. testFunction
  4. topLevelOnly

That's 3 too many.

All we need is the reconfigure Func, which centralizes in itself any decisions about whether to reconfigure or not. It either reconfigures the serializer if applicable or returns null.

As far as the rest are concerned:

TSerializer is redundant because the reconfigure Func can decide for itself

testFunction is redundant because the reconfigure Func can decide for itself

topLevelOnly is redundant because if you only want to configure the top level then don't call this function.

Here's what I think this method should look like:

// Reconfigures a serializer recursively.
// The reconfigure Func should return null if it does not apply to a given serializer
internal static IBsonSerializer ReconfigureSerializerRecursively(
    IBsonSerializer serializer,
    Func<IBsonSerializer, IBsonSerializer> reconfigure)
{
    switch (serializer)
    {
        // check IMultipleChildSerializersConfigurableSerializer first because some serializer implement both interfaces
        case IMultipleChildSerializersConfigurableSerializer multipleChildSerializersConfigurable:
            {
                var newChildSerializers = new List<IBsonSerializer>();
                                                                      
                foreach (var childSerializer in multipleChildSerializersConfigurable.ChildSerializers)
                {
                    var reconfiguredChildSerializer = ReconfigureSerializerRecursively(childSerializer, reconfigure) ?? childSerializer;
                    newChildSerializers.Add(reconfiguredChildSerializer);
                }
                 
                return multipleChildSerializersConfigurable.WithChildSerializers(newChildSerializers.ToArray());
            }
             
        case IChildSerializerConfigurable childSerializerConfigurable:
            {
                var childSerializer = childSerializerConfigurable.ChildSerializer;
                var reconfiguredChildSerializer = ReconfigureSerializerRecursively(childSerializer, reconfigure) ?? childSerializer;
                return reconfiguredChildSerializer != null ? childSerializerConfigurable.WithChildSerializer(reconfiguredChildSerializer) : null;
            }                                                                                                                                    
                                                                                                                                                 
        default:                                                                                                                                 
            return reconfigure(serializer);                                                                                                      
    }                                                                                                                                            
}                                                                                                                                                

Copy link
Contributor

Choose a reason for hiding this comment

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

I still think this method is much simpler and easier to understand if we only have 2 parameters instead of 5.

The only rule the reader needs to understand is that the reconfigure Func should either return a reconfigured serializer or null if it does not apply.

All the decision making about whether to reconfigure or not is centralized in one place (in the caller).

You are concerned about possible duplication of code, which is a valid concern. But if you look at my branch the amount of duplications is close to zero, and in return we only have to look in one place to understand whether and how the serializer is reconfigured.

I've slightly refactored my branch to make the calling code even simpler:

https://github.com/rstam/mongo-csharp-driver/tree/csharp2096-rstam

Look at all the callers of ReconfigureSerializerRecursively to verify how little duplication there actually is.

Copy link
Contributor

Choose a reason for hiding this comment

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

Please look at my ReconfigureSerializerRecursively implementation carefully, in particular the new anyChildSerializerWasReconfigured variable to return null if no child was reconfigured.

Copy link
Contributor Author

@papafe papafe Dec 31, 2024

Choose a reason for hiding this comment

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

I got convinced, I think I was too stubborn on this 😄
Probably it's worth having a little code duplication to make the code clearer.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just pushed the changes.

@@ -61,7 +61,7 @@ public BsonDateOnlyOptionsAttribute(BsonType representation, DateOnlyDocumentFor
/// <returns>A reconfigured serializer.</returns>
protected override IBsonSerializer Apply(IBsonSerializer serializer)
{
var reconfiguredSerializer = SerializerConfigurator.ReconfigureSerializer(serializer, (DateOnlySerializer s) => s.WithRepresentation(_representation, _documentFormat));
var reconfiguredSerializer = SerializerConfigurator.ReconfigureSerializer(serializer, (DateOnlySerializer s) => s.WithRepresentation(_representation, _documentFormat), topLevelOnly: false);
Copy link
Contributor

Choose a reason for hiding this comment

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

With the other suggestions this method would look like this:

protected override IBsonSerializer Apply(IBsonSerializer serializer)
{
    var reconfiguredSerializer = SerializerConfigurator.ReconfigureSerializerRecursively(serializer, Reconfigure);
    return reconfiguredSerializer ?? base.Apply(serializer);
                                                            
    IBsonSerializer Reconfigure(IBsonSerializer serializer) =>
        (serializer is DateOnlySerializer dateOnlySerializer) ? dateOnlySerializer.WithRepresentation(_representation, _documentFormat) : null;
}                                                                                                                                              

/// <summary>
/// Applies a modification to the member map.
/// </summary>
/// <param name="memberMap">The member map.</param>
public void Apply(BsonMemberMap memberMap)
{
var memberType = memberMap.MemberType;
var memberTypeInfo = memberType.GetTypeInfo();
var reconfiguredSerializer =
Copy link
Contributor

Choose a reason for hiding this comment

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

With the other suggestions this method would look like this:

public void Apply(BsonMemberMap memberMap)
{
    var serializer = memberMap.GetSerializer();

    IBsonSerializer reconfiguredSerializer;    
    if (_topLevelOnly && !serializer.ValueType.IsNullableEnum())
    {
        reconfiguredSerializer = Reconfigure(serializer);
    }
    else
    {
        reconfiguredSerializer = SerializerConfigurator.ReconfigureSerializerRecursively(serializer, Reconfigure);
    }                                                                                                             

    if (reconfiguredSerializer is not null)                                                                       
    {                                                                                                             
        memberMap.SetSerializer(reconfiguredSerializer);                                                          
    }                                                                                                             

    IBsonSerializer Reconfigure(IBsonSerializer serializer) =>                                                    
        (serializer.ValueType.IsEnum && serializer is IRepresentationConfigurable representationConfigurable) ?   
            representationConfigurable.WithRepresentation(_representation) : null;                                
}                                                                                                                 

Copy link
Contributor

Choose a reason for hiding this comment

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

Note that we use _topLevel locally here to decide whether we want to reconfigure recursively or not (with an exception for nullable enums for backward compatibility).

There is no need to pass the topLevel argument to ReconfigureSerializerRecursively.

Copy link
Contributor Author

@papafe papafe Dec 20, 2024

Choose a reason for hiding this comment

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

My only comment on this is that this way we'll need to have this code copied more or less in the same way wherever we need to reconfigure serializers recursively. If we have a ReconfigureSerializerRecursively with more parameters we could put all the complexities in one place and we don't need to repeat the same code.

I agree we could remove testFunc, but if we keep the topLevelOnly boolean we can keep the reconfiguration code in the method.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@rstam I think this the last point to agree upon.

Copy link
Contributor

Choose a reason for hiding this comment

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

See comments below.

/// <value>
/// The children serializers.
/// </value>
IBsonSerializer[] ChildrenSerializers { get; }
Copy link
Contributor

Choose a reason for hiding this comment

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

This property should be called ChildSerializers.

It is ungrammatical to say ChildrenSerializers.

Child is an adjective here and should not be plural.

Compare "child serializer" to "child seat".

The plural is "child seats", not "children seats".

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I was unsure about that, I'll correct it!

/// <summary>
/// Represents a serializer that has multiple children serializers that configuration attributes can be forwarded to.
/// </summary>
public interface IMultipleChildrenSerializerConfigurableSerializer
Copy link
Contributor

Choose a reason for hiding this comment

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

Class name (and file name) should be:

    MultipleChildSerializersConfigurableSerializer

"Child" to "Children"

See below.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes

/// </summary>
/// <param name="childrenSerializers">The children serializers.</param>
/// <returns>The reconfigured serializer.</returns>
IBsonSerializer WithChildrenSerializers(IBsonSerializer[] childrenSerializers);
Copy link
Contributor

Choose a reason for hiding this comment

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

IBsonSerializer WithChildSerializers(IBsonSerializer[] childSerializers);

@papafe papafe requested a review from rstam December 30, 2024 11:39
/// - or is a <see cref="Nullable"/> serializer;
/// the method traverses and applies the reconfiguration to its child serializers recursively.
internal static IBsonSerializer ReconfigureSerializer<TSerializer>(IBsonSerializer serializer, Func<TSerializer, IBsonSerializer> reconfigure,
Func<IBsonSerializer, bool> testFunction = null, bool topLevelOnly = false)
Copy link
Contributor

Choose a reason for hiding this comment

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

I still think this method is much simpler and easier to understand if we only have 2 parameters instead of 5.

The only rule the reader needs to understand is that the reconfigure Func should either return a reconfigured serializer or null if it does not apply.

All the decision making about whether to reconfigure or not is centralized in one place (in the caller).

You are concerned about possible duplication of code, which is a valid concern. But if you look at my branch the amount of duplications is close to zero, and in return we only have to look in one place to understand whether and how the serializer is reconfigured.

I've slightly refactored my branch to make the calling code even simpler:

https://github.com/rstam/mongo-csharp-driver/tree/csharp2096-rstam

Look at all the callers of ReconfigureSerializerRecursively to verify how little duplication there actually is.

/// - <paramref name="topLevelOnly"/> is false;
/// - or is a <see cref="Nullable"/> serializer;
/// the method traverses and applies the reconfiguration to its child serializers recursively.
internal static IBsonSerializer ReconfigureSerializer<TSerializer>(IBsonSerializer serializer, Func<TSerializer, IBsonSerializer> reconfigure,
Copy link
Contributor

Choose a reason for hiding this comment

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

I think the method should be called ReconfigureSerializerRecursively to call attention to the recursive nature of the reconfiguration.

/// - or is a <see cref="Nullable"/> serializer;
/// the method traverses and applies the reconfiguration to its child serializers recursively.
internal static IBsonSerializer ReconfigureSerializer<TSerializer>(IBsonSerializer serializer, Func<TSerializer, IBsonSerializer> reconfigure,
Func<IBsonSerializer, bool> testFunction = null, bool topLevelOnly = false)
Copy link
Contributor

Choose a reason for hiding this comment

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

Please look at my ReconfigureSerializerRecursively implementation carefully, in particular the new anyChildSerializerWasReconfigured variable to return null if no child was reconfigured.

return multipleChildSerializerConfigurable.WithChildSerializers(newSerializers.ToArray());
}
case IChildSerializerConfigurable childSerializerConfigurable when
!topLevelOnly || Nullable.GetUnderlyingType(serializer.ValueType) != null:
Copy link
Contributor

Choose a reason for hiding this comment

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

A nullable enum test doesn't belong here. It only applies to the EnumRepresentationConvention.

/// <summary>
/// Applies a modification to the member map.
/// </summary>
/// <param name="memberMap">The member map.</param>
public void Apply(BsonMemberMap memberMap)
{
var memberType = memberMap.MemberType;
var memberTypeInfo = memberType.GetTypeInfo();
var reconfiguredSerializer =
Copy link
Contributor

Choose a reason for hiding this comment

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

See comments below.

@papafe papafe requested a review from rstam December 31, 2024 10:29
Copy link
Contributor

@rstam rstam left a comment

Choose a reason for hiding this comment

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

Looks good.

I have one question for you before I LGTM. Let me know what you think.

var memberType = memberMap.MemberType;
var memberTypeInfo = memberType.GetTypeInfo();
var serializer = memberMap.GetSerializer();
var reconfiguredSerializer = _topLevelOnly && Nullable.GetUnderlyingType(serializer.ValueType) == null ?
Copy link
Contributor

Choose a reason for hiding this comment

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

Why did you not like:

!serializer.ValueType.IsNullableEnum()

instead of:

Nullable.GetUnderlyingType(serializer.ValueType) == null

The second is longer and it seems suspicious to call Nullable.GetUnderlyingType without even knowing if the ValueType is nullable or not.

Yes, after some research I now know Nullable.GetUnderlyingType returns null if the ValueType is not Nullable but still seems wrong to call a Nullable method when we don't know if the ValueType is Nullable or not. I'm surprised Nullable.GetUnderlyingType doesn't throw an exception when ValueType is not Nullable. I had to research the documentation to figure out what it does in that case.

Calling IsNullableEnum is not only shorter but it's also self-describing, without having to decipher the obscure behavior of GetUnderlyingType for non Nullable types.

Copy link
Contributor

Choose a reason for hiding this comment

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

Note: IsNullableEnum is a new extension method added to TypeExtensions.cs on the branch I shared with you.

Copy link
Contributor

Choose a reason for hiding this comment

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

Also... isn't the exception for "nullable enums", not for ALL nullable types?

Better to be precise.

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 agree it's better to have it as an extension method, so the intention is clearer. Just pushed it

@papafe papafe requested a review from rstam December 31, 2024 18:03
Copy link
Contributor

@rstam rstam left a comment

Choose a reason for hiding this comment

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

LGTM

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

Successfully merging this pull request may close these issues.

2 participants