andrewlock / StronglyTypedId

A Rosyln-powered generator for strongly-typed IDs
MIT License
1.53k stars 82 forks source link

Working with EF Core #97

Closed JustinianErdmier closed 10 months ago

JustinianErdmier commented 1 year ago

TL;DR

Is the StronglyTypedIdValueConverterSelector you discuss in your article Strongly-typed IDs in EF Core (Revisited) a type that which should be created by the consumer?

Context

The one thing I struggle with as I try to adopt your library is that your articles are heavily referenced in the repo's README as well as most of your answers to issues/questions, yet the articles are written with the perspective of authoring your library - not consuming it. For example, in your article, you discuss the how and why of creating the EF Core value converter, but when consuming the library, all that's done is setting a flag.

With that said, I'm having an issue creating my first migration. I only have one entity with which I am using a strongly typed id partially generated with your library. Including that entity, I have the following code:

/// <summary> A base class for all entities in the domain. </summary>
/// <typeparam name="TKey"> The type to use for the entity's ID. </typeparam>
public abstract class EntityBase<TKey> where TKey : IEquatable<TKey>
{
    /// <summary> Gets or sets the unique identifier for this entity. </summary>
    public TKey Id { get; set; } = default!;
}

[ assembly: StronglyTypedIdDefaults(converters: StronglyTypedIdConverter.TypeConverter
                                                | StronglyTypedIdConverter.SystemTextJson
                                                | StronglyTypedIdConverter.EfCoreValueConverter) ]

[ StronglyTypedId() ]
public partial struct ApplicationUserProfileImageId { }

/// <summary> Represents the profile image for an <see cref="ApplicationUser" />. </summary>
public class ApplicationUserProfileImage : EntityBase<ApplicationUserProfileImageId>
{
    // Omitted for brevity...
}

/// <summary> Configurations for the <see cref="ApplicationUserProfileImage" /> entity. </summary>
public class ApplicationUserProfileImageConfiguration : IEntityTypeConfiguration<ApplicationUserProfileImage>
{
    /// <inheritdoc />
    public void Configure(EntityTypeBuilder<ApplicationUserProfileImage> builder)
    {
        builder.ToTable("ApplicationUserProfileImages");

        builder.HasKey(aupi => aupi.Id);

        // Omitted for brevity...
    }
}

