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

AsObject<T> on 5.21.0 throws MappingFailedException, did the behaviour change? #805

Open
XzaR90 opened this issue Jun 22, 2024 · 6 comments
Labels

Comments

@XzaR90
Copy link

XzaR90 commented Jun 22, 2024

Describe the bug
Neo4j.Driver.Mapping.MappingFailedException: 'Cannot map record to type X because the record does not contain a value for the property 'Y'.'

Did the behaviour change?

To Reproduce
Use AsObject on a class where returned type is camelcase and property is pascalcase

    public class MapperConfiguration : IMappingProvider
    {
        public void CreateMappers(IMappingRegistry registry)
        {
            registry
                .RegisterMapping<X>(
                    mb =>
                    {
                        mb.UseDefaultMapping();
                        mb.Map(m => m.Y, "y");
                    });
        }
    }

RecordObjectMapping.RegisterProvider<MapperConfiguration>();

Expected behavior
It should map camelcase to pascalcase but not other way around and it should not matter if the property does not exists on the back-end side or it should be a optional configuration at least a option to disable it.

Version Info (please complete the following information):

  • .NET Version: .NET 8
  • .NET Driver Version 5.21.0
  • Neo4j Server Version & Edition 5.x
@XzaR90 XzaR90 added the bug label Jun 22, 2024
@XzaR90
Copy link
Author

XzaR90 commented Jun 23, 2024

Well, I made a workaround but I am not sure if it is the best approach,

using Neo4j.Driver.Mapping;
using X.Core.Models.Shared;
using System.Globalization;
using System.Reflection;

namespace X.Infrastructure.DataAccess.Database.Configurations
{
    public class MapperConfiguration : IMappingProvider
    {
        private readonly IDictionary<Type, IDictionary<string, PropertyInfo>> _propertyInfoCache;

        public MapperConfiguration()
        {
            _propertyInfoCache = new Dictionary<Type, IDictionary<string, PropertyInfo>>();
        }

        public void CreateMappers(IMappingRegistry registry)
        {
            registry
                .RegisterMapping<TestEntity>(
                    mb =>
                    {
                        mb.MapWholeObject((record) =>
                        {
                            var rankingTitleDict = record[0] as IDictionary<string, object>;
                            var ret = new TestEntity();
                            if (rankingTitleDict != null)
                            {
                                MapEntity<TestEntity, string>(ret, rankingTitleDict);
                            }

                            return ret;
                        });
                    });
        }

        private void MapEntity<T, TKey>(T entity, IDictionary<string, object> record) where T : IBaseEntity<TKey>
        {
            var entityType = typeof(T);
            var properties = GetCachedProperties(entityType);

            foreach (var property in properties)
            {
                var camelCaseName = char.ToLowerInvariant(property.Name[0]) + property.Name.Substring(1);
                if (record.ContainsKey(camelCaseName))
                {
                    var value = record[camelCaseName];
                    if (value != null)
                    {
                        property.SetValue(entity, Convert.ChangeType(value, property.PropertyType, CultureInfo.InvariantCulture));
                    }
                }
            }
        }

        private IEnumerable<PropertyInfo> GetCachedProperties(Type type)
        {
            if (!_propertyInfoCache.TryGetValue(type, out var properties))
            {
                properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
                                  .ToDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase);
                _propertyInfoCache[type] = properties;
            }

            return properties.Values;
        }
    }
}

@XzaR90
Copy link
Author

XzaR90 commented Jul 2, 2024

