You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ignite.apache.org by pt...@apache.org on 2022/10/31 12:35:17 UTC

[ignite-3] branch main updated: IGNITE-16356 .NET: Add mapped column name customization with ColumnAttribute (#1277)

This is an automated email from the ASF dual-hosted git repository.

ptupitsyn pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/ignite-3.git


The following commit(s) were added to refs/heads/main by this push:
     new 14b5464ed8 IGNITE-16356 .NET: Add mapped column name customization with ColumnAttribute (#1277)
14b5464ed8 is described below

commit 14b5464ed85d8eb86c90641371569c8fbcdc919c
Author: Pavel Tupitsyn <pt...@apache.org>
AuthorDate: Mon Oct 31 15:35:12 2022 +0300

    IGNITE-16356 .NET: Add mapped column name customization with ColumnAttribute (#1277)
    
    Support custom column names with a standard `System.ComponentModel.DataAnnotations.Schema.ColumnAttribute` when mapping user-defined types to Ignite columns in `RecordView<T>` and `KeyValueView<T>`.
---
 .../Table/RecordViewCustomMappingTest.cs           | 147 +++++++++++++++++++++
 .../Table/Serialization/ObjectSerializerHandler.cs |  14 +-
 .../Table/Serialization/ReflectionUtils.cs         |  64 ++++++++-
 3 files changed, 211 insertions(+), 14 deletions(-)

diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewCustomMappingTest.cs b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewCustomMappingTest.cs
new file mode 100644
index 0000000000..4f15100ac5
--- /dev/null
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewCustomMappingTest.cs
@@ -0,0 +1,147 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// ReSharper disable NotAccessedPositionalProperty.Local
+// ReSharper disable UnusedMember.Local
+// ReSharper disable UnusedAutoPropertyAccessor.Local
+// ReSharper disable AutoPropertyCanBeMadeGetOnly.Local
+namespace Apache.Ignite.Tests.Table;
+
+using System.ComponentModel.DataAnnotations.Schema;
+using System.Threading.Tasks;
+using Ignite.Table;
+using NUnit.Framework;
+
+/// <summary>
+/// Tests custom user type mapping behavior in <see cref="IRecordView{T}"/>.
+/// </summary>
+public class RecordViewCustomMappingTest : IgniteTestsBase
+{
+    private const long Key = 1;
+
+    private const string Val = "val1";
+
+    [SetUp]
+    public async Task SetUp()
+    {
+        await Table.RecordBinaryView.UpsertAsync(null, GetTuple(Key, Val));
+    }
+
+    [Test]
+    public async Task TestFieldMapping()
+    {
+        var res = await Table.GetRecordView<FieldMapping>().GetAsync(null, new FieldMapping(Key));
+        Assert.AreEqual(Val, res.Value.Name);
+    }
+
+    [Test]
+    public async Task TestRecordPropertyMapping()
+    {
+        var res = await Table.GetRecordView<PropertyMapping>().GetAsync(null, new PropertyMapping(Key));
+        Assert.AreEqual(Val, res.Value.Name);
+    }
+
+    [Test]
+    public async Task TestClassPropertyMapping()
+    {
+        var res = await Table.GetRecordView<ClassPropertyMapping>().GetAsync(null, new ClassPropertyMapping { Id = Key });
+        Assert.AreEqual(Val, res.Value.Name);
+    }
+
+    [Test]
+    public async Task TestStructFieldMapping()
+    {
+        var res = await Table.GetRecordView<StructFieldMapping>().GetAsync(null, new StructFieldMapping(Key));
+        Assert.AreEqual(Val, res.Value.Name);
+    }
+
+    [Test]
+    public async Task TestStructPropertyMapping()
+    {
+        var res = await Table.GetRecordView<StructPropertyMapping>().GetAsync(null, new StructPropertyMapping(Key));
+        Assert.AreEqual(Val, res.Value.Name);
+    }
+
+    [Test]
+    public void TestComputedPropertyMappingThrowsException()
+    {
+        var ex = Assert.ThrowsAsync<IgniteClientException>(async () =>
+            await Table.GetRecordView<ComputedPropertyMapping>().GetAsync(null, new ComputedPropertyMapping { Id = Key }));
+
+        Assert.AreEqual(ErrorGroups.Client.Configuration, ex!.Code);
+
+        Assert.AreEqual(
+            "Can't map 'Apache.Ignite.Tests.Table.RecordViewCustomMappingTest+ComputedPropertyMapping' to columns" +
+            " 'Int64 KEY, String VAL'. Matching fields not found.",
+            ex.Message);
+    }
+
+    [Test]
+    public void TestDuplicateColumnNameMappingThrowsException()
+    {
+        var ex = Assert.ThrowsAsync<IgniteClientException>(async () =>
+            await Table.GetRecordView<FieldMappingDuplicate>().GetAsync(null, new FieldMappingDuplicate(Key)));
+
+        Assert.AreEqual(ErrorGroups.Client.Configuration, ex!.Code);
+
+        Assert.AreEqual(
+            "Column 'Val' maps to more than one field of type " +
+            "Apache.Ignite.Tests.Table.RecordViewCustomMappingTest+FieldMappingDuplicate: " +
+            "System.String <Name2>k__BackingField and " +
+            "System.String <Name>k__BackingField",
+            ex.Message);
+    }
+
+    private record struct StructFieldMapping([field: Column("Key")] long Id, [field: Column("Val")] string? Name = null);
+
+    private record struct StructPropertyMapping([property: Column("KEY")] long Id, [property: Column("VAL")] string? Name = null);
+
+    private record FieldMapping([field: Column("Key")] long Id, [field: Column("Val")] string? Name = null);
+
+    private record PropertyMapping([property: Column("Key")] long Id, [property: Column("Val")] string? Name = null);
+
+    private class ClassPropertyMapping
+    {
+        [Column("key")]
+        public long Id { get; set; }
+
+        [Column("VAL")]
+        public string Name { get; set; } = null!;
+    }
+
+    // ReSharper disable MemberHidesStaticFromOuterClass
+    private record ComputedPropertyMapping
+    {
+        public long Id { get; set; }
+
+        public string? Name { get; set; }
+
+        public long Key
+        {
+            get => Id;
+            set => Id = value;
+        }
+
+        public string? Val
+        {
+            get => Name;
+            set => Name = value;
+        }
+    }
+
+    private record FieldMappingDuplicate(long Key, [field: Column("Val")] string? Name = null, [field: Column("Val")] string? Name2 = null);
+}
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/ObjectSerializerHandler.cs b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/ObjectSerializerHandler.cs
index 8969bce8de..a0cfe9450d 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/ObjectSerializerHandler.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/ObjectSerializerHandler.cs
@@ -143,7 +143,7 @@ namespace Apache.Ignite.Internal.Table.Serialization
             for (var index = 0; index < count; index++)
             {
                 var col = columns[index];
-                var fieldInfo = type.GetFieldIgnoreCase(col.Name);
+                var fieldInfo = type.GetFieldByColumnName(col.Name);
 
                 if (fieldInfo == null)
                 {
@@ -211,7 +211,7 @@ namespace Apache.Ignite.Internal.Table.Serialization
                 }
                 else
                 {
-                    fieldInfo = (col.IsKey ? keyType : valType).GetFieldIgnoreCase(col.Name);
+                    fieldInfo = (col.IsKey ? keyType : valType).GetFieldByColumnName(col.Name);
                 }
 
                 if (fieldInfo == null)
@@ -296,7 +296,7 @@ namespace Apache.Ignite.Internal.Table.Serialization
             for (var i = 0; i < count; i++)
             {
                 var col = columns[i];
-                var fieldInfo = type.GetFieldIgnoreCase(col.Name);
+                var fieldInfo = type.GetFieldByColumnName(col.Name);
 
                 EmitFieldRead(fieldInfo, il, col, i, local);
             }
@@ -345,7 +345,7 @@ namespace Apache.Ignite.Internal.Table.Serialization
                     local = col.IsKey ? keyLocal : valLocal;
                     fieldInfo = local == null
                         ? null
-                        : (col.IsKey ? keyType : valType).GetFieldIgnoreCase(col.Name);
+                        : (col.IsKey ? keyType : valType).GetFieldByColumnName(col.Name);
                 }
 
                 EmitFieldRead(fieldInfo, il, col, i, local);
@@ -403,7 +403,7 @@ namespace Apache.Ignite.Internal.Table.Serialization
             for (var i = 0; i < columns.Count; i++)
             {
                 var col = columns[i];
-                var fieldInfo = type.GetFieldIgnoreCase(col.Name);
+                var fieldInfo = type.GetFieldByColumnName(col.Name);
 
                 if (col.IsKey)
                 {
@@ -456,7 +456,7 @@ namespace Apache.Ignite.Internal.Table.Serialization
                 for (var i = schema.KeyColumnCount; i < columns.Count; i++)
                 {
                     var col = columns[i];
-                    var fieldInfo = valType.GetFieldIgnoreCase(col.Name);
+                    var fieldInfo = valType.GetFieldByColumnName(col.Name);
 
                     EmitFieldRead(fieldInfo, il, col, i - schema.KeyColumnCount, valLocal);
                 }
@@ -612,7 +612,7 @@ namespace Apache.Ignite.Internal.Table.Serialization
 
             var keyValTypes = type.GetGenericArguments();
 
-            return (keyValTypes[0], keyValTypes[1], type.GetFieldIgnoreCase("Key")!, type.GetFieldIgnoreCase("Val")!);
+            return (keyValTypes[0], keyValTypes[1], type.GetFieldByColumnName("Key")!, type.GetFieldByColumnName("Val")!);
         }
     }
 }
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/ReflectionUtils.cs b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/ReflectionUtils.cs
index d976239fc1..9a10fbfa94 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/ReflectionUtils.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/ReflectionUtils.cs
@@ -20,9 +20,10 @@ namespace Apache.Ignite.Internal.Table.Serialization
     using System;
     using System.Collections.Concurrent;
     using System.Collections.Generic;
-    using System.Linq;
+    using System.ComponentModel.DataAnnotations.Schema;
     using System.Linq.Expressions;
     using System.Reflection;
+    using System.Runtime.CompilerServices;
     using System.Runtime.Serialization;
 
     /// <summary>
@@ -41,7 +42,7 @@ namespace Apache.Ignite.Internal.Table.Serialization
         /// </summary>
         public static readonly MethodInfo GetTypeFromHandleMethod = GetMethodInfo(() => Type.GetTypeFromHandle(default));
 
-        private static readonly ConcurrentDictionary<Type, IReadOnlyDictionary<string, FieldInfo>> FieldsByNameCache = new();
+        private static readonly ConcurrentDictionary<Type, IReadOnlyDictionary<string, FieldInfo>> FieldsByColumnNameCache = new();
 
         /// <summary>
         /// Gets all fields from the type, including non-public and inherited.
@@ -50,6 +51,11 @@ namespace Apache.Ignite.Internal.Table.Serialization
         /// <returns>Fields.</returns>
         public static IEnumerable<FieldInfo> GetAllFields(this Type type)
         {
+            if (type.IsPrimitive)
+            {
+                yield break;
+            }
+
             const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public |
                                        BindingFlags.NonPublic | BindingFlags.DeclaredOnly;
 
@@ -67,20 +73,38 @@ namespace Apache.Ignite.Internal.Table.Serialization
         }
 
         /// <summary>
-        /// Gets the field by name ignoring case.
+        /// Gets the field by column name. Ignores case, handles <see cref="ColumnAttribute"/>.
         /// </summary>
         /// <param name="type">Type.</param>
         /// <param name="name">Field name.</param>
         /// <returns>Field info, or null when no matching fields exist.</returns>
-        public static FieldInfo? GetFieldIgnoreCase(this Type type, string name)
+        public static FieldInfo? GetFieldByColumnName(this Type type, string name)
         {
             // ReSharper disable once HeapView.CanAvoidClosure, ConvertClosureToMethodGroup
-            return FieldsByNameCache.GetOrAdd(type, t => GetFieldsByName(t)).TryGetValue(name, out var fieldInfo)
+            return FieldsByColumnNameCache.GetOrAdd(type, t => GetFieldsByColumnName(t)).TryGetValue(name, out var fieldInfo)
                 ? fieldInfo
                 : null;
 
-            static IReadOnlyDictionary<string, FieldInfo> GetFieldsByName(Type type) =>
-                type.GetAllFields().ToDictionary(f => f.GetCleanName(), StringComparer.OrdinalIgnoreCase);
+            static IReadOnlyDictionary<string, FieldInfo> GetFieldsByColumnName(Type type)
+            {
+                var res = new Dictionary<string, FieldInfo>(StringComparer.OrdinalIgnoreCase);
+
+                foreach (var field in type.GetAllFields())
+                {
+                    var columnName = field.GetColumnName();
+
+                    if (res.TryGetValue(columnName, out var existingField))
+                    {
+                        throw new IgniteClientException(
+                            ErrorGroups.Client.Configuration,
+                            $"Column '{columnName}' maps to more than one field of type {type}: {field} and {existingField}");
+                    }
+
+                    res.Add(columnName, field);
+                }
+
+                return res;
+            }
         }
 
         /// <summary>
@@ -90,6 +114,32 @@ namespace Apache.Ignite.Internal.Table.Serialization
         /// <returns>Clean name.</returns>
         public static string GetCleanName(this MemberInfo memberInfo) => CleanFieldName(memberInfo.Name);
 
+        /// <summary>
+        /// Gets column name for the specified field: uses <see cref="ColumnAttribute"/> when available,
+        /// falls back to cleaned up field name otherwise.
+        /// </summary>
+        /// <param name="fieldInfo">Member.</param>
+        /// <returns>Clean name.</returns>
+        public static string GetColumnName(this FieldInfo fieldInfo)
+        {
+            if (fieldInfo.GetCustomAttribute<ColumnAttribute>() is { Name: { } columnAttributeName })
+            {
+                return columnAttributeName;
+            }
+
+            var cleanName = fieldInfo.GetCleanName();
+
+            if (fieldInfo.IsDefined(typeof(CompilerGeneratedAttribute), inherit: true) &&
+                fieldInfo.DeclaringType?.GetProperty(cleanName) is { } property &&
+                property.GetCustomAttribute<ColumnAttribute>() is { Name: { } columnAttributeName2 })
+            {
+                // This is a compiler-generated backing field for an automatic property - get the attribute from the property.
+                return columnAttributeName2;
+            }
+
+            return cleanName;
+        }
+
         /// <summary>
         /// Cleans the field name and removes compiler-generated prefixes and suffixes.
         /// </summary>