Skip to content

Conversation

@papafe
Copy link
Contributor

@papafe papafe commented Nov 15, 2024

…izer AllowedTypes

@papafe papafe requested review from BorisDog and rstam November 15, 2024 12:25
@papafe
Copy link
Contributor Author

papafe commented Nov 15, 2024

This is an incredibly quick and dirty proof of concept for a new convention that allows users to define the allowed types for serialization/deserialization in multiple ways:

  • passing a delegate
  • passing a list of types
  • passing a list of accepted assemblies
  • passing nothing, and this would be the same as passing the calling assembly

I have also thought if it would make sense to give users the possibility of setting the allowed types with attributes, but I suppose that most of the times that list of types would be valid for the whole project, not only for certain POCOs.

As said, this just a very fast POC, so there's a lot missing, but it's to get some initial feedback.

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.

Everything looks very reasonable!

/// <param name="allowedSerializationTypes"></param>
public AllowedTypesConvention(IEnumerable<Type> allowedDeserializationTypes, IEnumerable<Type> allowedSerializationTypes)
{
var allowedDeserializationTypesArray = allowedDeserializationTypes as Type[] ?? allowedDeserializationTypes.ToArray();
Copy link
Contributor

Choose a reason for hiding this comment

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

Interesting optimization...

Copy link
Contributor

Choose a reason for hiding this comment

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

However... we should probably make a defensive copy anyway because the caller might alter the array they passed in later.

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, we should just make a copy

///
/// </summary>
#pragma warning disable CA1044
public bool AllowDefaultFrameworkTypes
Copy link
Contributor

Choose a reason for hiding this comment

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

I like the idea of giving them a way to opt-in the default framework types.

@papafe papafe marked this pull request as ready for review November 18, 2024 10:35
@papafe papafe requested a review from a team as a code owner November 18, 2024 10:35
@papafe papafe requested a review from rstam November 18, 2024 10:35
@papafe
Copy link
Contributor Author

papafe commented Nov 18, 2024

@rstam This is ready for review now. Apart from all your notes, I've also allowed the convention to work with collections and nested collection (as we did with the EnumConvention).

Copy link
Contributor

@BorisDog BorisDog 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 overall

/// Initializes a new instance of the <see cref="ObjectSerializerAllowedTypesConvention"/> class
/// that allows all types contained in the calling assembly.
/// </summary>
public ObjectSerializerAllowedTypesConvention() : this(Assembly.GetCallingAssembly())
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 we should define that the default behavior does not configure any allowed types (except for DefaultFrameworkAllowedTypes). So the configuration needs to be explicit.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Makes sense

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 have removed the automatic add of the types in the calling assembly and I have made AllowDefaultFrameworkTypes default to true, similar to the behaviour of the ObjectSerializer. For this reason now the delegates that are passed to the ObjectSerializer are built lazily.

@papafe papafe requested a review from BorisDog November 20, 2024 12:41
? ObjectSerializer.DefaultAllowedTypes
: t => _allowedDeserializationTypes(t) || ObjectSerializer.DefaultAllowedTypes(t)
: _allowedDeserializationTypes ?? ObjectSerializer.NoAllowedTypes;
});
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hope that this is readable enough

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 it's fine. minor optimization possible:

_lazyAllowedDeserializationTypes = new Lazy<Func<Type, bool>>(() => Construct(_allowedDeserializationTypes));
_lazyAllowedSerializationTypes= new Lazy<Func<Type, bool>>(() => Construct(_allowedSerializationTypes));

Func<Type, bool> Construct(Func<Type, bool> allowedTypes) => AllowDefaultFrameworkTypes
    ? (allowedTypes != null ? t => allowedTypes(t) || ObjectSerializer.DefaultAllowedTypes) : ObjectSerializer.DefaultAllowedTypes
    : allowedTypes ?? ObjectSerializer.NoAllowedTypes;

? ObjectSerializer.DefaultAllowedTypes
: t => _allowedDeserializationTypes(t) || ObjectSerializer.DefaultAllowedTypes(t)
: _allowedDeserializationTypes ?? ObjectSerializer.NoAllowedTypes;
});
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 it's fine. minor optimization possible:

_lazyAllowedDeserializationTypes = new Lazy<Func<Type, bool>>(() => Construct(_allowedDeserializationTypes));
_lazyAllowedSerializationTypes= new Lazy<Func<Type, bool>>(() => Construct(_allowedSerializationTypes));

