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 0e18f86bf1c IGNITE-27278 .NET: Add mapper support to SQL, Compute, 
PartitionManager APIs (#7305)
0e18f86bf1c is described below

commit 0e18f86bf1c67f2357f4915ab1d971636b9fac0a
Author: Pavel Tupitsyn <[email protected]>
AuthorDate: Tue Dec 30 12:01:54 2025 +0200

    IGNITE-27278 .NET: Add mapper support to SQL, Compute, PartitionManager 
APIs (#7305)
    
    Add remaining public APIs with `IMapper<T>`:
    * `JobTarget.Colocated`
    * `ISql.ExecuteAsync<T>`
    * `IPartitionManager.GetPartitionAsync`
---
 .../Sql/ResultSetBenchmarks.cs                     | 24 ++++++-
 .../Compute/ComputeTests.cs                        | 25 +++++--
 .../dotnet/Apache.Ignite.Tests.Aot/Sql/SqlTests.cs | 36 ++++++++++
 .../Table/IntMapper.cs}                            | 21 ++----
 .../Table/PocoAllColumnsSqlNullableMapper.cs       |  3 +-
 .../Apache.Ignite.Tests/Compute/ComputeTests.cs    |  5 ++
 .../Apache.Ignite.Tests/PartitionAwarenessTests.cs | 51 ++++++++++++--
 .../Sql/SqlResultSetObjectMappingTests.cs          | 80 +++++++++++++++++++++-
 .../dotnet/Apache.Ignite.Tests/Sql/SqlTests.cs     |  4 +-
 .../Table/PartitionManagerTests.cs                 |  7 +-
 .../Apache.Ignite/ApiCompatibilitySuppressions.xml | 14 ++++
 .../dotnet/Apache.Ignite/Compute/JobTarget.cs      | 58 +++++++++++++++-
 .../Apache.Ignite/Internal/Compute/Compute.cs      | 19 +++--
 .../Internal/Linq/IgniteQueryExecutor.cs           |  3 +-
 .../Apache.Ignite/Internal/Linq/ResultSelector.cs  | 18 ++---
 .../Apache.Ignite/Internal/Sql/ColumnMetadata.cs   |  3 +-
 .../dotnet/Apache.Ignite/Internal/Sql/ResultSet.cs | 35 ++++++----
 .../Internal/Sql/ResultSetMetadata.cs              | 29 ++++++--
 .../dotnet/Apache.Ignite/Internal/Sql/RowReader.cs |  7 +-
 .../Apache.Ignite/Internal/Sql/RowReaderFactory.cs |  7 +-
 .../dotnet/Apache.Ignite/Internal/Sql/Sql.cs       | 59 ++++++++++++++--
 .../dotnet/Apache.Ignite/Internal/Table/Column.cs  |  3 +
 .../Internal/Table/PartitionManager.cs             |  6 ++
 .../Table/Serialization/MapperSerializerHandler.cs |  7 +-
 modules/platforms/dotnet/Apache.Ignite/Sql/ISql.cs | 18 +++++
 .../Apache.Ignite/Table/IPartitionManager.cs       | 13 +++-
 .../Apache.Ignite/Table/Mapper/IMapperColumn.cs    | 21 ++++++
 .../dotnet/Apache.Ignite/Table/Mapper/RowReader.cs | 14 ++--
 28 files changed, 492 insertions(+), 98 deletions(-)

diff --git 
a/modules/platforms/dotnet/Apache.Ignite.Benchmarks/Sql/ResultSetBenchmarks.cs 
b/modules/platforms/dotnet/Apache.Ignite.Benchmarks/Sql/ResultSetBenchmarks.cs
index b08121a878c..edc938e5f18 100644
--- 
a/modules/platforms/dotnet/Apache.Ignite.Benchmarks/Sql/ResultSetBenchmarks.cs
+++ 
b/modules/platforms/dotnet/Apache.Ignite.Benchmarks/Sql/ResultSetBenchmarks.cs
@@ -23,9 +23,9 @@ namespace Apache.Ignite.Benchmarks.Sql
     using System.Threading;
     using System.Threading.Tasks;
     using BenchmarkDotNet.Attributes;
-    using BenchmarkDotNet.Jobs;
     using Ignite.Sql;
     using Ignite.Table;
+    using Ignite.Table.Mapper;
     using Tests;
 
     /// <summary>
@@ -43,7 +43,6 @@ namespace Apache.Ignite.Benchmarks.Sql
     /// |    PrimitiveMappingToListAsync | 121.2 us | 2.41 us | 4.10 us |  
0.54 |    0.02 |      - |     48 KB |.
     /// </summary>
     [MemoryDiagnoser]
-    [SimpleJob(RuntimeMoniker.Net60)]
     public class ResultSetBenchmarks
     {
         private FakeServer? _server;
@@ -151,6 +150,18 @@ namespace Apache.Ignite.Benchmarks.Sql
             }
         }
 
+        [Benchmark]
+        public async Task ObjectMappingWithMapperToListAsync()
+        {
+            await using var resultSet = await _client!.Sql.ExecuteAsync(null, 
new RecMapper(), "select 1", CancellationToken.None);
+            var rows = await resultSet.ToListAsync();
+
+            if (rows.Count != 1012)
+            {
+                throw new Exception("Wrong count");
+            }
+        }
+
         [Benchmark]
         public async Task PrimitiveMappingToListAsync()
         {
@@ -164,5 +175,14 @@ namespace Apache.Ignite.Benchmarks.Sql
         }
 
         private sealed record Rec(int Id);
+
+        private sealed class RecMapper : IMapper<Rec>
+        {
+            public void Write(Rec obj, ref RowWriter rowWriter, IMapperSchema 
schema) =>
+                throw new NotImplementedException();
+
+            public Rec Read(ref RowReader rowReader, IMapperSchema schema) =>
+                new(rowReader.ReadInt()!.Value);
+        }
     }
 }
diff --git 
a/modules/platforms/dotnet/Apache.Ignite.Tests.Aot/Compute/ComputeTests.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests.Aot/Compute/ComputeTests.cs
index e83c8267821..0ea1ae86065 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests.Aot/Compute/ComputeTests.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests.Aot/Compute/ComputeTests.cs
@@ -18,6 +18,7 @@
 namespace Apache.Ignite.Tests.Aot.Compute;
 
 using Common.Compute;
+using Common.Table;
 using Ignite.Compute;
 using Ignite.Table;
 using JetBrains.Annotations;
@@ -38,27 +39,41 @@ public class ComputeTests(IIgniteClient client)
     }
 
     [UsedImplicitly]
-    public async Task TestColocated()
+    public async Task TestColocatedTuple()
     {
         var keyTuple = new IgniteTuple { [KeyCol] = 42L };
 
-        IJobExecution<string> exec = await 
client.Compute.SubmitAsync(JobTarget.Colocated(TableName, keyTuple), 
JavaJobs.NodeNameJob, null);
+        IJobTarget<IgniteTuple> jobTarget = JobTarget.Colocated(TableName, 
keyTuple);
+        IJobExecution<string> exec = await 
client.Compute.SubmitAsync(jobTarget, JavaJobs.NodeNameJob, null);
         var res = await exec.GetResultAsync();
 
         Assert.AreEqual(JavaJobs.PlatformTestNodeRunner, res);
     }
 
     [UsedImplicitly]