It got more complicated than I thought but I could solve it using chatgpt,

        public IMappingBuilder<T> CreateMapWholeObject<T>(IMappingBuilder<T> mb) where T : class
        {
            return mb.MapWholeObject((records) =>
            {
                var record = records[0] as IDictionary<string, object?>;
                var ret = (Activator.CreateInstance(typeof(T)) as T)!;
                if (record != null)
                {
                    MapEntity(ret, record);
                }

                return ret;
            });
        }

        private void MapEntity(Type entityType, object entity, IDictionary<string, object?> record)
        {
            var setters = GetOrAddPropertySetters(entityType);

            foreach (var (propertyName, setter) in setters)
            {
                var camelCaseName = char.ToLowerInvariant(propertyName[0]) + propertyName.Substring(1);
                if (record.TryGetValue(camelCaseName, out var value) && value != null)
                {
                    var propertyInfo = entityType.GetProperty(propertyName);
                    if (propertyInfo != null)
                    {
                        var propertyType = propertyInfo.PropertyType;
                        object? mappedValue;
                        if (IsClass(propertyType))
                        {
                            mappedValue = CreateAndMapNestedEntity(propertyType, value);
                        }
                        else if (IsArray(propertyType) || IsList(propertyType))
                        {
                            var elementType = IsArray(propertyType) ? propertyType.GetElementType() : propertyType.GetGenericArguments()[0];
                            mappedValue = CreateAndMapCollection(elementType!, (value as IList<object>)!, propertyType);
                        }
                        else
                        {
                            mappedValue = value;
                        }

                        if (mappedValue != null)
                        {
                            setter(entity, mappedValue);
                        }
                    }
                }
            }
        }

        private void MapEntity<T>(T entity, IDictionary<string, object?> record) where T : class
        {
            var entityType = typeof(T);
            MapEntity(entityType, entity, record);
        }

        private object? CreateAndMapNestedEntity(Type propertyType, object value)
        {
            var nestedEntity = Activator.CreateInstance(propertyType)!;
            var nestedDict = value as IDictionary<string, object?>;
            if (nestedDict != null)
            {
                MapEntity(nestedEntity.GetType(), nestedEntity, nestedDict);
            }

            return nestedEntity;
        }

        private object? CreateAndMapCollection(Type elementType, IList<object> listValue, Type propertyType)
        {
            if (IsArray(propertyType))
            {
                var array = Array.CreateInstance(elementType, listValue.Count);
                for (int i = 0; i < listValue.Count; i++)
                {
                    var item = CreateAndMapElement(elementType, listValue[i]);
                    array.SetValue(item, i);
                }

                return array;
            }
            else if (IsList(propertyType))
            {
                var list = (IList?)Activator.CreateInstance(typeof(List<>).MakeGenericType(elementType));
                if (list != null)
                {
                    foreach (var itemValue in listValue)
                    {
                        var item = CreateAndMapElement(elementType, itemValue);
                        list.Add(item);
                    }

                    return list;
                }
            }

            return null;
        }

        private IDictionary<string, Action<object, object>> GetOrAddPropertySetters(Type type)
        {
            if (!_propertySettersCache.TryGetValue(type, out var setters))
            {
                setters = CreatePropertySetters(type);
                _propertySettersCache[type] = setters;
            }

            return setters;
        }

        private IDictionary<string, Action<object, object>> CreatePropertySetters(Type type)
        {
            var setters = new Dictionary<string, Action<object, object>>();

            foreach (var property in GetCachedProperties(type))
            {
                // Skip properties that are not writable or indexed properties
                if (!property.CanWrite || property.GetIndexParameters().Length > 0)
                {
                    continue;
                }

                var propertyName = property.Name;
                var setter = CreatePropertySetter(property);
                setters[propertyName] = setter;
            }

            return setters;
        }

        private Action<object, object> CreatePropertySetter(PropertyInfo propertyInfo)
        {
            var entityParameter = Expression.Parameter(typeof(object), "entity");
            var valueParameter = Expression.Parameter(typeof(object), "value");

            // Convert entityParameter from object to the actual entity type
            var convertedEntity = Expression.Convert(entityParameter, propertyInfo.DeclaringType);

            // Check if the property type is nullable
            var propertyType = propertyInfo.PropertyType;
            var targetType = propertyType;

            // If it's a nullable type, use the underlying type
            if (propertyType.IsGenericType && propertyType.GetGenericTypeDefinition() == typeof(Nullable<>))
            {
                targetType = Nullable.GetUnderlyingType(propertyType);
            }

            // Convert valueParameter to the target type using Convert.ChangeType
            var convertExpression = Expression.Call(
                typeof(Convert),
                nameof(Convert.ChangeType),
                null,
                Expression.Convert(valueParameter, typeof(object)),
                Expression.Constant(targetType)
            );

            // Convert the result to the property type
            var convertedValue = Expression.Convert(convertExpression, propertyType);

            // Create the assignment expression: ((TEntity)entity).Property = convertedValue
            var propertySetter = Expression.Lambda<Action<object, object>>(
                Expression.Assign(Expression.Property(convertedEntity, propertyInfo), convertedValue),
                entityParameter,
                valueParameter
            ).Compile();

            return propertySetter;
        }

        private static IEnumerable<PropertyInfo> GetCachedProperties(Type type)
        {
            return type.GetProperties(BindingFlags.Public | BindingFlags.Instance);
        }

        private static bool IsClass(Type type)
        {
            return type.IsClass && type != typeof(string);
        }

        private static bool IsArray(Type type)
        {
            return type.IsArray;
        }

        private static bool IsList(Type type)
        {
            return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>);
        }

        private object? CreateAndMapElement(Type elementType, object value)
        {
            if (IsClass(elementType))
            {
                return CreateAndMapNestedEntity(elementType, value);
            }

            return Convert.ChangeType(value, elementType);
        }