Every time, the migration fails with the following message (I've shared it as an external resource simply because of the copious amount of text): Failed EF Core Migration Output with Strongly-typed Id. The main gist though is the following:

System.InvalidOperationException: The 'ApplicationUserProfileImageId' property 'ApplicationUserProfileImage.Id' could not be mapped because the database provider does not support this type. Consider converting the property value to a type supported by the database using a value converter. See https://aka.ms/efcore-docs-value-converters for more information.

Attempts to Resolve

I have tried the following both individually and in combination with each other:

I've also source navigated to the file generated by your library, ApplicationUserProfileImageId.g.cs, and ensured the nested EfCoreValueConverter class was present.

Then, I tried doing what your article says and attempted to replace the service when I add the DbContext to the dependency injection container:

services.AddDbContextFactory<ApplicationDbContext>(options =>
        {
            options.ReplaceService<IValueConverterSelector, StronglyTypedIdValueConverterSelector>();

           // Omitted for brevity...
        });

As I thought, StronglyTypedIdValueConverterSelector is not a valid option; neither is qualifying it with StronglyTypedIds namespace. After reading some of your answers to other issues/questions, I realize that your library is solely a source generator and does not provide any consumable code.

Final Thoughts

I'm almost certain that the answer is "Yes, you have to create and register the StronglyTypedIdValueConverterSelector class yourself." Even so, I want to do my due diligence and ask and provide some explicit clarity for anyone else who might have the same confusion but is scared/shy to ask.

After posting this, I am going to attempt to manually create and register the value converter selector class. I'll come back and edit this post with an update on my results.

Edit

It worked!

To those who might be thinking the same thing as me and/or looking for the answer to this very question,

There are some things to note. If you source navigate to the file generated by the library of any of your strongly typed ids, you'll see that the name of the nested class is different than that used in the article. This could technically change in the future, so it's best to check and confirm yourself, but at the time of writing this, the class name was EfCoreValueConverter. You'll want to ensure you fix it on around line 26 of the original snippet in the article, like so:

// Try and get a nested class with the expected name. 
var converterType = underlyingModelType.GetNestedType("EfCoreValueConverter");

I also updated the class to be stricter with nullable types and removed a lot of nesting. I haven't had a chance to actually test it (i.e., writing/reading data), but I was able to create my migration correctly.

public class StronglyTypedIdValueConverterSelector : ValueConverterSelector
{
    // The dictionary in the base type is private, so we need our own one here.
    private readonly ConcurrentDictionary<(Type ModelClrType, Type ProviderClrType), ValueConverterInfo> _converters = new ();

    public StronglyTypedIdValueConverterSelector(ValueConverterSelectorDependencies dependencies) : base(dependencies) { }

    public override IEnumerable<ValueConverterInfo> Select(Type modelClrType, Type? providerClrType = null)
    {
        IEnumerable<ValueConverterInfo> baseConverters = base.Select(modelClrType, providerClrType);

        foreach (ValueConverterInfo converter in baseConverters)
        {
            yield return converter;
        }

        // Extract the "real" type T from Nullable<T> if required
        Type?  underlyingModelType    = UnwrapNullableType(modelClrType);
        Type? underlyingProviderType = UnwrapNullableType(providerClrType);

        // 'null' means 'get any value converters for the modelClrType'
        if (underlyingProviderType is not null && underlyingProviderType != typeof(Guid))
            yield break;

        // Try and get a nested class with the expected name.
        Type? converterType = underlyingModelType?.GetNestedType("EfCoreValueConverter");

        if (converterType is not null)
        {
            yield return _converters.GetOrAdd((underlyingModelType, typeof(Guid))!,
                                              _ =>
                                              {
                                                  // Create an instance of the converter whenever it's requested.
                                                  Func<ValueConverterInfo, ValueConverter> factory =
                                                      info => ((ValueConverter) Activator.CreateInstance(converterType,
                                                                                                         info.MappingHints)!);

                                                  // Build the info for our strongly-typed ID => Guid converter
                                                  return new ValueConverterInfo(modelClrType, typeof(Guid), factory);
                                              });
        }
    }

    private static Type? UnwrapNullableType(Type? type)
    {
        if (type is null)
            return null;

        return Nullable.GetUnderlyingType(type) ?? type;
    }
}
modabas commented 1 year ago

With settings to auto generate EfCoreValueConverter, all i had to do to create an EF Core migration was to define HasConversion for property in entity specific type configuration with fluent api.

public class AddressEntityTypeConfiguration : IEntityTypeConfiguration<AddressEntity>
{
    public void Configure(EntityTypeBuilder<AddressEntity> builder)
    {
        builder
          .Property(t => t.Id)
          .HasConversion<AddressId.EfCoreValueConverter>()
          .ValueGeneratedOnAdd()
          .IsRequired();
    }
}
andrewlock commented 10 months ago

Thanks for the info on where you struggled, I'll update the documentation to try to make it clearer how to work with these libraries.

By the way, in .NET 6+, you can define a convention based on a type not just a property, so you can do something like this:

internal class ConventionsDbContext : DbContext
        {
            public ConventionsDbContext(DbContextOptions options) : base(options)
            {
            }

            protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
            {
                configurationBuilder
                    .Properties<AddressId>()
                    .HaveConversion<AddressId.EfCoreValueConverter>();  // Always use the converter any time AddressId is used
            }
        }

I'm going to close this one for now as I think it's mostly resolved, but I'll aim to do a re-hash of the documentation, thanks!