-    public async Task TestColocatedPrimitiveKeyThrows()
+    public async Task TestColocatedPoco()
+    {
+        var key = new Poco { Key = 42L };
+
+        IJobTarget<Poco> jobTarget = JobTarget.Colocated(TableName, key, new 
PocoMapper());
+        IJobExecution<string> exec = await 
client.Compute.SubmitAsync(jobTarget, JavaJobs.NodeNameJob, null);
+        var res = await exec.GetResultAsync();
+
+        Assert.AreEqual(JavaJobs.PlatformTestNodeRunner, res);
+    }
+
+    [UsedImplicitly]
+    public async Task TestColocatedWithoutMapperThrows()
     {
         try
         {
-            await client.Compute.SubmitAsync(JobTarget.Colocated(TableName, 
42L), JavaJobs.NodeNameJob, null);
+            IJobTarget<long> jobTarget = JobTarget.Colocated(TableName, 42L);
+            await client.Compute.SubmitAsync(jobTarget, JavaJobs.NodeNameJob, 
null);
             throw new Exception("Expected exception was not thrown.");
         }
         catch (InvalidOperationException e)
         {
-            Assert.AreEqual("Colocated job target requires an IIgniteTuple key 
when running in trimmed AOT mode.", e.Message);
+            Assert.AreEqual("Use JobTarget.Colocated overload with 
IMapper<T>.", e.Message);
         }
     }
 
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests.Aot/Sql/SqlTests.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests.Aot/Sql/SqlTests.cs
index 5bda2ea72be..729a83de304 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests.Aot/Sql/SqlTests.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests.Aot/Sql/SqlTests.cs
@@ -84,6 +84,42 @@ public class SqlTests(IIgniteClient client)
         Assert.AreEqual(poco.Blob, row["BLOB"]!);
     }
 
+    [UsedImplicitly]
+    public async Task TestAllColumnTypesWithMapper()
+    {
+        var table = await 
client.Tables.GetTableAsync(TestTables.TableAllColumnsSqlName);
+        var view = table!.GetRecordView(new PocoAllColumnsSqlMapper());
+
+        var poco = GetPoco();
+        await view.UpsertAsync(null, poco);
+
+        await using IResultSet<PocoAllColumnsSql> resultSet = await 
client.Sql.ExecuteAsync(
+            transaction: null,
+            mapper: new PocoAllColumnsSqlMapper(),
+            statement: $"select * from {table.Name} where KEY = ?",
+            CancellationToken.None,
+            poco.Key);
+
+        List<PocoAllColumnsSql> rows = await resultSet.ToListAsync();
+        PocoAllColumnsSql row = rows.Single();
+
+        Assert.AreEqual(poco.Key, row.Key);
+        Assert.AreEqual(poco.Str, row.Str);
+        Assert.AreEqual(poco.Int8, row.Int8);
+        Assert.AreEqual(poco.Int16, row.Int16);
+        Assert.AreEqual(poco.Int32, row.Int32);
+        Assert.AreEqual(poco.Int64, row.Int64);
+        Assert.AreEqual(poco.Float, row.Float);
+        Assert.AreEqual(poco.Double, row.Double);
+        Assert.AreEqual(poco.Uuid, row.Uuid);
+        Assert.AreEqual(poco.Decimal, row.Decimal);
+        Assert.AreEqual(poco.Date, row.Date);
+        Assert.AreEqual(poco.Time, row.Time);
+        Assert.AreEqual(poco.DateTime, row.DateTime);
+        Assert.AreEqual(poco.Timestamp, row.Timestamp);
+        Assert.AreEqual(poco.Blob, row.Blob);
+    }
+
     [UsedImplicitly]
     public async Task TestExecuteReaderAsync()
     {
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Table/Mapper/IMapperColumn.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests.Common/Table/IntMapper.cs
similarity index 71%
copy from modules/platforms/dotnet/Apache.Ignite/Table/Mapper/IMapperColumn.cs
copy to modules/platforms/dotnet/Apache.Ignite.Tests.Common/Table/IntMapper.cs
index b83019f2a92..21e955760d2 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Table/Mapper/IMapperColumn.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests.Common/Table/IntMapper.cs
@@ -15,22 +15,15 @@
  * limitations under the License.
  */
 
-namespace Apache.Ignite.Table.Mapper;
+namespace Apache.Ignite.Tests.Common.Table;
 
-using Sql;
+using Ignite.Table.Mapper;
 
-/// <summary>
-/// Mapper schema column.
-/// </summary>
-public interface IMapperColumn
+public class IntMapper : IMapper<int>
 {
-    /// <summary>
-    /// Gets the column name.
-    /// </summary>
-    string Name { get; }
+    public void Write(int obj, ref RowWriter rowWriter, IMapperSchema schema)
+        => rowWriter.WriteInt(obj);
 
-    /// <summary>
-    /// Gets the column type.
-    /// </summary>
-    ColumnType Type { get; }
+    public int Read(ref RowReader rowReader, IMapperSchema schema)
+        => rowReader.ReadInt()!.Value;
 }
diff --git 
a/modules/platforms/dotnet/Apache.Ignite.Tests.Common/Table/PocoAllColumnsSqlNullableMapper.cs
 
b/modules/platforms/dotnet/Apache.Ignite.Tests.Common/Table/PocoAllColumnsSqlNullableMapper.cs
index 6ced7807b0a..80c0dc443b9 100644
--- 
a/modules/platforms/dotnet/Apache.Ignite.Tests.Common/Table/PocoAllColumnsSqlNullableMapper.cs
+++ 
b/modules/platforms/dotnet/Apache.Ignite.Tests.Common/Table/PocoAllColumnsSqlNullableMapper.cs
@@ -187,7 +187,8 @@ public class PocoAllColumnsSqlNullableMapper : 
IMapper<PocoAllColumnsSqlNullable
                     break;
 
                 default:
-                    throw new InvalidOperationException("Unexpected column: " 
+ col.Name);
+                    rowReader.Skip();
+                    break;
             }
         }
 
diff --git 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Compute/ComputeTests.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Compute/ComputeTests.cs
index 023308a360d..7e018f422b5 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/Compute/ComputeTests.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Compute/ComputeTests.cs
@@ -279,12 +279,16 @@ namespace Apache.Ignite.Tests.Compute
             var resNodeName3 = await 
client.Compute.SubmitAsync(JobTarget.Colocated(TableName, keyPocoStruct), 
NodeNameJob, null);
             var requestTargetNodeName3 = GetRequestTargetNodeName(proxies, 
ClientOp.ComputeExecuteColocated);
 
+            var resNodeName4 = await 
client.Compute.SubmitAsync(JobTarget.Colocated(QualifiedName.Parse(TableName), 
keyPoco, new PocoMapper()), NodeNameJob, null);
+            var requestTargetNodeName4 = GetRequestTargetNodeName(proxies, 
ClientOp.ComputeExecuteColocated);
+
             var nodeName = nodeIdx == 1 ? string.Empty : "_" + nodeIdx;
             var expectedNodeName = PlatformTestNodeRunner + nodeName;
 
             Assert.AreEqual(expectedNodeName, await 
resNodeName.GetResultAsync());
             Assert.AreEqual(expectedNodeName, await 
resNodeName2.GetResultAsync());
             Assert.AreEqual(expectedNodeName, await 
resNodeName3.GetResultAsync());
+            Assert.AreEqual(expectedNodeName, await 
resNodeName4.GetResultAsync());
 
             // We only connect to 2 of 4 nodes because of different auth 
settings.
             if (nodeIdx < 3)
@@ -292,6 +296,7 @@ namespace Apache.Ignite.Tests.Compute
                 Assert.AreEqual(expectedNodeName, requestTargetNodeName);
                 Assert.AreEqual(expectedNodeName, requestTargetNodeName2);
                 Assert.AreEqual(expectedNodeName, requestTargetNodeName3);
+                Assert.AreEqual(expectedNodeName, requestTargetNodeName4);
             }
         }
 
diff --git 
a/modules/platforms/dotnet/Apache.Ignite.Tests/PartitionAwarenessTests.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests/PartitionAwarenessTests.cs
index ae7d446220d..e58ce786c40 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/PartitionAwarenessTests.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/PartitionAwarenessTests.cs
@@ -24,6 +24,7 @@ using System.Threading.Channels;
 using System.Threading.Tasks;
 using Ignite.Compute;
 using Ignite.Table;
+using Ignite.Table.Mapper;
 using Ignite.Transactions;
 using Internal.Proto;
 using Internal.Transactions;
@@ -48,6 +49,12 @@ public class PartitionAwarenessTests
         new object[] { int.MinValue, 2 }
     };
 
+    private static readonly object[] KeyNodeCasesWithMapper = KeyNodeCases
+        .Cast<object[]>()
+        .SelectMany(arr => new[] { true, false }.Select(withMapper => 
(object[])[.. arr, withMapper]))
+        .Cast<object>()
+        .ToArray();
+
     private FakeServer _server1 = null!;
     private FakeServer _server2 = null!;
 
@@ -320,10 +327,14 @@ public class PartitionAwarenessTests
     }
 
     [Test]