Func<Type, bool> Construct(Func<Type, bool> allowedTypes) => AllowDefaultFrameworkTypes
    ? (allowedTypes != null ? t => allowedTypes(t) || ObjectSerializer.DefaultAllowedTypes) : ObjectSerializer.DefaultAllowedTypes
    : allowedTypes ?? ObjectSerializer.NoAllowedTypes;

/// <summary>
/// A convention that allows to set the types that can be safely serialized and deserialized with the <see cref="ObjectSerializer"/>.
/// </summary>
public sealed class ObjectSerializerAllowedTypesConvention
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we want to have convenience presets, like:

ObjectSerializerAllowedTypesConvention.AllowAllTypes
ObjectSerializerAllowedTypesConvention.AllowAllExecutingAssemblyTypes
ObjectSerializerAllowedTypesConvention.DefaultAllowedTypes
...

?

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 that would make sense

Copy link
Contributor

Choose a reason for hiding this comment

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

This class needs to implement IMemberMapConvention.

Follow the pattern from other similar classes:

public sealed class ObjectSerializerAllowedTypesConvention : ConventionBase, IMemberMapConvention

Copy link
Contributor

Choose a reason for hiding this comment

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

To verify why implementing IMemberMapConvention is required try writing a test that registers this convention and see if it gets used during automapping.

This test will have to be disabled and only run manually in isolation, because registering this convention globally would affect other tests.

/// <summary>
/// Default <see cref="ObjectSerializerAllowedTypesConvention"/> where all calling assembly types and default framework types are allowed for both serialization and deserialization.
/// </summary>
public static ObjectSerializerAllowedTypesConvention AllowAllCallingAssemblyAndDefaultFrameworkTypes => new(Assembly.GetCallingAssembly());
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 it makes sense to have both AllowAllCallingAssemblyAndDefaultFrameworkTypes and AllowAllCallingAssemblyTypes or keep only the second one (but then we need to decide if the default framework types are included or not)

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 we need only need AllowAllCallingAssemblyAndDefaultFrameworkTypes

@papafe papafe requested a review from BorisDog November 21, 2024 07:45
/// <summary>
/// Default <see cref="ObjectSerializerAllowedTypesConvention"/> where all calling assembly types and default framework types are allowed for both serialization and deserialization.
/// </summary>
public static ObjectSerializerAllowedTypesConvention AllowAllCallingAssemblyAndDefaultFrameworkTypes => new(Assembly.GetCallingAssembly());
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 we need only need AllowAllCallingAssemblyAndDefaultFrameworkTypes