@RichardIrons-neo4j
Copy link
Contributor

Hi there. The behaviour did change when the mapping went from preview to GA. If the case is different between the database and the code, you will need to be explicit about this. For example:

public class TestEntity
{
    [MappingSource("name")]
    public string Name { get; set; }

    [MappingOptional]
    public int Age { get; set; }

    [MappingDefaultValue("Unspecified")]
    public string Nationality { get; set; }
}

In the above example, we specify name (lower case) as being the source of the Name (pascal case) property. And we declare the Age property as optional, meaning no exception will be thrown if there is no Age field in the record being mapped. Finally with the Nationality property we specify a default value to use if the field is missing. MappingSource can be combined with either the MappingOptional or MappingDefaultValue attributes.

I hope this is useful information.

@kevinoneill
Copy link

@RichardIrons-neo4j would it be possible to allow us to pass a name mapper / or naming conventions instance in a similar way to json serialisation. I don't have control over the output model types or the input schema though the two share the same properties the naming conventions mean that they are stored as a_b and have property names in dotnet as Ab.

@RichardIrons-neo4j
Copy link
Contributor

RichardIrons-neo4j commented Aug 16, 2024

@kevinoneill (sorry I forgot to @ you)

Hi. There isn't currently a way to just provide a name translation service (which could be used for translating between naming conventions etc), although that is a good idea for a future feature.

Something you can do right now is to implement something like the following:

public class NameTranslationTests
{
    private class FirstNameMappingTestObject
    {
        public string FavouriteColor { get; set; }
        public int LuckyNumber { get; set; }
    }

    private class SecondNameMappingTestObject
    {
        public string JobTitle { get; set; }
        public int YearsOfService { get; set; }
    }

    private class NamingConventionTranslator<T> : IRecordMapper<T>
    {
        private string GetTranslatedPropertyName(string fieldName)
        {
            // convert from snake_case to PascalCase
            var capitaliseNext = true;
            var result = "";
            foreach (var c in fieldName)
            {
                if (c == '_')
                {
                    capitaliseNext = true;
                }
                else
                {
                    result += capitaliseNext ? char.ToUpper(c) : c;
                    capitaliseNext = false;
                }
            }

            return result;
        }

        /// <inheritdoc />
        public T Map(IRecord record)
        {
            // fields in the record will be like "something_else" and should be mapped to "SomethingElse" property
            var type = typeof(T);
            var obj = Activator.CreateInstance(type);
            foreach (var field in record.Keys)
            {
                var property = type.GetProperty(GetTranslatedPropertyName(field));
                if (property != null)
                {
                    property.SetValue(obj, record[field]);
                }
            }

            return (T)obj;
        }
    }

    [Fact]
    public void ShouldTranslateColumnNames()
    {
        var record1 = TestRecord.Create(("favourite_color", "blue"), ("lucky_number", 7));
        var record2 = TestRecord.Create(("job_title", "developer"), ("years_of_service", 5));

        // need to register a new instance for each type we're planning to map to
        RecordObjectMapping.Register(new NamingConventionTranslator<FirstNameMappingTestObject>());
        RecordObjectMapping.Register(new NamingConventionTranslator<SecondNameMappingTestObject>());

        var obj1 = record1.AsObject<FirstNameMappingTestObject>();
        var obj2 = record2.AsObject<SecondNameMappingTestObject>();

        obj1.FavouriteColor.Should().Be("blue");
        obj1.LuckyNumber.Should().Be(7);
        obj2.JobTitle.Should().Be("developer");
        obj2.YearsOfService.Should().Be(5);
    }
}

In this code the NamingConventionTranslator<T> class is what you're interested in - it will map field names in snake case to properties in Pascal case. You can see in the test that the type is registered once for every class that is going to be mapped to, which isn't ideal, but it does keep everything together in one place. One thing with this is that it will use this class instead of the default mapper, so things like the [MappingXXX] attributes will have no effect - although it sounds like you wouldn't be able to use them anyway.

I hope this code is useful, and thanks for the idea about being able to inject a column name mapper!

@XzaR90
Copy link
Author

XzaR90 commented Jan 11, 2025

I found another solution to implement my own IRecord where the input is a object of type IReadOnly<string,object?> and recursive rename all the camel case into pascal case and then use AsObject(). But I got one issue with missing keys because T contains properties that is not returned. Would it be possible to turn off that as a option?

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

No branches or pull requests

3 participants