-    public async Task TestCompositeKey()
+    public async Task TestCompositeKey([Values(true, false)] bool withMapper)
     {
         using var client = await GetClient();
-        var view = (await 
client.Tables.GetTableAsync(FakeServer.CompositeKeyTableName))!.GetRecordView<CompositeKey>();
+
+        var table = await 
client.Tables.GetTableAsync(FakeServer.CompositeKeyTableName);
+        var view = withMapper
+            ? table!.GetRecordView(new CompositeKeyMapper())
+            : table!.GetRecordView<CompositeKey>();
 
         await view.UpsertAsync(null, new CompositeKey("1", Guid.Empty)); // 
Warm up.
 
@@ -338,10 +349,14 @@ public class PartitionAwarenessTests
     }
 
     [Test]
-    public async Task TestCustomColocationKey()
+    public async Task TestCustomColocationKey([Values(true, false)] bool 
withMapper)
     {
         using var client = await GetClient();
-        var view = (await 
client.Tables.GetTableAsync(FakeServer.CustomColocationKeyTableName))!.GetRecordView<CompositeKey>();
+
+        var table = await 
client.Tables.GetTableAsync(FakeServer.CustomColocationKeyTableName);
+        var view = withMapper
+            ? table!.GetRecordView(new CompositeKeyMapper())
+            : table!.GetRecordView<CompositeKey>();
 
         // Warm up.
         await view.UpsertAsync(null, new CompositeKey("1", Guid.Empty));
@@ -375,14 +390,17 @@ public class PartitionAwarenessTests
     }
 
     [Test]
-    [TestCaseSource(nameof(KeyNodeCases))]
-    public async Task 
TestExecuteColocatedObjectKeyRoutesRequestToPrimaryNode(int keyId, int node)
+    [TestCaseSource(nameof(KeyNodeCasesWithMapper))]
+    public async Task 
TestExecuteColocatedObjectKeyRoutesRequestToPrimaryNode(int keyId, int node, 
bool withMapper)
     {
         using var client = await GetClient();
         var expectedNode = node == 1 ? _server1 : _server2;
         var key = new SimpleKey(keyId);
 
-        var jobTarget = JobTarget.Colocated(FakeServer.ExistingTableName, key);
+        var jobTarget = withMapper
+            ? JobTarget.Colocated(FakeServer.ExistingTableName, key, new 
SimpleKeyMapper())
+            : JobTarget.Colocated(FakeServer.ExistingTableName, key);
+
         var jobDescriptor = new JobDescriptor<object?, object?>("job");
 
         // Warm up.
@@ -525,5 +543,24 @@ public class PartitionAwarenessTests
     // ReSharper disable NotAccessedPositionalProperty.Local
     private record CompositeKey(string IdStr, Guid IdGuid);
 
+    private sealed class CompositeKeyMapper : IMapper<CompositeKey>
+    {
+        public void Write(CompositeKey obj, ref RowWriter rowWriter, 
IMapperSchema schema)
+        {
+            rowWriter.WriteString(obj.IdStr);
+            rowWriter.WriteGuid(obj.IdGuid);
+        }
+
+        public CompositeKey Read(ref RowReader rowReader, IMapperSchema 
schema) =>
+            new(rowReader.ReadString()!, rowReader.ReadGuid()!.Value);
+    }
+
     private record SimpleKey(int Id);
+
+    private sealed class SimpleKeyMapper : IMapper<SimpleKey>
+    {
+        public void Write(SimpleKey obj, ref RowWriter rowWriter, 
IMapperSchema schema) => rowWriter.WriteInt(obj.Id);
+
+        public SimpleKey Read(ref RowReader rowReader, IMapperSchema schema) 
=> new(rowReader.ReadInt()!.Value);
+    }
 }
diff --git 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Sql/SqlResultSetObjectMappingTests.cs
 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Sql/SqlResultSetObjectMappingTests.cs
index 6eb90568c63..47224a1e597 100644
--- 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Sql/SqlResultSetObjectMappingTests.cs
+++ 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Sql/SqlResultSetObjectMappingTests.cs
@@ -18,11 +18,16 @@
 namespace Apache.Ignite.Tests.Sql;
 
 using System;
+using System.Collections.Generic;
 using System.ComponentModel.DataAnnotations.Schema;
+using System.Globalization;
 using System.Linq;
+using System.Text;
+using System.Threading;
 using System.Threading.Tasks;
 using Common.Table;
 using Ignite.Sql;
+using Ignite.Table.Mapper;
 using NodaTime;
 using NUnit.Framework;
 using static Common.Table.TestTables;
@@ -89,9 +94,12 @@ public class SqlResultSetObjectMappingTests : IgniteTestsBase
     }
 
     [Test]