{
_lazyAllowedDeserializationTypes = new Lazy<Func<Type, bool>>(() => Construct(_allowedDeserializationTypes));
_lazyAllowedSerializationTypes = new Lazy<Func<Type, bool>>(() => Construct(_allowedSerializationTypes));
return;
Copy link
Contributor

Choose a reason for hiding this comment

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

no need for return

Copy link
Contributor Author

@papafe papafe Nov 22, 2024

Choose a reason for hiding this comment

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

I agree, it's mostly to separate "visually" the method body from the local functions. I'm not super sold either way to be honest, so I'll remove it

public ObjectSerializerAllowedTypesConvention()
{
_lazyAllowedDeserializationTypes = new Lazy<Func<Type, bool>>(() => Construct(_allowedDeserializationTypes));
_lazyAllowedSerializationTypes = new Lazy<Func<Type, bool>>(() => Construct(_allowedSerializationTypes));
Copy link
Contributor

Choose a reason for hiding this comment

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

Are we sure that the values of _allowedDeserialization and _allowedSerializationTypes have already been stored when this code fetches them?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The Func is built when it's used the first time, so the variables will be set by then

/// <summary>
/// A convention that allows to set the types that can be safely serialized and deserialized with the <see cref="ObjectSerializer"/>.
/// </summary>
public sealed class ObjectSerializerAllowedTypesConvention
Copy link
Contributor

Choose a reason for hiding this comment

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

This class needs to implement IMemberMapConvention.

Follow the pattern from other similar classes:

public sealed class ObjectSerializerAllowedTypesConvention : ConventionBase, IMemberMapConvention

/// <summary>
/// A convention that allows to set the types that can be safely serialized and deserialized with the <see cref="ObjectSerializer"/>.
/// </summary>
public sealed class ObjectSerializerAllowedTypesConvention
Copy link
Contributor

Choose a reason for hiding this comment

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

To verify why implementing IMemberMapConvention is required try writing a test that registers this convention and see if it gets used during automapping.

This test will have to be disabled and only run manually in isolation, because registering this convention globally would affect other tests.

/// A predefined <see cref="ObjectSerializerAllowedTypesConvention"/> where no types are allowed for both serialization and deserialization.
/// </summary>
public static ObjectSerializerAllowedTypesConvention AllowNoTypes { get; } = new(ObjectSerializer.NoAllowedTypes)
{ AllowDefaultFrameworkTypes = 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 formatting threw me off. I didn't realize at first that the line was continued on the next line.

Maybe this way:

public static ObjectSerializerAllowedTypesConvention AllowNoTypes { get; } =
    new(ObjectSerializer.NoAllowedTypes) { AllowDefaultFrameworkTypes = false };

private readonly Lazy<Func<Type, bool>> _effectiveAllowedDeserializationTypes;
private readonly Lazy<Func<Type, bool>> _effectiveAllowedSerializationTypes;

private readonly bool _allowDefaultFrameworkTypes = true;
Copy link
Contributor

Choose a reason for hiding this comment

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

Consider placing line 61 immediately after line 56.

@papafe papafe requested a review from rstam November 28, 2024 11:09
[Fact]
public void TestMappingUsesMemberDefaultValueConvention()
{
var conventionName = Guid.NewGuid().ToString();
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 found these tests that were registering conventions but never unregistering them

Copy link
Contributor

Choose a reason for hiding this comment

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

This was not a problem because of t => t == typeof(A)

}

[Fact]
public void Convention_should_be_applied_during_automapping()
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've added this test to verify that the convention gets registered during automap. I know you've said we should only run it manually, but can't we register the convention and unregister it at the end of the test like here?

Copy link
Contributor

Choose a reason for hiding this comment

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

As i commented above I'm surprised we even have a method to unregister a convention. You shouldn't be able to alter conventions in the middle of a program run.

We can either unregister it as you discovered (even though I didn't think you should be able to do that), or you can simply program the convention to only apply to the class in this test and just leave it registered forever.

Assert.IsType<int>(defaultValue);
Assert.Equal(1, defaultValue);

ConventionRegistry.Remove(conventionName);
Copy link
Contributor

Choose a reason for hiding this comment

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

Wow I am surprised the Remove method even exists.

A general principle of our whole serialization configuration machinery is that you shouldn't be able to change how things are serialized in the middle of the program run.

Though I guess this Remove method is somewhat less harmful because it doesn't change how anything that is already configured is serialized, only how future classes that have not yet been configured will end up being configured after the convention has been removed.

Note also that this convention does not HAVE to be unregistered. It is already programmed to ONLY apply to this one class with t => t == typeof(A).

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 understand we could have a convention that applies to a certain type and leave it registered, and while this likely does not affect anything else, shouldn’t we aim to keep the tests as independent as possible?

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, tests should be as independent as possible. That's why we should use t => t == typeof(A) instead of t => true here.

It's fine if you want to unregister this convention at the end of the test. I just wanted to make the following points:

  1. The test was not inherently wrong because the registered convention only applied to this test
  2. I was surprised we even had a Remove method

The Remove method concerns me because the way serialization is supposed to work is that an application configures serialization at startup and then leaves it alone. Removing a convention at some later time sounds like it would destabilize the application because POCOs might be serialized differently depending on whether the POCO is mapped before or after the convention was removed.

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 with all the points :)
I modified the predicate to be more restrictive, but kept the Remove to have a clean state after each test. I can agree also that maybe that method should not be there, but for now I'd say it's better to use it

}

[Fact]
public void Convention_should_be_applied_during_automapping()
Copy link
Contributor

Choose a reason for hiding this comment

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

As i commented above I'm surprised we even have a method to unregister a convention. You shouldn't be able to alter conventions in the middle of a program run.

We can either unregister it as you discovered (even though I didn't think you should be able to do that), or you can simply program the convention to only apply to the class in this test and just leave it registered forever.

var conventionName = Guid.NewGuid().ToString();

var subject = new ObjectSerializerAllowedTypesConvention(Assembly.GetExecutingAssembly());
ConventionRegistry.Register(conventionName, new ConventionPack {subject}, _ => true);
Copy link
Contributor

Choose a reason for hiding this comment

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

Can't we use something more restrictive that t => true?

If we think about a time in the future where we want to run our tests in parallel relying on unregistering will no longer work.

[Fact]
public void TestMappingUsesMemberDefaultValueConvention()
{
var conventionName = Guid.NewGuid().ToString();
Copy link
Contributor

Choose a reason for hiding this comment

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

This was not a problem because of t => t == typeof(A)

@papafe papafe requested a review from rstam December 3, 2024 13:49
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

@papafe papafe merged commit 3fd8923 into mongodb:main Dec 6, 2024
26 of 30 checks passed
@papafe papafe deleted the csharp4495 branch December 6, 2024 12:21
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.

3 participants