Skip to content

TypeHandlerResolver not being called for nested composite types #4663

@Compufreak345

Description

@Compufreak345

Steps to reproduce

I've got the following data model:

    public record DbSiteOpeningHours
    {
        public string SomeString{ get; init; } = null!;

        public OpeningHours[] OpeningHours { get; set; } = null!;
    }

    public record OpeningHours
    {
        public Weekday Weekday { get; init; }
    }

    public enum Weekday : short
    {
        Unknown = 0,
        MON = 1,
        TUE = 2,
        WED = 3,
        THU = 4,
        FRI = 5,
        SAT = 6,
        SUN = 7
    }

The important thing to notice here is that our enum is inheriting from short, which means we need to do the cast to short in a custom type handler.

This is the type handler:

public class EnumToSmallIntTypeHandler : NpgsqlTypeHandler<short>
    {
        private readonly Int16Handler _intHandler;

        public EnumToSmallIntTypeHandler(PostgresType postgresType) : base(postgresType)
        {
            _intHandler = new Int16Handler(postgresType);
        }

        public override int ValidateObjectAndGetLength(object value, ref NpgsqlLengthCache? lengthCache, NpgsqlParameter? parameter)
        {
            if(value.GetType().IsEnum)
            {
                return _intHandler.ValidateObjectAndGetLength((short)value, ref lengthCache, parameter);
            }
            return _intHandler.ValidateObjectAndGetLength(value, ref lengthCache, parameter);
        }

        public override async Task WriteObjectWithLength(object value, NpgsqlWriteBuffer buf, NpgsqlLengthCache? lengthCache,
            NpgsqlParameter? parameter, bool async, CancellationToken cancellationToken = new CancellationToken())
        {
            if(value.GetType().IsEnum)
            {
                await _intHandler.WriteObjectWithLength((short)value, buf, lengthCache, parameter, async, cancellationToken);
            }
            await _intHandler.WriteObjectWithLength(value, buf, lengthCache, parameter, async, cancellationToken);
        }

        public override async ValueTask<short> Read(NpgsqlReadBuffer buf, int len, bool async, FieldDescription? fieldDescription = null)
        {
            return await _intHandler.Read(buf, len, async, fieldDescription);
        }

        public override int ValidateAndGetLength(short value, ref NpgsqlLengthCache? lengthCache, NpgsqlParameter? parameter)
        {
            return _intHandler.ValidateAndGetLength(value, ref lengthCache, parameter);
        }

        public override async Task Write(short value, NpgsqlWriteBuffer buf, NpgsqlLengthCache? lengthCache, NpgsqlParameter? parameter, bool async,
            CancellationToken cancellationToken = new CancellationToken())
        {
            await _intHandler.Write(value, buf, lengthCache, parameter, async, cancellationToken);
        }
    }

And this is the TypeHandlerResolver:

public class EnumTypeHandlerResolver : TypeHandlerResolver
    {
        private readonly EnumToSmallIntTypeHandler _enumHandler;

        public EnumTypeHandlerResolver(NpgsqlConnector connector)
        {
            _enumHandler = new EnumToSmallIntTypeHandler(connector.DatabaseInfo.GetPostgresTypeByName("smallint"));
        }
        
        public override NpgsqlTypeHandler? ResolveByDataTypeName(string typeName)
        {
            if(typeName.Equals("smallint"))
            {
                return _enumHandler;
            }

            return null;
        }

        public override NpgsqlTypeHandler? ResolveByClrType(Type type)
        {
            return type == typeof(Enum) ? _enumHandler : null;
        }

        public override TypeMappingInfo? GetMappingByDataTypeName(string dataTypeName)
        {
            return dataTypeName.Equals("smallint") ? new TypeMappingInfo(NpgsqlDbType.Smallint, "smallint", typeof(short), typeof(Enum)) : null;
        }
    }

And the TypeHandlerResolverFactory:

   public class EnumToSmallIntTypeHandlerFactory : TypeHandlerResolverFactory
    {
        private EnumTypeHandlerResolver _enumTypeHandlerResolver = null!;

        internal EnumToSmallIntTypeHandlerFactory()
        {
        }

        public override TypeHandlerResolver Create(NpgsqlConnector connector)
        {
            return _enumTypeHandlerResolver = new EnumTypeHandlerResolver(connector);
        }

        public override string? GetDataTypeNameByClrType(Type clrType)
        {
            return typeof(Enum) == clrType ? "smallint" : null;
        }

        public override TypeMappingInfo? GetMappingByDataTypeName(string dataTypeName)
        {
            return _enumTypeHandlerResolver.GetMappingByDataTypeName(dataTypeName);
        }
    }

And this is how we register our types and the resolver:

            NpgsqlConnection.GlobalTypeMapper.AddTypeResolverFactory(new EnumToSmallIntTypeHandlerFactory());
            AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);

            NpgsqlConnection.GlobalTypeMapper.MapComposite("db_type_1", typeof(DbSiteOpeningHours));
            NpgsqlConnection.GlobalTypeMapper.MapComposite("db_type_2", typeof(OpeningHours));
 