-    public async Task TestSelectAllColumns()
+    public async Task TestSelectAllColumns([Values(true, false)] bool 
withMapper)
     {
-        var resultSet = await 
Client.Sql.ExecuteAsync<PocoAllColumnsSqlNullable>(null, "select * from 
TBL_ALL_COLUMNS_SQL order by 1");
+        var resultSet = withMapper
+            ? await Client.Sql.ExecuteAsync(null, new 
PocoAllColumnsSqlNullableMapper(), "select * from TBL_ALL_COLUMNS_SQL order by 
1", CancellationToken.None)
+            : await Client.Sql.ExecuteAsync<PocoAllColumnsSqlNullable>(null, 
"select * from TBL_ALL_COLUMNS_SQL order by 1");
+
         var rows = await resultSet.ToListAsync();
 
         Assert.AreEqual(Count + 1, rows.Count);
@@ -115,6 +123,25 @@ public class SqlResultSetObjectMappingTests : 
IgniteTestsBase
         Assert.AreEqual(new PocoAllColumnsSqlNullable(100), rows[Count]);
     }
 
+    [Test]
+    public async Task TestSelectAllColumnsStringMapper()
+    {
+        var resultSet = await Client.Sql.ExecuteAsync(
+                transaction: null,
+                new StringMapper(),
+                "select * from TBL_ALL_COLUMNS_SQL order by KEY LIMIT 1",
+                CancellationToken.None);
+
+        List<string> rows = await resultSet.ToListAsync();
+        string row = rows.Single();
+
+        Assert.AreEqual(
+            "STR=v-0, INT8=1, KEY=0, INT16=2, INT32=3, INT64=4, FLOAT=5.5, 
DOUBLE=6.5, UUID=00000001-0002-0003-0405-060708090a01, " +
+            "DATE=Thursday, 01 December 2022, TIME=11:38:01, TIME2=, 
DATETIME=12/19/2022 11:01:00, DATETIME2=, " +
+            "TIMESTAMP=1970-01-01T00:00:01Z, TIMESTAMP2=, BLOB=System.Byte[], 
DECIMAL=7.7, BOOLEAN=",
+            row);
+    }
+
     [Test]
     public async Task TestSelectUuid()
     {
@@ -259,4 +286,53 @@ public class SqlResultSetObjectMappingTests : 
IgniteTestsBase
     private record ConvertTypeRec(sbyte Key, float Double, double Float, long 
Int8);
 
     private record DateTimeRec(DateTime Dt);
+
+    private class StringMapper : IMapper<string>
+    {
+        public void Write(string obj, ref RowWriter rowWriter, IMapperSchema 
schema) => throw new NotImplementedException();
+
+        public string Read(ref RowReader rowReader, IMapperSchema schema)
+        {
+            var sb = new StringBuilder();
+
+            foreach (var col in schema.Columns)
+            {
+                if (sb.Length > 0)
+                {
+                    sb.Append(", ");
+                }
+
+                object? val = col.Type switch
+                {
+                    ColumnType.Null => null,
+                    ColumnType.Boolean => rowReader.ReadBool(),
+                    ColumnType.Int8 => rowReader.ReadByte(),
+                    ColumnType.Int16 => rowReader.ReadShort(),
+                    ColumnType.Int32 => rowReader.ReadInt(),
+                    ColumnType.Int64 => rowReader.ReadLong(),
+                    ColumnType.Float => rowReader.ReadFloat(),
+                    ColumnType.Double => rowReader.ReadDouble(),
+                    ColumnType.Decimal => rowReader.ReadDecimal(),
+                    ColumnType.Date => rowReader.ReadDate(),
+                    ColumnType.Time => rowReader.ReadTime(),
+                    ColumnType.Datetime => rowReader.ReadDateTime(),
+                    ColumnType.Timestamp => rowReader.ReadTimestamp(),
+                    ColumnType.Uuid => rowReader.ReadGuid(),
+                    ColumnType.String => rowReader.ReadString(),
+                    ColumnType.ByteArray => rowReader.ReadBytes(),
+                    ColumnType.Period => rowReader.ReadPeriod(),
+                    ColumnType.Duration => rowReader.ReadDuration(),
+                    _ => throw new InvalidOperationException("Unexpected 
column type: " + col.Type)
+                };
+
+                var valStr = val is IFormattable formattable
+                    ? formattable.ToString(null, CultureInfo.InvariantCulture)
+                    : val?.ToString();
+
+                sb.Append(col.Name).Append('=').Append(valStr);
+            }
+
+            return sb.ToString();
+        }
+    }
 }
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/Sql/SqlTests.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Sql/SqlTests.cs
index e0dddee043d..b5bd59599da 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/Sql/SqlTests.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Sql/SqlTests.cs
@@ -25,6 +25,7 @@ namespace Apache.Ignite.Tests.Sql
     using System.Text;
     using System.Threading;
     using System.Threading.Tasks;
+    using Common.Table;
     using Ignite.Sql;
     using Ignite.Table;
     using Ignite.Transactions;
@@ -792,7 +793,7 @@ namespace Apache.Ignite.Tests.Sql
         }
 
         [Test]
-        public async Task TestCancelQueryExecute([Values("sql", "sql-mapped", 
"script", "reader", "batch")] string mode)
+        public async Task TestCancelQueryExecute([Values("sql", "sql-mapped", 
"sql-mapped2", "script", "reader", "batch")] string mode)
         {
             // Cross join will produce 10^N rows, which takes a while to 
execute.
             var manyRowsQuery = $"select count (*) from 
({GenerateCrossJoin(8)})";
@@ -803,6 +804,7 @@ namespace Apache.Ignite.Tests.Sql
             {
                 "sql" => Client.Sql.ExecuteAsync(transaction: null, 
manyRowsQuery, cts.Token),
                 "sql-mapped" => Client.Sql.ExecuteAsync<int>(transaction: 
null, manyRowsQuery, cts.Token),
+                "sql-mapped2" => Client.Sql.ExecuteAsync(transaction: null, 
new IntMapper(), manyRowsQuery, cts.Token),
                 "script" => Client.Sql.ExecuteScriptAsync($"DELETE FROM 
{TableName} WHERE KEY = ({manyRowsQuery})", cts.Token),
                 "reader" => Client.Sql.ExecuteReaderAsync(transaction: null, 
manyRowsQuery, cts.Token),
                 "batch" => Client.Sql.ExecuteBatchAsync(null, $"DELETE FROM 
{TableName} WHERE KEY = ({manyRowsQuery}) + ?", [[1]], cts.Token),
diff --git 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/PartitionManagerTests.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/PartitionManagerTests.cs
index d78c69a0a01..5797a5dac2a 100644
--- 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/PartitionManagerTests.cs
+++ 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/PartitionManagerTests.cs
@@ -23,6 +23,7 @@ using System.Linq;
 using System.Net;
 using System.Threading.Tasks;
 using Common.Compute;
+using Common.Table;
 using Compute;
 using Ignite.Compute;
 using Ignite.Table;
@@ -114,14 +115,16 @@ public class PartitionManagerTests : IgniteTestsBase
     }
 
     [Test]
-    public async Task TestGetPartitionForKey([Values(true, false)] bool poco)
+    public async Task TestGetPartitionForKey([Values(true, false)] bool poco, 
[Values(true, false)] bool withMapper)
     {
         var jobTarget = JobTarget.AnyNode(await Client.GetClusterNodesAsync());
 
         for (int id = 0; id < 30; id++)
         {
             var partition = poco
-                ? await Table.PartitionManager.GetPartitionAsync(GetPoco(id))
+                ? withMapper
+                    ? await 
Table.PartitionManager.GetPartitionAsync(GetPoco(id), new PocoMapper())
+                    : await 
Table.PartitionManager.GetPartitionAsync(GetPoco(id))
                 : await Table.PartitionManager.GetPartitionAsync(GetTuple(id));
 
             var partitionJobExec = await Client.Compute.SubmitAsync(jobTarget, 
JavaJobs.PartitionJob, id);
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/ApiCompatibilitySuppressions.xml 
b/modules/platforms/dotnet/Apache.Ignite/ApiCompatibilitySuppressions.xml
index fc287895b2c..34c321c6fb0 100644
--- a/modules/platforms/dotnet/Apache.Ignite/ApiCompatibilitySuppressions.xml
+++ b/modules/platforms/dotnet/Apache.Ignite/ApiCompatibilitySuppressions.xml
@@ -133,4 +133,18 @@
     <Right>lib/net8.0/Apache.Ignite.dll</Right>
     <IsBaselineSuppression>true</IsBaselineSuppression>
   </Suppression>
+  <Suppression>
+    <DiagnosticId>CP0006</DiagnosticId>
+    
<Target>M:Apache.Ignite.Table.IPartitionManager.GetPartitionAsync``1(``0,Apache.Ignite.Table.Mapper.IMapper{``0})</Target>
+    <Left>lib/net8.0/Apache.Ignite.dll</Left>
+    <Right>lib/net8.0/Apache.Ignite.dll</Right>
+    <IsBaselineSuppression>true</IsBaselineSuppression>
+  </Suppression>
+  <Suppression>
+    <DiagnosticId>CP0006</DiagnosticId>
+    
<Target>M:Apache.Ignite.Sql.ISql.ExecuteAsync``1(Apache.Ignite.Transactions.ITransaction,Apache.Ignite.Table.Mapper.IMapper{``0},Apache.Ignite.Sql.SqlStatement,System.Threading.CancellationToken,System.Object[])</Target>
+    <Left>lib/net8.0/Apache.Ignite.dll</Left>
+    <Right>lib/net8.0/Apache.Ignite.dll</Right>
+    <IsBaselineSuppression>true</IsBaselineSuppression>
+  </Suppression>
 </Suppressions>
\ No newline at end of file
diff --git a/modules/platforms/dotnet/Apache.Ignite/Compute/JobTarget.cs 
b/modules/platforms/dotnet/Apache.Ignite/Compute/JobTarget.cs
index 0c635f89960..091cea8f7b9 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Compute/JobTarget.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Compute/JobTarget.cs
@@ -17,10 +17,15 @@
 
 namespace Apache.Ignite.Compute;
 
+using System;
 using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
 using Internal.Common;
+using Internal.Table;
+using Internal.Table.Serialization;
 using Network;
 using Table;
+using Table.Mapper;
 
 /// <summary>
 /// Compute job target.
@@ -75,7 +80,23 @@ public static class JobTarget
     {
         IgniteArgumentCheck.NotNull(key);
 
-        return new ColocatedTarget<TKey>(tableName, key);
+        return new ColocatedTarget<TKey>(tableName, key, null);
+    }
+
+    /// <summary>
+    /// Creates a colocated job target for a specific table and key.
+    /// </summary>
+    /// <param name="tableName">Table name.</param>
+    /// <param name="key">Key.</param>
+    /// <param name="mapper">Mapper for the key.</param>
+    /// <typeparam name="TKey">Key type.</typeparam>
+    /// <returns>Colocated job target.</returns>
+    public static IJobTarget<TKey> Colocated<TKey>(QualifiedName tableName, 
TKey key, IMapper<TKey> mapper)
+        where TKey : notnull
+    {
+        IgniteArgumentCheck.NotNull(key);
+
+        return new ColocatedTarget<TKey>(tableName, key, mapper);
     }
 
     /// <summary>
@@ -89,6 +110,18 @@ public static class JobTarget
         where TKey : notnull =>
         Colocated(QualifiedName.Parse(tableName), key);
 
+    /// <summary>
+    /// Creates a colocated job target for a specific table and key.
+    /// </summary>
+    /// <param name="tableName">Table name.</param>
+    /// <param name="key">Key.</param>
+    /// <param name="mapper">Mapper for the key.</param>
+    /// <typeparam name="TKey">Key type.</typeparam>
+    /// <returns>Colocated job target.</returns>
+    public static IJobTarget<TKey> Colocated<TKey>(string tableName, TKey key, 
IMapper<TKey> mapper)
+        where TKey : notnull =>
+        Colocated(QualifiedName.Parse(tableName), key, mapper);
+
     /// <summary>
     /// Single node job target.
     /// </summary>
@@ -106,7 +139,26 @@ public static class JobTarget
     /// </summary>
     /// <param name="TableName">Table name.</param>
     /// <param name="Data">Key.</param>
+    /// <param name="Mapper">Optional mapper for the key.</param>
     /// <typeparam name="TKey">Key type.</typeparam>
-    internal sealed record ColocatedTarget<TKey>(QualifiedName TableName, TKey 
Data) : IJobTarget<TKey>
-        where TKey : notnull;
+    internal sealed record ColocatedTarget<TKey>(QualifiedName TableName, TKey 
Data, IMapper<TKey>? Mapper) : IJobTarget<TKey>
+        where TKey : notnull
+    {
+        /// <summary>
+        /// Gets the cached serializer handler function.
+        /// </summary>
+        internal Func<Table, IRecordSerializerHandler<TKey>>? 
SerializerHandlerFunc { get; } = GetSerializerHandlerFunc(Mapper);
+
+        private static Func<Table, IRecordSerializerHandler<TKey>>? 
GetSerializerHandlerFunc(IMapper<TKey>? mapper)
+        {
+            if (mapper == null)
+            {
+                return null;
+            }
+
+            var handler = new MapperSerializerHandler<TKey>(mapper);
+
+            return _ => handler;
+        }
+    }
 }
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Compute/Compute.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Compute/Compute.cs
index 198a9f9b546..863e0d57690 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Compute/Compute.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Compute/Compute.cs
@@ -624,6 +624,18 @@ namespace Apache.Ignite.Internal.Compute
                     .ConfigureAwait(false);
             }
 
+            if (target.SerializerHandlerFunc != null)
+            {
+                return await ExecuteColocatedAsync(
+                        target.TableName,
+                        target.Data,
+                        target.SerializerHandlerFunc,
+                        jobDescriptor,
+                        arg,
+                        cancellationToken)
+                    .ConfigureAwait(false);
+            }
+
             return await ExecuteColocatedAsync<TArg, TResult, TKey>(
                     target.TableName,
                     target.Data,
@@ -633,14 +645,13 @@ namespace Apache.Ignite.Internal.Compute
                     cancellationToken)
                 .ConfigureAwait(false);
 
-            [UnconditionalSuppressMessage("Trimming", "IL2026", Justification 
= "IGNITE-27278")]
-            [UnconditionalSuppressMessage("Trimming", "IL3050", Justification 
= "IGNITE-27278")]
+            [UnconditionalSuppressMessage("Trimming", "IL2026", Justification 
= "Unreachable with IMapper.")]
+            [UnconditionalSuppressMessage("Trimming", "IL3050", Justification 
= "Unreachable with IMapper.")]
             static IRecordSerializerHandler<TKey> GetSerializerHandler(Table 
table)
             {
                 if (!RuntimeFeature.IsDynamicCodeSupported)
                 {
-                    // TODO IGNITE-27278: Remove suppression and require 
mapper in trimmed mode.
-                    throw new InvalidOperationException("Colocated job target 
requires an IIgniteTuple key when running in trimmed AOT mode.");
+                    throw new InvalidOperationException("Use 
JobTarget.Colocated overload with IMapper<T>.");
                 }
 
                 return 
table.GetRecordViewInternal<TKey>().RecordSerializer.Handler;
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/IgniteQueryExecutor.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/IgniteQueryExecutor.cs
index e17fa47d80f..378bebcae9d 100644
--- 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/IgniteQueryExecutor.cs
+++ 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/IgniteQueryExecutor.cs
@@ -139,7 +139,8 @@ internal sealed class IgniteQueryExecutor : IQueryExecutor
         IResultSet<T> resultSet = await _sql.ExecuteAsyncInternal(
             _transaction,
             statement,
-            cols => ResultSelector.Get<T>(cols, 
queryModel.SelectClause.Selector, selectorOptions),
+            meta => ResultSelector.Get<T>(meta, 
queryModel.SelectClause.Selector, selectorOptions),
+            rowReaderArg: null,
             queryData.Parameters,
             CancellationToken.None)
             .ConfigureAwait(false);
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/ResultSelector.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/ResultSelector.cs
index 72c969a3fe7..d243d4b8925 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/ResultSelector.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/ResultSelector.cs
@@ -62,13 +62,15 @@ internal static class ResultSelector
     /// LINQ handles type conversion when possible;
     /// LINQ allows more ways to instantiate resulting objects.
     /// </summary>
-    /// <param name="columns">Columns.</param>
+    /// <param name="meta">Metadata.</param>
     /// <param name="selectorExpression">Selector expression.</param>
     /// <param name="options">Options.</param>
     /// <typeparam name="T">Result type.</typeparam>
     /// <returns>Row reader.</returns>
-    public static RowReader<T> Get<T>(IReadOnlyList<IColumnMetadata> columns, 
Expression? selectorExpression, ResultSelectorOptions options)
+    public static RowReader<T> Get<T>(ResultSetMetadata meta, Expression? 
selectorExpression, ResultSelectorOptions options)
     {
+        var columns = meta.Columns;
+
         // Anonymous type projections use a constructor call. But user-defined 
types can also be used with constructor call.
         if (selectorExpression is NewExpression newExpr)
         {
@@ -115,7 +117,7 @@ internal static class ResultSelector
             })
         {
             // Select everything from a sub-query - use nested selector.
-            return Get<T>(columns, subQuery.QueryModel.SelectClause.Selector, 
options);
+            return Get<T>(meta, subQuery.QueryModel.SelectClause.Selector, 
options);
         }
 
         var readerCacheKey = new ResultSelectorCacheKey<Type>(typeof(T), 
columns, options);
@@ -130,7 +132,7 @@ internal static class ResultSelector
         var method = new DynamicMethod(
             name: 
$"SingleColumnFromBinaryTupleReader_{typeof(T).FullName}_{GetNextId()}",
             returnType: typeof(T),
-            parameterTypes: new[] { typeof(IReadOnlyList<IColumnMetadata>), 
typeof(BinaryTupleReader).MakeByRefType() },
+            parameterTypes: [typeof(ResultSetMetadata), 
typeof(BinaryTupleReader).MakeByRefType(), typeof(object)],
             m: typeof(IIgnite).Module,
             skipVisibility: true);
 
@@ -151,7 +153,7 @@ internal static class ResultSelector
         var method = new DynamicMethod(
             name: 
$"ConstructorFromBinaryTupleReader_{typeof(T).FullName}_{GetNextId()}",
             returnType: typeof(T),
-            parameterTypes: new[] { typeof(IReadOnlyList<IColumnMetadata>), 
typeof(BinaryTupleReader).MakeByRefType() },
+            parameterTypes: [typeof(ResultSetMetadata), 
typeof(BinaryTupleReader).MakeByRefType(), typeof(object)],
             m: typeof(IIgnite).Module,
             skipVisibility: true);
 
@@ -183,7 +185,7 @@ internal static class ResultSelector
         var method = new DynamicMethod(
             name: 
$"UninitializedObjectFromBinaryTupleReader_{typeof(T).FullName}_{GetNextId()}",
             returnType: typeof(T),
-            parameterTypes: new[] { typeof(IReadOnlyList<IColumnMetadata>), 
typeof(BinaryTupleReader).MakeByRefType() },
+            parameterTypes: [typeof(ResultSetMetadata), 
typeof(BinaryTupleReader).MakeByRefType(), typeof(object)],
             m: typeof(IIgnite).Module,
             skipVisibility: true);
 
@@ -219,7 +221,7 @@ internal static class ResultSelector
         var method = new DynamicMethod(
             name: 
$"MemberInitFromBinaryTupleReader_{typeof(T).FullName}_{GetNextId()}",
             returnType: typeof(T),
-            parameterTypes: new[] { typeof(IReadOnlyList<IColumnMetadata>), 
typeof(BinaryTupleReader).MakeByRefType() },
+            parameterTypes: [typeof(ResultSetMetadata), 
typeof(BinaryTupleReader).MakeByRefType(), typeof(object)],
             m: typeof(IIgnite).Module,
             skipVisibility: true);
 
@@ -279,7 +281,7 @@ internal static class ResultSelector
         var method = new DynamicMethod(
             name: 
$"KvPairFromBinaryTupleReader_{typeof(T).FullName}_{GetNextId()}",
             returnType: typeof(T),
-            parameterTypes: new[] { typeof(IReadOnlyList<IColumnMetadata>), 
typeof(BinaryTupleReader).MakeByRefType() },
+            parameterTypes: [typeof(ResultSetMetadata), 
typeof(BinaryTupleReader).MakeByRefType(), typeof(object)],
             m: typeof(IIgnite).Module,
             skipVisibility: true);
 
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/ColumnMetadata.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/ColumnMetadata.cs
index 25390da864b..5294149dc89 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/ColumnMetadata.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/ColumnMetadata.cs
@@ -18,6 +18,7 @@
 namespace Apache.Ignite.Internal.Sql;
 
 using Ignite.Sql;
+using Ignite.Table.Mapper;
 
 /// <summary>
 /// Column metadata.
@@ -29,4 +30,4 @@ using Ignite.Sql;
 /// <param name="Nullable">Whether column is nullable.</param>
 /// <param name="Origin">Origin.</param>
 internal sealed record ColumnMetadata(string Name, ColumnType Type, int 
Precision, int Scale, bool Nullable, IColumnOrigin? Origin)
-    : IColumnMetadata;
+    : IColumnMetadata, IMapperColumn;
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/ResultSet.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/ResultSet.cs
index a40938e901d..798079578b1 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/ResultSet.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/ResultSet.cs
@@ -44,8 +44,12 @@ namespace Apache.Ignite.Internal.Sql
 
         private readonly bool _hasMorePages;
 
+        private readonly ResultSetMetadata? _metadata;
+
         private readonly RowReader<T>? _rowReader;
 
+        private readonly object? _rowReaderArg;
+
         private readonly CancellationToken _cancellationToken;
 
         private bool _resourceClosed;
@@ -60,8 +64,14 @@ namespace Apache.Ignite.Internal.Sql
         /// <param name="socket">Socket.</param>
         /// <param name="buf">Buffer to read initial data from.</param>
         /// <param name="rowReaderFactory">Row reader factory.</param>
+        /// <param name="rowReaderArg">Row reader argument.</param>
         /// <param name="cancellationToken">Cancellation token.</param>
-        public ResultSet(ClientSocket socket, PooledBuffer buf, 
RowReaderFactory<T> rowReaderFactory, CancellationToken cancellationToken)
+        public ResultSet(
+            ClientSocket socket,
+            PooledBuffer buf,
+            RowReaderFactory<T> rowReaderFactory,
+            object? rowReaderArg,
+            CancellationToken cancellationToken)
         {
             _socket = socket;
             _cancellationToken = cancellationToken;
@@ -76,8 +86,9 @@ namespace Apache.Ignite.Internal.Sql
             WasApplied = reader.ReadBoolean();
             AffectedRows = reader.ReadInt64();
 
-            Metadata = HasRowSet ? ReadMeta(ref reader) : null;
-            _rowReader = Metadata != null ? rowReaderFactory(Metadata.Columns) 
: null;
+            _metadata = HasRowSet ? ReadMeta(ref reader) : null;
+            _rowReader = _metadata != null ? rowReaderFactory(_metadata) : 
null;
+            _rowReaderArg = rowReaderArg;
 
             if (HasRowSet)
             {
@@ -102,7 +113,7 @@ namespace Apache.Ignite.Internal.Sql
         }
 
         /// <inheritdoc/>
-        public IResultSetMetadata? Metadata { get; }
+        public IResultSetMetadata? Metadata => _metadata;
 
         /// <inheritdoc/>
         public bool HasRowSet { get; }
@@ -156,7 +167,6 @@ namespace Apache.Ignite.Internal.Sql
             ValidateAndSetIteratorState();
 
             // First page is included in the initial response.
-            var cols = Metadata!.Columns;
             var hasMore = _hasMorePages;
             TResult? res = default;
 
@@ -183,7 +193,7 @@ namespace Apache.Ignite.Internal.Sql
 
                 for (var rowIdx = 0; rowIdx < pageSize; rowIdx++)
                 {
-                    var row = ReadRow(cols, ref reader);
+                    var row = ReadRow(ref reader);
                     accumulator(res, row);
                 }
 
@@ -290,7 +300,7 @@ namespace Apache.Ignite.Internal.Sql
         {
             var size = reader.ReadInt32();
 
-            var columns = new List<IColumnMetadata>(size);
+            var columns = new ColumnMetadata[size];
 
             for (int i = 0; i < size; i++)
             {
@@ -312,23 +322,22 @@ namespace Apache.Ignite.Internal.Sql
                         TableName: reader.TryReadInt(out idx) ? 
columns[idx].Origin!.TableName : reader.ReadString())
                     : null;
 
-                columns.Add(new ColumnMetadata(name, type, precision, scale, 
nullable, origin));
+                columns[i] = new ColumnMetadata(name, type, precision, scale, 
nullable, origin);
             }
 
             return new ResultSetMetadata(columns);
         }
 
-        private T ReadRow(IReadOnlyList<IColumnMetadata> cols, ref 
MsgPackReader reader)
+        private T ReadRow(ref MsgPackReader reader)
         {
-            var tupleReader = new BinaryTupleReader(reader.ReadBinary(), 
cols.Count);
+            var tupleReader = new BinaryTupleReader(reader.ReadBinary(), 
_metadata!.Columns.Count);
 
-            return _rowReader!(cols, ref tupleReader);
+            return _rowReader!(_metadata, ref tupleReader, _rowReaderArg);
         }
 
         private async IAsyncEnumerable<T> EnumerateRows()
         {
             var hasMore = _hasMorePages;
-            var cols = Metadata!.Columns;
             var offset = 0;
 
             // First page.
@@ -367,7 +376,7 @@ namespace Apache.Ignite.Internal.Sql
                     // Can't use ref struct reader from above inside iterator 
block (CS4013).
                     // Use a new reader for every row (stack allocated).
                     var rowReader = buf.GetReader(offset);
-                    var row = ReadRow(cols, ref rowReader);
+                    var row = ReadRow(ref rowReader);
 
                     offset += rowReader.Consumed;
                     yield return row;
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/ResultSetMetadata.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/ResultSetMetadata.cs
index 644a713c354..a6a5f3dace8 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/ResultSetMetadata.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/ResultSetMetadata.cs
@@ -20,16 +20,33 @@ namespace Apache.Ignite.Internal.Sql
     using System.Collections.Generic;
     using Common;
     using Ignite.Sql;
+    using Ignite.Table.Mapper;
 
     /// <summary>
     /// Result set metadata.
     /// </summary>
-    /// <param name="Columns">Columns.</param>
-    internal sealed record ResultSetMetadata(IReadOnlyList<IColumnMetadata> 
Columns) : IResultSetMetadata
+    internal sealed record ResultSetMetadata : IResultSetMetadata, 
IMapperSchema
     {
+        private readonly ColumnMetadata[] _columns;
+
         /** Column index by name. Initialized on first access. */
         private Dictionary<string, int>? _indices;
 
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ResultSetMetadata"/> 
class.
+        /// </summary>
+        /// <param name="columns">Columns.</param>
+        internal ResultSetMetadata(ColumnMetadata[] columns)
+        {
+            _columns = columns;
+        }
+
+        /// <inheritdoc/>
+        public IReadOnlyList<IColumnMetadata> Columns => _columns;
+
+        /// <inheritdoc/>
+        IReadOnlyList<IMapperColumn> IMapperSchema.Columns => _columns;
+
         /// <inheritdoc/>
         public int IndexOf(string columnName)
         {
@@ -37,17 +54,17 @@ namespace Apache.Ignite.Internal.Sql
 
             if (indices == null)
             {
-                indices = new Dictionary<string, int>(Columns.Count);
+                indices = new Dictionary<string, int>(_columns.Length);
 
-                for (var i = 0; i < Columns.Count; i++)
+                for (var i = 0; i < _columns.Length; i++)
                 {
-                    indices[Columns[i].Name] = i;
+                    indices[_columns[i].Name] = i;
                 }
 
                 _indices = indices;
             }
 
-            return indices.TryGetValue(columnName, out var idx) ? idx : -1;
+            return indices.GetValueOrDefault(columnName, -1);
         }
 
         /// <inheritdoc/>
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/RowReader.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/RowReader.cs
index d0034df9c8f..37daa714df1 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/RowReader.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/RowReader.cs
@@ -17,15 +17,14 @@
 
 namespace Apache.Ignite.Internal.Sql;
 
-using System.Collections.Generic;
-using Ignite.Sql;
 using Proto.BinaryTuple;
 
 /// <summary>
 /// Row reader delegate.
 /// </summary>
-/// <param name="cols">Columns.</param>
+/// <param name="metadata">Metadata.</param>
 /// <param name="tupleReader">Tuple reader.</param>
+/// <param name="arg">Argument.</param>
 /// <typeparam name="T">Result type.</typeparam>
 /// <returns>Resulting row.</returns>
-internal delegate T RowReader<out T>(IReadOnlyList<IColumnMetadata> cols, ref 
BinaryTupleReader tupleReader);
+internal delegate T RowReader<out T>(ResultSetMetadata metadata, ref 
BinaryTupleReader tupleReader, object? arg);
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/RowReaderFactory.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/RowReaderFactory.cs
index 7f1b2070361..73889c97ec7 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/RowReaderFactory.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/RowReaderFactory.cs
@@ -17,13 +17,10 @@
 
 namespace Apache.Ignite.Internal.Sql;
 
-using System.Collections.Generic;
-using Ignite.Sql;
-
 /// <summary>
 /// Row reader factory.
 /// </summary>
-/// <param name="cols">Columns.</param>
+/// <param name="metadata">Metadata.</param>
 /// <typeparam name="T">Result type.</typeparam>
 /// <returns>Resulting row.</returns>
-internal delegate RowReader<T> RowReaderFactory<out 
T>(IReadOnlyList<IColumnMetadata> cols);
+internal delegate RowReader<T> RowReaderFactory<out T>(ResultSetMetadata 
metadata);
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/Sql.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/Sql.cs
index 2c955ebb201..b89451b50e2 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/Sql.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/Sql.cs
@@ -27,6 +27,7 @@ namespace Apache.Ignite.Internal.Sql
     using Common;
     using Ignite.Sql;
     using Ignite.Table;
+    using Ignite.Table.Mapper;
     using Ignite.Transactions;
     using Linq;
     using Proto;
@@ -41,7 +42,7 @@ namespace Apache.Ignite.Internal.Sql
     internal sealed class Sql : ISql
     {
         private static readonly RowReader<IIgniteTuple> TupleReader =
-            static (IReadOnlyList<IColumnMetadata> cols, ref BinaryTupleReader 
reader) => ReadTuple(cols, ref reader);
+            static (ResultSetMetadata metadata, ref BinaryTupleReader reader, 
object? _) => ReadTuple(metadata.Columns, ref reader);
 
         private static readonly RowReaderFactory<IIgniteTuple> 
TupleReaderFactory = static _ => TupleReader;
 
@@ -60,7 +61,14 @@ namespace Apache.Ignite.Internal.Sql
         /// <inheritdoc/>
         public async Task<IResultSet<IIgniteTuple>> ExecuteAsync(
             ITransaction? transaction, SqlStatement statement, 
CancellationToken cancellationToken, params object?[]? args) =>
-            await ExecuteAsyncInternal(transaction, statement, 
TupleReaderFactory, args, cancellationToken).ConfigureAwait(false);
+            await ExecuteAsyncInternal(
+                transaction,
+                statement,
+                TupleReaderFactory,
+                rowReaderArg: null,
+                args,
+                cancellationToken)
+                .ConfigureAwait(false);
 
         /// <inheritdoc/>
         [RequiresUnreferencedCode(ReflectionUtils.TrimWarning)]
@@ -69,17 +77,52 @@ namespace Apache.Ignite.Internal.Sql
             await ExecuteAsyncInternal(
                     transaction,
                     statement,
-                    static cols => GetReaderFactory<T>(cols),
+                    static meta => GetReaderFactory<T>(meta),
+                    rowReaderArg: null,
+                    args,
+                    cancellationToken)
+                .ConfigureAwait(false);
+
+        /// <inheritdoc/>
+        public async Task<IResultSet<T>> ExecuteAsync<T>(
+            ITransaction? transaction,
+            IMapper<T> mapper,
+            SqlStatement statement,
+            CancellationToken cancellationToken,
+            params object?[]? args)
+        {
+            IgniteArgumentCheck.NotNull(mapper);
+
+            return await ExecuteAsyncInternal(
+                    transaction,
+                    statement,
+                    RowReaderFactory,
+                    rowReaderArg: mapper,
                     args,
                     cancellationToken)
                 .ConfigureAwait(false);
 
+            static RowReader<T> RowReaderFactory(ResultSetMetadata 
resultSetMetadata) =>
+                static (ResultSetMetadata meta, ref BinaryTupleReader reader, 
object? arg) =>
+                {
+                    var mapperReader = new RowReader(ref reader, meta);
+                    var mapper = (IMapper<T>)arg!;
+
+                    return mapper.Read(ref mapperReader, meta);
+                };
+        }
+
         /// <inheritdoc/>
         public async Task<IgniteDbDataReader> ExecuteReaderAsync(
             ITransaction? transaction, SqlStatement statement, 
CancellationToken cancellationToken, params object?[]? args)
         {
             var resultSet = await ExecuteAsyncInternal<object>(
-                transaction, statement, _ => null!, args, 
cancellationToken).ConfigureAwait(false);
+                transaction,
+                statement,
+                static _ => null!,
+                rowReaderArg: null,
+                args,
+                cancellationToken).ConfigureAwait(false);
 
             if (!resultSet.HasRowSet)
             {
@@ -215,6 +258,7 @@ namespace Apache.Ignite.Internal.Sql
         /// <param name="transaction">Optional transaction.</param>
         /// <param name="statement">Statement to execute.</param>
         /// <param name="rowReaderFactory">Row reader factory.</param>
+        /// <param name="rowReaderArg">Row reader arg.</param>
         /// <param name="args">Arguments for the statement.</param>
         /// <param name="cancellationToken">Cancellation token.</param>
         /// <typeparam name="T">Row type.</typeparam>
@@ -223,6 +267,7 @@ namespace Apache.Ignite.Internal.Sql
             ITransaction? transaction,
             SqlStatement statement,
             RowReaderFactory<T> rowReaderFactory,
+            object? rowReaderArg,
             ICollection<object?>? args,
             CancellationToken cancellationToken)
         {
@@ -242,7 +287,7 @@ namespace Apache.Ignite.Internal.Sql
                     ClientOp.SqlExec, tx, bufferWriter, cancellationToken: 
cancellationToken).ConfigureAwait(false);
 
                 // ResultSet will dispose the pooled buffer.
-                return new ResultSet<T>(socket, buf, rowReaderFactory, 
cancellationToken);
+                return new ResultSet<T>(socket, buf, rowReaderFactory, 
rowReaderArg, cancellationToken);
             }
             catch (SqlException e)
             {
@@ -307,8 +352,8 @@ namespace Apache.Ignite.Internal.Sql
         }
 
         [RequiresUnreferencedCode(ReflectionUtils.TrimWarning)]
-        private static RowReader<T> 
GetReaderFactory<T>(IReadOnlyList<IColumnMetadata> cols) =>
-            ResultSelector.Get<T>(cols, selectorExpression: null, 
ResultSelectorOptions.None);
+        private static RowReader<T> GetReaderFactory<T>(ResultSetMetadata 
metadata) =>
+            ResultSelector.Get<T>(metadata, selectorExpression: null, 
ResultSelectorOptions.None);
 
         private static void WriteBatchArgs(PooledArrayBuffer writer, 
IEnumerable<IEnumerable<object?>> args)
         {
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Column.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Column.cs
index 1cc14c5142c..8fc3ab2f7a8 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Column.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Column.cs
@@ -44,6 +44,9 @@ internal record Column(
     /// </summary>
     public bool IsColocation => ColocationIndex >= 0;
 
+    /// <inheritdoc/>
+    public bool Nullable => IsNullable;
+
     /// <summary>
     /// Gets the column index within a binary tuple.
     /// </summary>
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/PartitionManager.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/PartitionManager.cs
index 0d75089d201..df91b5c5156 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/PartitionManager.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/PartitionManager.cs
@@ -25,6 +25,7 @@ using System.Threading.Tasks;
 using Common;
 using Ignite.Network;
 using Ignite.Table;
+using Ignite.Table.Mapper;
 using Network;
 using Proto;
 using Proto.MsgPack;
@@ -101,6 +102,11 @@ internal sealed class PartitionManager : IPartitionManager
         where TK : notnull =>
         GetPartitionInternalAsync(key, 
_table.GetRecordViewInternal<TK>().RecordSerializer.Handler);
 
+    /// <inheritdoc/>
+    public ValueTask<IPartition> GetPartitionAsync<TK>(TK key, IMapper<TK> 
mapper)
+        where TK : notnull =>
+        GetPartitionInternalAsync(key, new 
MapperSerializerHandler<TK>(mapper));
+
     /// <inheritdoc/>
     public override string ToString() =>
         new IgniteToStringBuilder(GetType())
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/MapperSerializerHandler.cs
 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/MapperSerializerHandler.cs
index 06c7b502c54..8327f188bae 100644
--- 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/MapperSerializerHandler.cs
+++ 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/MapperSerializerHandler.cs
@@ -52,12 +52,11 @@ internal sealed class MapperSerializerHandler<T> : 
IRecordSerializerHandler<T>
     /// <inheritdoc/>
     public T Read(ref MsgPackReader reader, Schema schema, bool keyOnly = 
false)
     {
-        Column[] columns = schema.GetColumnsFor(keyOnly);
-        var binaryTupleReader = new BinaryTupleReader(reader.ReadBinary(), 
columns.Length);
-
-        var mapperReader = new RowReader(ref binaryTupleReader, columns);
         IMapperSchema mapperSchema = schema.GetMapperSchema(keyOnly);
 
+        var binaryTupleReader = new BinaryTupleReader(reader.ReadBinary(), 
mapperSchema.Columns.Count);
+        var mapperReader = new RowReader(ref binaryTupleReader, mapperSchema);
+
         return _mapper.Read(ref mapperReader, mapperSchema);
     }
 
diff --git a/modules/platforms/dotnet/Apache.Ignite/Sql/ISql.cs 
b/modules/platforms/dotnet/Apache.Ignite/Sql/ISql.cs
index 3122de15909..895c195d903 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Sql/ISql.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Sql/ISql.cs
@@ -24,6 +24,7 @@ namespace Apache.Ignite.Sql
     using System.Threading.Tasks;
     using Internal.Table.Serialization;
     using Table;
+    using Table.Mapper;
     using Transactions;
 
     /// <summary>
@@ -77,6 +78,23 @@ namespace Apache.Ignite.Sql
         Task<IResultSet<T>> ExecuteAsync<T>(
             ITransaction? transaction, SqlStatement statement, 
CancellationToken cancellationToken, params object?[]? args);
 
+        /// <summary>
+        /// Executes a single SQL statement and returns rows deserialized into 
the specified user type <typeparamref name="T"/>.
+        /// </summary>
+        /// <param name="transaction">Optional transaction.</param>
+        /// <param name="mapper">Mapper for the result type.</param>
+        /// <param name="statement">Statement to execute.</param>
+        /// <param name="cancellationToken">Cancellation token.</param>
+        /// <param name="args">Arguments for the statement.</param>
+        /// <typeparam name="T">Row type.</typeparam>
+        /// <returns>SQL result set.</returns>
+        Task<IResultSet<T>> ExecuteAsync<T>(
+            ITransaction? transaction,
+            IMapper<T> mapper,
+            SqlStatement statement,
+            CancellationToken cancellationToken,
+            params object?[]? args);
+
         /// <summary>
         /// Executes a single SQL statement and returns a <see 
cref="DbDataReader"/> to consume them in an efficient, forward-only way.
         /// </summary>
diff --git a/modules/platforms/dotnet/Apache.Ignite/Table/IPartitionManager.cs 
b/modules/platforms/dotnet/Apache.Ignite/Table/IPartitionManager.cs
index 6f60e56b09a..536e482a52d 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Table/IPartitionManager.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Table/IPartitionManager.cs
@@ -21,6 +21,7 @@ using System.Collections.Generic;
 using System.Diagnostics.CodeAnalysis;
 using System.Threading.Tasks;
 using Internal.Table.Serialization;
+using Mapper;
 using Network;
 
 /// <summary>
@@ -58,7 +59,17 @@ public interface IPartitionManager
     /// <param name="key">Table key.</param>
     /// <returns>Partition that contains the specified key.</returns>
     /// <typeparam name="TK">Key type.</typeparam>
-    [RequiresUnreferencedCode(ReflectionUtils.TrimWarning)] // TODO: 
IGNITE-27278
+    [RequiresUnreferencedCode(ReflectionUtils.TrimWarning)]
     ValueTask<IPartition> GetPartitionAsync<TK>(TK key)
         where TK : notnull;
+
+    /// <summary>
+    /// Gets the partition for the specified table key.
+    /// </summary>
+    /// <param name="key">Table key.</param>
+    /// <param name="mapper">Mapper for the key.</param>
+    /// <returns>Partition that contains the specified key.</returns>
+    /// <typeparam name="TK">Key type.</typeparam>
+    ValueTask<IPartition> GetPartitionAsync<TK>(TK key, IMapper<TK> mapper)
+        where TK : notnull;
 }
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Table/Mapper/IMapperColumn.cs 
b/modules/platforms/dotnet/Apache.Ignite/Table/Mapper/IMapperColumn.cs
index b83019f2a92..20d34200973 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Table/Mapper/IMapperColumn.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Table/Mapper/IMapperColumn.cs
@@ -33,4 +33,25 @@ public interface IMapperColumn
     /// Gets the column type.
     /// </summary>
     ColumnType Type { get; }
+
+    /// <summary>
+    /// Gets the column precision, or -1 when not applicable to the current 
column type.
+    /// <para />
+    /// </summary>
+    /// <returns>
+    /// Number of decimal digits for exact numeric types; number of decimal 
digits in mantissa for approximate numeric types;
+    /// number of decimal digits for fractional seconds of datetime types; 
length in characters for character types;
+    /// length in bytes for binary types; length in bits for bit types; 1 for 
BOOLEAN; -1 if precision is not valid for the type.
+    /// </returns>
+    int Precision { get; }
+
+    /// <summary>
+    /// Gets the column scale.
+    /// </summary>
+    int Scale { get; }
+
+    /// <summary>
+    /// Gets a value indicating whether the column is nullable.
+    /// </summary>
+    bool Nullable { get; }
 }
diff --git a/modules/platforms/dotnet/Apache.Ignite/Table/Mapper/RowReader.cs 
b/modules/platforms/dotnet/Apache.Ignite/Table/Mapper/RowReader.cs
index 74a1d988793..c36fbb77325 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Table/Mapper/RowReader.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Table/Mapper/RowReader.cs
@@ -18,9 +18,9 @@
 namespace Apache.Ignite.Table.Mapper;
 
 using System;
+using System.Collections.Generic;
 using Internal.Common;
 using Internal.Proto.BinaryTuple;
-using Internal.Table;
 using NodaTime;
 using Sql;
 
@@ -31,7 +31,7 @@ public ref struct RowReader
 {
     private readonly BinaryTupleReader _reader;
 
-    private readonly Column[] _columns;
+    private readonly IReadOnlyList<IMapperColumn> _columns;
 
     private int _position = -1;
 
@@ -39,18 +39,18 @@ public ref struct RowReader
     /// Initializes a new instance of the <see cref="RowReader"/> struct.
     /// </summary>
     /// <param name="reader">Reader.</param>
-    /// <param name="columns">Columns.</param>
-    internal RowReader(ref BinaryTupleReader reader, Column[] columns)
+    /// <param name="schema">Schema.</param>
+    internal RowReader(ref BinaryTupleReader reader, IMapperSchema schema)
     {
         _reader = reader;
-        _columns = columns;
+        _columns = schema.Columns;
     }
 
-    private readonly Column Column
+    private readonly IMapperColumn Column
     {
         get
         {
-            if (_position >= _columns.Length)
+            if (_position >= _columns.Count)
             {
                 throw new IgniteClientException(
                     ErrorGroups.Client.Configuration,

Reply via email to