That's how we call the stored procedure:

string statement = "CALL schema.procedure (in_db_type_1 => @in_db_type_1)"
NpgsqlConnection connection = await _connectionProvider.GetConnectionAsync();
DynamicParameters parameters = new();
parameters.Add("in_db_type_1", data);
await connection.ExecuteAsync(statement, parameters);

The issue

For simple types, the EnumTypeHandlerResolver is called when inserting new data and everything works fine.

For the example displayed above with nested composite types, the resolver is only called for the DbSiteOpeningHours type, but not called for the OpeningHours or WeekDay type. Thus I cannot pass my custom TypeHandler.

In npgsql version 5, we were able to use the NpgsqlConnection.GlobalTypeMapper.AddMapping functionality and it did work fine, but this is not available in npgsql 6 anymore.

I did invest quite some time to find what exactly is causing this in the Npgsql source code, but I got stuck somewhere inbetween the ConnectorTypeMapper and the CompositeHandler.

I hope you can provide a hint what to do, or a fix if it's a bug.

Thank you so much!

Exception message: 
System.Exception: While trying to write an array, one of its elements failed validation. You may be trying to mix types in a non-generic IList, or to write a jagged array.
Stack trace:
 ---> System.InvalidCastException: Cannot write a value of CLR type 'Enums.Weekday' as database type 'smallint'.
   at Npgsql.Internal.TypeHandling.NpgsqlTypeHandler.ValidateAndGetLengthCustom[TAny](TAny value, NpgsqlLengthCache& lengthCache, NpgsqlParameter parameter)
   at Npgsql.Internal.TypeHandlers.CompositeHandlers.CompositeClassMemberHandler`2.ValidateAndGetLength(TComposite composite, NpgsqlLengthCache& lengthCache)
   at Npgsql.Internal.TypeHandlers.CompositeHandlers.CompositeHandler`1.ValidateAndGetLength(T value, NpgsqlLengthCache& lengthCache, NpgsqlParameter parameter)
   at Npgsql.Internal.TypeHandlers.ArrayHandler`1.ValidateAndGetLengthGeneric(ICollection`1 value, NpgsqlLengthCache& lengthCache)
   --- End of inner exception stack trace ---
   at Npgsql.Internal.TypeHandlers.ArrayHandler`1.ValidateAndGetLengthGeneric(ICollection`1 value, NpgsqlLengthCache& lengthCache)
   at Npgsql.Internal.TypeHandlers.ArrayHandler`1.ValidateAndGetLength(Object value, NpgsqlLengthCache& lengthCache)
   at Npgsql.Internal.TypeHandlers.ArrayHandler`1.ValidateAndGetLengthCustom[TAny](TAny value, NpgsqlLengthCache& lengthCache, NpgsqlParameter parameter)
   at Npgsql.Internal.TypeHandlers.CompositeHandlers.CompositeClassMemberHandler`2.ValidateAndGetLength(TComposite composite, NpgsqlLengthCache& lengthCache)
   at Npgsql.Internal.TypeHandlers.CompositeHandlers.CompositeHandler`1.ValidateAndGetLength(T value, NpgsqlLengthCache& lengthCache, NpgsqlParameter parameter)
   at Npgsql.Internal.TypeHandlers.ArrayHandler`1.ValidateAndGetLengthGeneric(ICollection`1 value, NpgsqlLengthCache& lengthCache)
   --- End of inner exception stack trace ---
   at Npgsql.Internal.TypeHandlers.ArrayHandler`1.ValidateAndGetLengthGeneric(ICollection`1 value, NpgsqlLengthCache& lengthCache)
   at Npgsql.Internal.TypeHandlers.ArrayHandler`1.ValidateAndGetLength(Object value, NpgsqlLengthCache& lengthCache)
   at Npgsql.Internal.TypeHandlers.ArrayHandler`1.ValidateObjectAndGetLength(Object value, NpgsqlLengthCache& lengthCache, NpgsqlParameter parameter)
   at Npgsql.NpgsqlParameter.ValidateAndGetLength()
   at Npgsql.NpgsqlParameterCollection.ValidateAndBind(ConnectorTypeMapper typeMapper)
   at Npgsql.NpgsqlCommand.ExecuteReader(CommandBehavior behavior, Boolean async, CancellationToken cancellationToken)
   at Npgsql.NpgsqlCommand.ExecuteReader(CommandBehavior behavior, Boolean async, CancellationToken cancellationToken)
   at Npgsql.NpgsqlCommand.ExecuteNonQuery(Boolean async, CancellationToken cancellationToken)
   at Dapper.SqlMapper.ExecuteImplAsync(IDbConnection cnn, CommandDefinition command, Object param) in /_/Dapper/SqlMapper.Async.cs:line 646

Further technical details

Npgsql version: 6.0.6
PostgreSQL version: 12.7
Operating system: Windows 10

Other details about my project setup:
We are running .NET Core 3.1 and are inserting the data using stored procedures. If you need any further details about that, I am happy to answer any questions.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions