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 2023/01/24 14:45:57 UTC

[ignite-3] branch main updated: IGNITE-18478 .NET: Add ExecuteReader to SQL API (#1567)

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 d5bdbf1c8c IGNITE-18478 .NET: Add ExecuteReader to SQL API (#1567)
d5bdbf1c8c is described below

commit d5bdbf1c8c1d811cbbda0dfb9b543894edb611b6
Author: Pavel Tupitsyn <pt...@apache.org>
AuthorDate: Tue Jan 24 16:45:52 2023 +0200

    IGNITE-18478 .NET: Add ExecuteReader to SQL API (#1567)
    
    Add `ISql.ExecuteReaderAsync`:
    * `IgniteDbDataReader` inherits from `System.Data.Common.DbDataReader`, providing a standard interface to work with query results.
    * `DbDataReader` API is an efficient way to retrieve query results without object mapping and with minimal memory allocation. This is a good alternative to allocation-heavy `IResultSet<IIgniteTuple>`.
---
 .../dotnet/Apache.Ignite.Benchmarks/Program.cs     |   4 +-
 .../Sql/ResultSetBenchmarks.cs                     |  33 +-
 .../Proto/BinaryTuple/BinaryTupleTests.cs          |  10 +
 .../Sql/IgniteDbDataReaderTests.cs                 | 650 +++++++++++++++++++++
 .../dotnet/Apache.Ignite.Tests/Sql/SqlTests.cs     |   4 +
 .../Proto/BinaryTuple/BinaryTupleReader.cs         |   7 +
 .../dotnet/Apache.Ignite/Internal/Sql/ResultSet.cs |  58 +-
 .../dotnet/Apache.Ignite/Internal/Sql/Sql.cs       |  15 +-
 .../Internal/Sql/SqlColumnTypeExtensions.cs        |  16 +
 modules/platforms/dotnet/Apache.Ignite/Sql/ISql.cs |  10 +
 .../dotnet/Apache.Ignite/Sql/IgniteDbColumn.cs     |  49 ++
 .../dotnet/Apache.Ignite/Sql/IgniteDbDataReader.cs | 537 +++++++++++++++++
 modules/platforms/dotnet/DEVNOTES.md               |   6 +-
 13 files changed, 1379 insertions(+), 20 deletions(-)

diff --git a/modules/platforms/dotnet/Apache.Ignite.Benchmarks/Program.cs b/modules/platforms/dotnet/Apache.Ignite.Benchmarks/Program.cs
index 72570b8afb..04fd5dde56 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Benchmarks/Program.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Benchmarks/Program.cs
@@ -18,12 +18,12 @@
 namespace Apache.Ignite.Benchmarks;
 
 using BenchmarkDotNet.Running;
-using Proto.BinaryTuple;
+using Sql;
 
 internal static class Program
 {
     private static void Main()
     {
-        BenchmarkRunner.Run<BinaryTupleReaderBenchmarks>();
+        BenchmarkRunner.Run<ResultSetBenchmarks>();
     }
 }
diff --git a/modules/platforms/dotnet/Apache.Ignite.Benchmarks/Sql/ResultSetBenchmarks.cs b/modules/platforms/dotnet/Apache.Ignite.Benchmarks/Sql/ResultSetBenchmarks.cs
index f2164061ad..2b465a076a 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Benchmarks/Sql/ResultSetBenchmarks.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Benchmarks/Sql/ResultSetBenchmarks.cs
@@ -20,6 +20,7 @@ namespace Apache.Ignite.Benchmarks.Sql
     using System;
     using System.Collections.Generic;
     using System.Linq;
+    using System.Threading;
     using System.Threading.Tasks;
     using BenchmarkDotNet.Attributes;
     using BenchmarkDotNet.Jobs;
@@ -30,13 +31,14 @@ namespace Apache.Ignite.Benchmarks.Sql
     /// <summary>
     /// Measures SQL result set enumeration with two pages of data (two network requests per enumeration).
     /// <para />
-    /// Results on Intel Core i7-7700HQ, .NET 6, GC = Concurrent Server, Ubuntu 20.04:
-    /// |                         Method |       Mean |    Error |   StdDev | Ratio | RatioSD |   Gen 0 |  Gen 1 | Allocated |
-    /// |------------------------------- |-----------:|---------:|---------:|------:|--------:|--------:|-------:|----------:|
-    /// |                    ToListAsync |   967.2 us | 18.17 us | 17.00 us |  1.00 |    0.00 | 15.6250 |      - |    392 KB |
-    /// |                AsyncEnumerable | 1,172.7 us | 23.41 us | 27.87 us |  1.21 |    0.03 | 15.6250 | 1.9531 |    393 KB |
-    /// |                LinqToListAsync |   786.1 us | 14.94 us | 15.34 us |  0.81 |    0.02 |  2.9297 |      - |     80 KB |
-    /// | LinqSelectOneColumnToListAsync |   677.0 us | 13.42 us | 11.21 us |  0.70 |    0.02 |  1.9531 |      - |     57 KB |.
+    /// Results on i9-12900H, .NET SDK 6.0.405, Ubuntu 22.04:
+    /// |                         Method |     Mean |   Error |  StdDev | Ratio | RatioSD |  Gen 0 | Allocated |
+    /// |------------------------------- |---------:|--------:|--------:|------:|--------:|-------:|----------:|
+    /// |                    ToListAsync | 229.0 us | 3.69 us | 3.45 us |  1.00 |    0.00 | 1.4648 |    392 KB |
+    /// |                AsyncEnumerable | 263.2 us | 5.05 us | 4.72 us |  1.15 |    0.02 | 1.4648 |    393 KB |
+    /// |                LinqToListAsync | 180.1 us | 3.18 us | 2.65 us |  0.79 |    0.02 | 0.2441 |     80 KB |
+    /// | LinqSelectOneColumnToListAsync | 156.0 us | 3.06 us | 4.48 us |  0.68 |    0.02 | 0.2441 |     57 KB |
+    /// |                   DbDataReader | 189.3 us | 1.61 us | 1.51 us |  0.83 |    0.01 |      - |     51 KB |.
     /// </summary>
     [MemoryDiagnoser]
     [SimpleJob(RuntimeMoniker.Net60)]
@@ -118,6 +120,23 @@ namespace Apache.Ignite.Benchmarks.Sql
             }
         }
 
+        [Benchmark]
+        public async Task DbDataReader()
+        {
+            await using var reader = await _client!.Sql.ExecuteReaderAsync(null, "select 1");
+            var rows = new List<int>(1100);
+
+            while (await reader.ReadAsync(CancellationToken.None))
+            {
+                rows.Add(reader.GetInt32(0));
+            }
+
+            if (rows.Count != 1012)
+            {
+                throw new Exception("Wrong count");
+            }
+        }
+
         private record Rec(int Id);
     }
 }
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/Proto/BinaryTuple/BinaryTupleTests.cs b/modules/platforms/dotnet/Apache.Ignite.Tests/Proto/BinaryTuple/BinaryTupleTests.cs
index a93aef1c7f..6394a9b0eb 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/Proto/BinaryTuple/BinaryTupleTests.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Proto/BinaryTuple/BinaryTupleTests.cs
@@ -400,6 +400,16 @@ namespace Apache.Ignite.Tests.Proto.BinaryTuple
             CollectionAssert.AreEqual(bytes, res);
         }
 
+        [Test]
+        public void TestBytesSpan([Values(0, 1, 123)] int count)
+        {
+            var bytes = Enumerable.Range(1, count).Select(x => (byte)x).ToArray();
+            var reader = BuildAndRead((ref BinaryTupleBuilder b) => b.AppendBytes(bytes));
+            var res = reader.GetBytesSpan(0).ToArray();
+
+            CollectionAssert.AreEqual(bytes, res);
+        }
+
         [Test]
         public void TestBitMask([Values(0, 1, 123)] int count)
         {
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/Sql/IgniteDbDataReaderTests.cs b/modules/platforms/dotnet/Apache.Ignite.Tests/Sql/IgniteDbDataReaderTests.cs
new file mode 100644
index 0000000000..a0adbae08a
--- /dev/null
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Sql/IgniteDbDataReaderTests.cs
@@ -0,0 +1,650 @@
+/*
+ * 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.
+ */
+
+namespace Apache.Ignite.Tests.Sql;
+
+using System;
+using System.Collections.ObjectModel;
+using System.Data;
+using System.Data.Common;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading.Tasks;
+using Ignite.Sql;
+using Internal.Sql;
+using NodaTime;
+using NUnit.Framework;
+using Table;
+
+/// <summary>
+/// Tests for <see cref="ISql.ExecuteReaderAsync"/>.
+/// </summary>
+public class IgniteDbDataReaderTests : IgniteTestsBase
+{
+    private const string AllColumnsQuery = "select \"KEY\", \"STR\", \"INT8\", \"INT16\", \"INT32\", \"INT64\", \"FLOAT\", " +
+                                           "\"DOUBLE\", \"DATE\", \"TIME\", \"DATETIME\", \"TIMESTAMP\", \"BLOB\", \"DECIMAL\" " +
+                                           "from TBL_ALL_COLUMNS_SQL ORDER BY KEY";
+
+    private static readonly LocalDate LocalDate = new(2023, 01, 18);
+
+    private static readonly LocalTime LocalTime = new(09, 28);
+
+    private static readonly LocalDateTime LocalDateTime = new(2023, 01, 18, 09, 29);
+    private static readonly Instant Instant = Instant.FromUnixTimeSeconds(123);
+    private static readonly byte[] Bytes = { 1, 2 };
+
+    [OneTimeSetUp]
+    public async Task InsertTestData()
+    {
+        await Client.Sql.ExecuteAsync(null, "delete from TBL_ALL_COLUMNS_SQL");
+
+        var pocoAllColumns1 = new PocoAllColumnsSqlNullable(
+            Key: 1,
+            Str: "v-1",
+            Int8: 2,
+            Int16: 3,
+            Int32: 4,
+            Int64: 5,
+            Float: 6.5F,
+            Double: 7.5D,
+            Date: LocalDate,
+            Time: LocalTime,
+            DateTime: LocalDateTime,
+            Timestamp: Instant,
+            Blob: Bytes,
+            Decimal: 8.7M);
+
+        var pocoAllColumns2 = new PocoAllColumnsSqlNullable(
+            Key: 2,
+            Str: "v-2",
+            Int8: sbyte.MinValue,
+            Int16: short.MinValue,
+            Int32: int.MinValue,
+            Int64: long.MinValue,
+            Float: float.MinValue,
+            Double: double.MinValue);
+
+        await PocoAllColumnsSqlNullableView.UpsertAllAsync(null, new[] { pocoAllColumns1, pocoAllColumns2 });
+
+        for (int i = 3; i < 10; i++)
+        {
+            await PocoAllColumnsSqlNullableView.UpsertAsync(null, new(i));
+        }
+    }
+
+    [Test]
+    [SuppressMessage("ReSharper", "ReturnValueOfPureMethodIsNotUsed", Justification = "Reviewed.")]
+    public async Task TestBasicUsage()
+    {
+        await using IgniteDbDataReader reader = await Client.Sql.ExecuteReaderAsync(
+            null,
+            "select KEY, INT8 from TBL_ALL_COLUMNS_SQL ORDER BY KEY");
+
+        Assert.AreEqual(2, reader.FieldCount);
+        Assert.IsTrue(reader.HasRows);
+        Assert.AreEqual(0, reader.Depth);
+        Assert.AreEqual(-1, reader.RecordsAffected);
+
+        Assert.AreEqual("KEY", reader.Metadata.Columns[0].Name);
+        Assert.AreEqual("INT8", reader.Metadata.Columns[1].Name);
+
+        Assert.Throws<InvalidOperationException>(() => reader.GetByte(1));
+
+        await reader.ReadAsync();
+        Assert.AreEqual(2, reader.GetByte(1));
+    }
+
+    [Test]
+    public async Task TestAllColumnTypes()
+    {
+        await using var reader = await ExecuteReader();
+
+        Assert.AreEqual(14, reader.FieldCount);
+
+        Assert.AreEqual(1, reader.GetInt64("KEY"));
+        Assert.AreEqual("v-1", reader.GetString("STR"));
+        Assert.AreEqual(2, reader.GetByte("INT8"));
+        Assert.AreEqual(3, reader.GetInt16("INT16"));
+        Assert.AreEqual(4, reader.GetInt32("INT32"));
+        Assert.AreEqual(5, reader.GetInt64("INT64"));
+        Assert.AreEqual(6.5f, reader.GetFloat("FLOAT"));
+        Assert.AreEqual(7.5d, reader.GetDouble("DOUBLE"));
+        Assert.AreEqual(new DateTime(2023, 01, 18), reader.GetDateTime("DATE"));
+        Assert.AreEqual(LocalTime, reader.GetFieldValue<LocalTime>("TIME"));
+        Assert.AreEqual(new DateTime(2023, 01, 18, 09, 29, 0), reader.GetDateTime("DATETIME"));
+        Assert.AreEqual(Instant.ToDateTimeUtc(), reader.GetDateTime("TIMESTAMP"));
+        Assert.AreEqual(8.7m, reader.GetDecimal("DECIMAL"));
+        Assert.AreEqual(2, reader.GetBytes("BLOB", 0, null!, 0, 0));
+    }
+
+    [Test]
+    public async Task TestAllColumnTypesGetFieldValue()
+    {
+        await using var reader = await ExecuteReader();
+
+        Assert.AreEqual(1, reader.GetFieldValue<long>("KEY"));
+        Assert.AreEqual(1, reader.GetFieldValue<int>("KEY"));
+        Assert.AreEqual(1, reader.GetFieldValue<byte>("KEY"));
+        Assert.AreEqual(1, reader.GetFieldValue<short>("KEY"));
+
+        Assert.AreEqual("v-1", reader.GetFieldValue<string>("STR"));
+
+        Assert.AreEqual(2, reader.GetFieldValue<byte>("INT8"));
+        Assert.AreEqual(2, reader.GetFieldValue<short>("INT8"));
+        Assert.AreEqual(2, reader.GetFieldValue<int>("INT8"));
+        Assert.AreEqual(2, reader.GetFieldValue<long>("INT8"));
+
+        Assert.AreEqual(3, reader.GetFieldValue<short>("INT16"));
+        Assert.AreEqual(3, reader.GetFieldValue<byte>("INT16"));
+        Assert.AreEqual(3, reader.GetFieldValue<int>("INT16"));
+        Assert.AreEqual(3, reader.GetFieldValue<long>("INT16"));
+
+        Assert.AreEqual(4, reader.GetFieldValue<int>("INT32"));
+        Assert.AreEqual(4, reader.GetFieldValue<byte>("INT32"));
+        Assert.AreEqual(4, reader.GetFieldValue<short>("INT32"));
+        Assert.AreEqual(4, reader.GetFieldValue<long>("INT32"));
+
+        Assert.AreEqual(5, reader.GetFieldValue<long>("INT64"));
+        Assert.AreEqual(5, reader.GetFieldValue<byte>("INT64"));
+        Assert.AreEqual(5, reader.GetFieldValue<short>("INT64"));
+        Assert.AreEqual(5, reader.GetFieldValue<int>("INT64"));
+
+        Assert.AreEqual(6.5f, reader.GetFieldValue<float>("FLOAT"));
+        Assert.AreEqual(6.5f, reader.GetFieldValue<double>("FLOAT"));
+
+        Assert.AreEqual(7.5d, reader.GetFieldValue<double>("DOUBLE"));
+        Assert.AreEqual(7.5d, reader.GetFieldValue<float>("DOUBLE"));
+
+        Assert.AreEqual(LocalDate, reader.GetFieldValue<LocalDate>("DATE"));
+        Assert.AreEqual(LocalDate.ToDateTimeUnspecified(), reader.GetFieldValue<DateTime>("DATE"));
+
+        Assert.AreEqual(LocalTime, reader.GetFieldValue<LocalTime>("TIME"));
+        Assert.AreEqual(LocalDateTime, reader.GetFieldValue<LocalDateTime>("DATETIME"));
+        Assert.AreEqual(LocalDateTime.ToDateTimeUnspecified(), reader.GetFieldValue<DateTime>("DATETIME"));
+
+        Assert.AreEqual(Instant, reader.GetFieldValue<Instant>("TIMESTAMP"));
+        Assert.AreEqual(8.7m, reader.GetFieldValue<decimal>("DECIMAL"));
+        Assert.AreEqual(Bytes, reader.GetFieldValue<byte[]>("BLOB"));
+
+        Assert.Throws<InvalidCastException>(() => reader.GetFieldValue<Array>("TIME"));
+    }
+
+    [Test]
+    public async Task TestAllColumnTypesGetFieldValueAsync()
+    {
+        await using var reader = await ExecuteReader();
+
+        Assert.AreEqual(1, await reader.GetFieldValueAsync<long>("KEY"));
+        Assert.AreEqual(1, await reader.GetFieldValueAsync<int>("KEY"));
+        Assert.AreEqual(1, await reader.GetFieldValueAsync<byte>("KEY"));
+        Assert.AreEqual(1, await reader.GetFieldValueAsync<short>("KEY"));
+
+        Assert.AreEqual("v-1", await reader.GetFieldValueAsync<string>("STR"));
+
+        Assert.AreEqual(2, await reader.GetFieldValueAsync<byte>("INT8"));
+        Assert.AreEqual(2, await reader.GetFieldValueAsync<short>("INT8"));
+        Assert.AreEqual(2, await reader.GetFieldValueAsync<int>("INT8"));
+        Assert.AreEqual(2, await reader.GetFieldValueAsync<long>("INT8"));
+
+        Assert.AreEqual(3, await reader.GetFieldValueAsync<short>("INT16"));
+        Assert.AreEqual(3, await reader.GetFieldValueAsync<byte>("INT16"));
+        Assert.AreEqual(3, await reader.GetFieldValueAsync<int>("INT16"));
+        Assert.AreEqual(3, await reader.GetFieldValueAsync<long>("INT16"));
+
+        Assert.AreEqual(4, await reader.GetFieldValueAsync<int>("INT32"));
+        Assert.AreEqual(4, await reader.GetFieldValueAsync<byte>("INT32"));
+        Assert.AreEqual(4, await reader.GetFieldValueAsync<short>("INT32"));
+        Assert.AreEqual(4, await reader.GetFieldValueAsync<long>("INT32"));
+
+        Assert.AreEqual(5, await reader.GetFieldValueAsync<long>("INT64"));
+        Assert.AreEqual(5, await reader.GetFieldValueAsync<byte>("INT64"));
+        Assert.AreEqual(5, await reader.GetFieldValueAsync<short>("INT64"));
+        Assert.AreEqual(5, await reader.GetFieldValueAsync<int>("INT64"));
+
+        Assert.AreEqual(6.5f, await reader.GetFieldValueAsync<float>("FLOAT"));
+        Assert.AreEqual(6.5f, await reader.GetFieldValueAsync<double>("FLOAT"));
+
+        Assert.AreEqual(7.5d, await reader.GetFieldValueAsync<double>("DOUBLE"));
+        Assert.AreEqual(7.5d, await reader.GetFieldValueAsync<float>("DOUBLE"));
+
+        Assert.AreEqual(LocalDate, await reader.GetFieldValueAsync<LocalDate>("DATE"));
+        Assert.AreEqual(LocalDate.ToDateTimeUnspecified(), await reader.GetFieldValueAsync<DateTime>("DATE"));
+
+        Assert.AreEqual(LocalTime, await reader.GetFieldValueAsync<LocalTime>("TIME"));
+        Assert.AreEqual(LocalDateTime, await reader.GetFieldValueAsync<LocalDateTime>("DATETIME"));
+        Assert.AreEqual(LocalDateTime.ToDateTimeUnspecified(), await reader.GetFieldValueAsync<DateTime>("DATETIME"));
+
+        Assert.AreEqual(Instant, await reader.GetFieldValueAsync<Instant>("TIMESTAMP"));
+        Assert.AreEqual(8.7m, await reader.GetFieldValueAsync<decimal>("DECIMAL"));
+        Assert.AreEqual(Bytes, await reader.GetFieldValueAsync<byte[]>("BLOB"));
+    }
+
+    [Test]
+    public async Task TestAllColumnTypesAsCompatibleTypes()
+    {
+        await using var reader = await ExecuteReader();
+
+        Assert.AreEqual(2, reader.GetByte("INT8"));
+        Assert.AreEqual(2, reader.GetInt16("INT8"));
+        Assert.AreEqual(2, reader.GetInt32("INT8"));
+        Assert.AreEqual(2, reader.GetInt64("INT8"));
+
+        Assert.AreEqual(3, reader.GetByte("INT16"));
+        Assert.AreEqual(3, reader.GetInt16("INT16"));
+        Assert.AreEqual(3, reader.GetInt32("INT16"));
+        Assert.AreEqual(3, reader.GetInt64("INT16"));
+
+        Assert.AreEqual(4, reader.GetByte("INT32"));
+        Assert.AreEqual(4, reader.GetInt16("INT32"));
+        Assert.AreEqual(4, reader.GetInt32("INT32"));
+        Assert.AreEqual(4, reader.GetInt64("INT32"));
+
+        Assert.AreEqual(5, reader.GetByte("INT64"));
+        Assert.AreEqual(5, reader.GetInt16("INT64"));
+        Assert.AreEqual(5, reader.GetInt32("INT64"));
+        Assert.AreEqual(5, reader.GetInt64("INT64"));
+
+        Assert.AreEqual(6.5f, reader.GetFloat("FLOAT"));
+        Assert.AreEqual(6.5f, reader.GetDouble("FLOAT"));
+
+        Assert.AreEqual(7.5d, reader.GetFloat("DOUBLE"));
+        Assert.AreEqual(7.5d, reader.GetDouble("DOUBLE"));
+
+        await reader.ReadAsync();
+
+        Assert.AreEqual(unchecked((byte)sbyte.MinValue), reader.GetByte("INT8"));
+        Assert.AreEqual(sbyte.MinValue, reader.GetInt16("INT8"));
+        Assert.AreEqual(sbyte.MinValue, reader.GetInt32("INT8"));
+        Assert.AreEqual(sbyte.MinValue, reader.GetInt64("INT8"));
+
+        Assert.AreEqual(short.MinValue, reader.GetInt16("INT16"));
+        Assert.AreEqual(short.MinValue, reader.GetInt32("INT16"));
+        Assert.AreEqual(short.MinValue, reader.GetInt64("INT16"));
+
+        Assert.AreEqual(int.MinValue, reader.GetInt32("INT32"));
+        Assert.AreEqual(int.MinValue, reader.GetInt64("INT32"));
+
+        Assert.AreEqual(long.MinValue, reader.GetInt64("INT64"));
+
+        Assert.AreEqual(float.MinValue, reader.GetFloat("FLOAT"));
+        Assert.AreEqual(float.MinValue, reader.GetDouble("FLOAT"));
+
+        Assert.AreEqual(double.MinValue, reader.GetDouble("DOUBLE"));
+    }
+
+    [Test]
+    [SuppressMessage("ReSharper", "AccessToDisposedClosure", Justification = "Reviewed.")]
+    public async Task TestIntFloatColumnsValueOutOfRangeThrows()
+    {
+        await using IgniteDbDataReader reader = await Client.Sql.ExecuteReaderAsync(null, AllColumnsQuery);
+        await reader.ReadAsync();
+        await reader.ReadAsync();
+
+        Test(() => reader.GetByte("INT16"), 1, 2);
+        Test(() => reader.GetByte("INT32"), 1, 4);
+        Test(() => reader.GetByte("INT64"), 1, 8);
+
+        Test(() => reader.GetInt16("INT32"), 2, 4);
+        Test(() => reader.GetInt16("INT64"), 2, 8);
+
+        Test(() => reader.GetInt32("INT64"), 4, 8);
+
+        Test(() => reader.GetFloat("DOUBLE"), 4, 8);
+
+        static void Test(TestDelegate testDelegate, int expected, int actual)
+        {
+            var ex = Assert.Throws<InvalidOperationException>(testDelegate);
+            StringAssert.StartsWith("Binary tuple element with index", ex!.Message);
+            StringAssert.Contains($"has invalid length (expected {expected}, actual {actual}).", ex.Message);
+        }
+    }
+
+    [Test]
+    [SuppressMessage("ReSharper", "AccessToDisposedClosure", Justification = "Reviewed.")]
+    public async Task TestAllColumnTypesAsIncompatibleTypeThrows()
+    {
+        await using var reader = await ExecuteReader();
+
+        Test(() => reader.GetBoolean("STR"), "STR", SqlColumnType.String, typeof(bool), typeof(string));
+        Test(() => reader.GetString("INT8"), "INT8", SqlColumnType.Int8, typeof(string), typeof(sbyte));
+        Test(() => reader.GetGuid("INT16"), "INT16", SqlColumnType.Int16, typeof(Guid), typeof(short));
+        Test(() => reader.GetDateTime("INT32"), "INT32", SqlColumnType.Int32, typeof(DateTime), typeof(int));
+        Test(() => reader.GetFloat("INT64"), "INT64", SqlColumnType.Int64, typeof(float), typeof(long));
+        Test(() => reader.GetDouble("INT64"), "INT64", SqlColumnType.Int64, typeof(double), typeof(long));
+        Test(() => reader.GetString("INT64"), "INT64", SqlColumnType.Int64, typeof(string), typeof(long));
+        Test(() => reader.GetByte("STR"), "STR", SqlColumnType.String, typeof(byte), typeof(string));
+        Test(() => reader.GetBytes("STR", 0, null!, 0, 0), "STR", SqlColumnType.String, typeof(byte[]), typeof(string));
+        Test(() => reader.GetDecimal("STR"), "STR", SqlColumnType.String, typeof(decimal), typeof(string));
+        Test(() => reader.GetInt16("STR"), "STR", SqlColumnType.String, typeof(short), typeof(string));
+        Test(() => reader.GetInt32("STR"), "STR", SqlColumnType.String, typeof(int), typeof(string));
+        Test(() => reader.GetInt64("STR"), "STR", SqlColumnType.String, typeof(long), typeof(string));
+
+        static void Test(TestDelegate testDelegate, string columnName, SqlColumnType columnType, Type expectedType, Type actualType)
+        {
+            var ex = Assert.Throws<InvalidCastException>(testDelegate);
+
+            Assert.AreEqual(
+                $"Column {columnName} of type {columnType.ToSqlTypeName()} ({actualType}) can not be cast to {expectedType}.",
+                ex!.Message);
+        }
+    }
+
+    [Test]
+    [SuppressMessage("Performance", "CA1849:Call async methods when in an async method", Justification = "Testing sync method.")]
+    public async Task TestMultiplePages([Values(true, false)] bool async)
+    {
+        var statement = new SqlStatement(AllColumnsQuery, pageSize: 2);
+        await using var reader = await Client.Sql.ExecuteReaderAsync(null, statement);
+
+        var count = 0;
+
+        while (async ? await reader.ReadAsync() : reader.Read())
+        {
+            count++;
+
+            Assert.AreEqual(count, reader.GetInt32(0));
+        }
+
+        Assert.IsFalse(reader.Read());
+        Assert.IsFalse(await reader.ReadAsync());
+
+        Assert.AreEqual(9, count);
+    }
+
+    [Test]
+    public async Task TestGetColumnSchema([Values(true, false)] bool async)
+    {
+        await using var reader = await ExecuteReader();
+
+        ReadOnlyCollection<DbColumn> schema = async ? await reader.GetColumnSchemaAsync() : reader.GetColumnSchema();
+
+        Assert.AreEqual(14, schema.Count);
+
+        Assert.AreEqual("KEY", schema[0].ColumnName);
+        Assert.AreEqual(0, schema[0].ColumnOrdinal);
+        Assert.IsNull(schema[0].ColumnSize);
+        Assert.AreEqual(typeof(long), schema[0].DataType);
+        Assert.AreEqual("bigint", schema[0].DataTypeName);
+        Assert.IsFalse(schema[0].AllowDBNull);
+        Assert.AreEqual(19, schema[0].NumericPrecision);
+        Assert.AreEqual(0, schema[0].NumericScale);
+        Assert.IsNotNull((schema[0] as IgniteDbColumn)?.ColumnMetadata);
+
+        Assert.AreEqual("STR", schema[1].ColumnName);
+        Assert.AreEqual(1, schema[1].ColumnOrdinal);
+        Assert.IsNull(schema[1].ColumnSize);
+        Assert.AreEqual(typeof(string), schema[1].DataType);
+        Assert.AreEqual("varchar", schema[1].DataTypeName);
+        Assert.IsTrue(schema[1].AllowDBNull);
+        Assert.AreEqual(65536, schema[1].NumericPrecision);
+        Assert.IsNull(schema[1].NumericScale);
+        Assert.IsNotNull((schema[1] as IgniteDbColumn)?.ColumnMetadata);
+    }
+
+    [Test]
+    public async Task TestGetName()
+    {
+        // ReSharper disable once UseAwaitUsing (test sync variant)
+        using var reader = await ExecuteReader();
+
+        Assert.AreEqual("KEY", reader.GetName(0));
+        Assert.AreEqual("STR", reader.GetName(1));
+        Assert.AreEqual("DECIMAL", reader.GetName(13));
+    }
+
+    [Test]
+    public async Task TestGetValue()
+    {
+        await using var reader = await ExecuteReader();
+
+        Assert.AreEqual(1, reader.GetValue("KEY"));
+        Assert.AreEqual("v-1", reader.GetValue("STR"));
+        Assert.AreEqual(2, reader.GetValue("INT8"));
+        Assert.AreEqual(3, reader.GetValue("INT16"));
+        Assert.AreEqual(4, reader.GetValue("INT32"));
+        Assert.AreEqual(5, reader.GetValue("INT64"));
+        Assert.AreEqual(6.5f, reader.GetValue("FLOAT"));
+        Assert.AreEqual(7.5d, reader.GetValue("DOUBLE"));
+        Assert.AreEqual(LocalDate, reader.GetValue("DATE"));
+        Assert.AreEqual(LocalTime, reader.GetValue("TIME"));
+        Assert.AreEqual(LocalDateTime, reader.GetValue("DATETIME"));
+        Assert.AreEqual(Instant, reader.GetValue("TIMESTAMP"));
+        Assert.AreEqual(8.7m, reader.GetValue("DECIMAL"));
+        Assert.AreEqual(Bytes, reader.GetValue("BLOB"));
+    }
+
+    [Test]
+    public async Task TestGetValues()
+    {
+        await using var reader = await ExecuteReader();
+
+        var values = new object[reader.FieldCount];
+        var count = reader.GetValues(values);
+
+        var expected = new object[] { 1, "v-1", 2, 3, 4, 5, 6.5f, 7.5d, LocalDate, LocalTime, LocalDateTime, Instant, Bytes, 8.7m };
+
+        CollectionAssert.AreEqual(expected, values);
+        Assert.AreEqual(reader.FieldCount, count);
+    }
+
+    [Test]
+    public async Task TestGetChar()
+    {
+        await using var reader = await ExecuteReader();
+
+        Assert.Throws<NotSupportedException>(() => reader.GetChar("KEY"));
+    }
+
+    [Test]
+    public async Task TestGetBytes()
+    {
+        await using var reader = await ExecuteReader();
+
+        var bytesLen = reader.GetBytes(name: "BLOB", dataOffset: 0, buffer: null!, bufferOffset: 0, length: 0);
+        var bytes = new byte[bytesLen];
+
+        Assert.AreEqual(2, bytesLen);
+        Assert.AreEqual(2, reader.GetBytes(name: "BLOB", dataOffset: 0L, buffer: bytes, bufferOffset: 0, length: (int)bytesLen));
+        Assert.AreEqual(Bytes, bytes);
+
+        Assert.Throws<ArgumentOutOfRangeException>(() =>
+            reader.GetBytes(ordinal: 0, dataOffset: -1, buffer: null, bufferOffset: 0, length: 0));
+
+        Assert.Throws<ArgumentOutOfRangeException>(() =>
+            reader.GetBytes(ordinal: 0, dataOffset: 0, buffer: bytes, bufferOffset: 10, length: 0));
+
+        Assert.Throws<ArgumentOutOfRangeException>(() =>
+            reader.GetBytes(ordinal: 0, dataOffset: 0, buffer: bytes, bufferOffset: 0, length: 10));
+    }
+
+    [Test]
+    public async Task TestGetChars()
+    {
+        await using var reader = await ExecuteReader();
+
+        var len = reader.GetChars(name: "STR", dataOffset: 0, buffer: null!, bufferOffset: 0, length: 0);
+        var chars = new char[len];
+        var count = reader.GetChars(name: "STR", dataOffset: 1, buffer: chars, bufferOffset: 1, length: 2);
+
+        Assert.AreEqual(3, len);
+        Assert.AreEqual(2, count);
+        Assert.AreEqual(new[] { (char)0, '-', '1' }, chars);
+
+        Assert.Throws<ArgumentOutOfRangeException>(() =>
+            reader.GetChars(ordinal: 0, dataOffset: -1, buffer: null, bufferOffset: 0, length: 0));
+
+        Assert.Throws<ArgumentOutOfRangeException>(() =>
+            reader.GetChars(ordinal: 0, dataOffset: 0, buffer: chars, bufferOffset: 10, length: 0));
+
+        Assert.Throws<ArgumentOutOfRangeException>(() =>
+            reader.GetChars(ordinal: 0, dataOffset: 0, buffer: chars, bufferOffset: 0, length: 10));
+    }
+
+    [Test]
+    public async Task TestIndexers()
+    {
+        await using var reader = await ExecuteReader();
+
+        Assert.AreEqual(1, reader[0]);
+        Assert.AreEqual(1, reader["KEY"]);
+
+        Assert.AreEqual("v-1", reader[1]);
+        Assert.AreEqual("v-1", reader["STR"]);
+
+        Assert.Throws<ArgumentOutOfRangeException>(() => _ = reader[100]);
+        Assert.Throws<InvalidOperationException>(() => _ = reader["ABC"]);
+    }
+
+    [Test]
+    [SuppressMessage("Performance", "CA1849:Call async methods when in an async method", Justification = "Testing sync method.")]
+    [SuppressMessage("ReSharper", "MethodHasAsyncOverload", Justification = "Testing sync method.")]
+    public async Task TestClose([Values(true, false)] bool async)
+    {
+        await using var reader = await ExecuteReader();
+        Assert.IsFalse(reader.IsClosed);
+
+        if (async)
+        {
+            await reader.CloseAsync();
+        }
+        else
+        {
+            reader.Close();
+        }
+
+        Assert.IsTrue(reader.IsClosed);
+    }
+
+    [Test]
+    public async Task TestReadAllRowsClosesReader()
+    {
+        await using var reader = await ExecuteReader();
+        Assert.IsFalse(reader.IsClosed);
+
+        while (await reader.ReadAsync())
+        {
+            // No-op.
+        }
+
+        Assert.IsTrue(reader.IsClosed);
+    }
+
+    [Test]
+    public async Task TestIsDbNull()
+    {
+        await using var reader = await ExecuteReader();
+        await reader.ReadAsync();
+
+        /* ReSharper disable MethodHasAsyncOverload */
+        Assert.IsFalse(reader.IsDBNull("KEY"));
+        Assert.IsTrue(reader.IsDBNull("BLOB"));
+        /* ReSharper restore MethodHasAsyncOverload */
+    }
+
+    [Test]
+    public async Task TestNextResult()
+    {
+        await using var reader = await ExecuteReader();
+
+        Assert.Throws<NotSupportedException>(() => reader.NextResult());
+        Assert.ThrowsAsync<NotSupportedException>(() => reader.NextResultAsync());
+    }
+
+    [Test]
+    public async Task TestGetEnumerator()
+    {
+        await using var reader = await Client.Sql.ExecuteReaderAsync(null, AllColumnsQuery);
+
+        foreach (DbDataRecord row in reader)
+        {
+            // DbDataRecord delegates to methods in DbDataReader, no need to test everything here.
+            Assert.AreEqual(14, row.FieldCount);
+            Assert.AreEqual("KEY", row.GetName(0));
+        }
+    }
+
+    [Test]
+    public async Task TestGetFieldType()
+    {
+        await using var reader = await ExecuteReader();
+
+        Assert.AreEqual(typeof(long), reader.GetFieldType(0));
+        Assert.AreEqual(typeof(string), reader.GetFieldType(1));
+        Assert.AreEqual(typeof(sbyte), reader.GetFieldType(2));
+        Assert.AreEqual(typeof(short), reader.GetFieldType(3));
+        Assert.AreEqual(typeof(int), reader.GetFieldType(4));
+        Assert.AreEqual(typeof(long), reader.GetFieldType(5));
+        Assert.AreEqual(typeof(float), reader.GetFieldType(6));
+        Assert.AreEqual(typeof(double), reader.GetFieldType(7));
+        Assert.AreEqual(typeof(LocalDate), reader.GetFieldType(8));
+        Assert.AreEqual(typeof(LocalTime), reader.GetFieldType(9));
+        Assert.AreEqual(typeof(LocalDateTime), reader.GetFieldType(10));
+        Assert.AreEqual(typeof(Instant), reader.GetFieldType(11));
+        Assert.AreEqual(typeof(byte[]), reader.GetFieldType(12));
+        Assert.AreEqual(typeof(decimal), reader.GetFieldType(13));
+    }
+
+    [Test]
+    public async Task TestGetDataTypeName()
+    {
+        await using var reader = await ExecuteReader();
+
+        Assert.AreEqual("bigint", reader.GetDataTypeName(0));
+        Assert.AreEqual("varchar", reader.GetDataTypeName(1));
+        Assert.AreEqual("tinyint", reader.GetDataTypeName(2));
+        Assert.AreEqual("smallint", reader.GetDataTypeName(3));
+        Assert.AreEqual("int", reader.GetDataTypeName(4));
+        Assert.AreEqual("bigint", reader.GetDataTypeName(5));
+        Assert.AreEqual("real", reader.GetDataTypeName(6));
+        Assert.AreEqual("double", reader.GetDataTypeName(7));
+        Assert.AreEqual("date", reader.GetDataTypeName(8));
+        Assert.AreEqual("time", reader.GetDataTypeName(9));
+        Assert.AreEqual("timestamp", reader.GetDataTypeName(10));
+        Assert.AreEqual("timestamp_tz", reader.GetDataTypeName(11));
+        Assert.AreEqual("varbinary", reader.GetDataTypeName(12));
+        Assert.AreEqual("decimal", reader.GetDataTypeName(13));
+    }
+
+    [Test]
+    public async Task TestDataTableLoad()
+    {
+        await using var reader = await Client.Sql.ExecuteReaderAsync(null, AllColumnsQuery);
+
+        // This calls GetSchemaTable underneath.
+        var dt = new DataTable();
+        dt.Load(reader);
+
+        Assert.AreEqual(14, dt.Columns.Count);
+        Assert.AreEqual(9, dt.Rows.Count);
+    }
+
+    [Test]
+    public void TestExecuteReaderThrowsOnDmlQuery()
+    {
+        var ex = Assert.ThrowsAsync<InvalidOperationException>(async () =>
+            await Client.Sql.ExecuteReaderAsync(null, "UPDATE TBL_ALL_COLUMNS_SQL SET STR='s' WHERE KEY > 100"));
+
+        Assert.AreEqual("ExecuteReaderAsync does not support queries without row set (DDL, DML).", ex!.Message);
+    }
+
+    private async Task<IgniteDbDataReader> ExecuteReader()
+    {
+        var reader = await Client.Sql.ExecuteReaderAsync(null, AllColumnsQuery);
+        await reader.ReadAsync();
+
+        return reader;
+    }
+}
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/Sql/SqlTests.cs b/modules/platforms/dotnet/Apache.Ignite.Tests/Sql/SqlTests.cs
index 815b8c6194..60ee7a72b1 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/Sql/SqlTests.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Sql/SqlTests.cs
@@ -294,12 +294,16 @@ namespace Apache.Ignite.Tests.Sql
             Assert.AreEqual("TESTDDLDML", columns[0].Origin!.TableName);
             Assert.IsTrue(columns[0].Nullable);
             Assert.AreEqual(SqlColumnType.String, columns[0].Type);
+            Assert.AreEqual(int.MinValue, columns[0].Scale); // TODO IGNITE-18602 should conform to javadoc/xmldoc.
+            Assert.AreEqual(65536, columns[0].Precision);
 
             Assert.AreEqual("ID", columns[1].Name);
             Assert.AreEqual("ID", columns[1].Origin!.ColumnName);
             Assert.AreEqual("PUBLIC", columns[1].Origin!.SchemaName);
             Assert.AreEqual("TESTDDLDML", columns[1].Origin!.TableName);
             Assert.IsFalse(columns[1].Nullable);
+            Assert.AreEqual(0, columns[1].Scale);
+            Assert.AreEqual(10, columns[1].Precision);
 
             Assert.AreEqual("ID + 1", columns[2].Name);
             Assert.IsNull(columns[2].Origin);
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Proto/BinaryTuple/BinaryTupleReader.cs b/modules/platforms/dotnet/Apache.Ignite/Internal/Proto/BinaryTuple/BinaryTupleReader.cs
index e33ff2b83a..fdb1ac0d69 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Proto/BinaryTuple/BinaryTupleReader.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Proto/BinaryTuple/BinaryTupleReader.cs
@@ -463,6 +463,13 @@ namespace Apache.Ignite.Internal.Proto.BinaryTuple
         /// <returns>Value.</returns>
         public byte[]? GetBytesNullable(int index) => IsNull(index) ? null : GetBytes(index);
 
+        /// <summary>
+        /// Gets bytes.
+        /// </summary>
+        /// <param name="index">Index.</param>
+        /// <returns>Value.</returns>
+        public ReadOnlySpan<byte> GetBytesSpan(int index) => Seek(index);
+
         /// <summary>
         /// Gets an object value according to the specified type.
         /// </summary>
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/ResultSet.cs b/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/ResultSet.cs
index a1031ead5e..d4003f3e84 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/ResultSet.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/ResultSet.cs
@@ -42,8 +42,6 @@ namespace Apache.Ignite.Internal.Sql
 
         private readonly PooledBuffer? _buffer;
 
-        private readonly int _bufferOffset;
-
         private readonly bool _hasMorePages;
 
         private readonly RowReader<T>? _rowReader;
@@ -79,8 +77,7 @@ namespace Apache.Ignite.Internal.Sql
 
             if (HasRowSet)
             {
-                _buffer = buf;
-                _bufferOffset = reader.Consumed;
+                _buffer = buf.Slice(reader.Consumed);
             }
             else
             {
@@ -110,6 +107,11 @@ namespace Apache.Ignite.Internal.Sql
         /// <inheritdoc/>
         public bool WasApplied { get; }
 
+        /// <summary>
+        /// Gets a value indicating whether this instance is disposed.
+        /// </summary>
+        internal bool IsDisposed => (_resourceId == null || _resourceClosed) && _bufferReleased > 0;
+
         /// <inheritdoc/>
         public async ValueTask<List<T>> ToListAsync() =>
             await CollectAsync(
@@ -146,22 +148,22 @@ namespace Apache.Ignite.Internal.Sql
             var hasMore = _hasMorePages;
             TResult? res = default;
 
-            ReadPage(_buffer!.Value, _bufferOffset);
+            ReadPage(_buffer!.Value);
             ReleaseBuffer();
 
             while (hasMore)
             {
                 using var pageBuf = await FetchNextPage().ConfigureAwait(false);
-                ReadPage(pageBuf, 0);
+                ReadPage(pageBuf);
             }
 
             _resourceClosed = true;
 
             return res!;
 
-            void ReadPage(PooledBuffer buf, int offset)
+            void ReadPage(PooledBuffer buf)
             {
-                var reader = buf.GetReader(offset);
+                var reader = buf.GetReader();
                 var pageSize = reader.ReadArrayHeader();
 
                 var capacity = hasMore ? pageSize * 2 : pageSize;
@@ -222,6 +224,44 @@ namespace Apache.Ignite.Internal.Sql
             return EnumerateRows().GetAsyncEnumerator(cancellationToken);
         }
 
+        /// <summary>
+        /// Enumerates ResultSet pages.
+        /// </summary>
+        /// <returns>ResultSet pages.</returns>
+        internal async IAsyncEnumerable<PooledBuffer> EnumeratePagesInternal()
+        {
+            ValidateAndSetIteratorState();
+
+            yield return _buffer!.Value;
+
+            ReleaseBuffer();
+
+            if (!_hasMorePages)
+            {
+                yield break;
+            }
+
+            while (true)
+            {
+                using var buffer = await FetchNextPage().ConfigureAwait(false);
+
+                yield return buffer;
+
+                if (!HasMore(buffer))
+                {
+                    break;
+                }
+            }
+
+            static bool HasMore(PooledBuffer buf)
+            {
+                var reader = buf.GetReader();
+                reader.Skip();
+
+                return !reader.End && reader.ReadBoolean();
+            }
+        }
+
         private static ResultSetMetadata ReadMeta(ref MsgPackReader reader)
         {
             var size = reader.ReadArrayHeader();
@@ -265,7 +305,7 @@ namespace Apache.Ignite.Internal.Sql
         {
             var hasMore = _hasMorePages;
             var cols = Metadata!.Columns;
-            var offset = _bufferOffset;
+            var offset = 0;
 
             // First page.
             foreach (var row in EnumeratePage(_buffer!.Value))
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/Sql.cs b/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/Sql.cs
index 0201bf68e5..71e10acf18 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/Sql.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/Sql.cs
@@ -62,6 +62,19 @@ namespace Apache.Ignite.Internal.Sql
             throw new NotSupportedException();
         }
 
+        /// <inheritdoc/>
+        public async Task<IgniteDbDataReader> ExecuteReaderAsync(ITransaction? transaction, SqlStatement statement, params object?[]? args)
+        {
+            var resultSet = await ExecuteAsyncInternal<object>(transaction, statement, _ => null!, args).ConfigureAwait(false);
+
+            if (!resultSet.HasRowSet)
+            {
+                throw new InvalidOperationException($"{nameof(ExecuteReaderAsync)} does not support queries without row set (DDL, DML).");
+            }
+
+            return new IgniteDbDataReader(resultSet);
+        }
+
         /// <summary>
         /// Reads column value.
         /// </summary>
@@ -110,7 +123,7 @@ namespace Apache.Ignite.Internal.Sql
         /// <param name="args">Arguments for the statement.</param>
         /// <typeparam name="T">Row type.</typeparam>
         /// <returns>SQL result set.</returns>
-        internal async Task<IResultSet<T>> ExecuteAsyncInternal<T>(
+        internal async Task<ResultSet<T>> ExecuteAsyncInternal<T>(
             ITransaction? transaction,
             SqlStatement statement,
             RowReaderFactory<T> rowReaderFactory,
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/SqlColumnTypeExtensions.cs b/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/SqlColumnTypeExtensions.cs
index 68a1790aec..9b331d05c5 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/SqlColumnTypeExtensions.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/SqlColumnTypeExtensions.cs
@@ -124,4 +124,20 @@ internal static class SqlColumnTypeExtensions
     /// <returns>SQL column type, or null.</returns>
     public static SqlColumnType? ToSqlColumnType(this Type type) =>
         ClrToSql.TryGetValue(Nullable.GetUnderlyingType(type) ?? type, out var sqlType) ? sqlType : null;
+
+    /// <summary>
+    /// Gets a value indicating whether specified column type is an integer of any size (int8 to int64).
+    /// </summary>
+    /// <param name="sqlColumnType">SQL column type.</param>
+    /// <returns>Whether the type is integer.</returns>
+    public static bool IsAnyInt(this SqlColumnType sqlColumnType) =>
+        sqlColumnType is SqlColumnType.Int8 or SqlColumnType.Int16 or SqlColumnType.Int32 or SqlColumnType.Int64;
+
+    /// <summary>
+    /// Gets a value indicating whether specified column type is a floating point of any size (float32 to float64).
+    /// </summary>
+    /// <param name="sqlColumnType">SQL column type.</param>
+    /// <returns>Whether the type is floating point.</returns>
+    public static bool IsAnyFloat(this SqlColumnType sqlColumnType) =>
+        sqlColumnType is SqlColumnType.Float or SqlColumnType.Double;
 }
diff --git a/modules/platforms/dotnet/Apache.Ignite/Sql/ISql.cs b/modules/platforms/dotnet/Apache.Ignite/Sql/ISql.cs
index 247950b05d..ac81dfcb56 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Sql/ISql.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Sql/ISql.cs
@@ -17,6 +17,7 @@
 
 namespace Apache.Ignite.Sql
 {
+    using System.Data.Common;
     using System.Threading.Tasks;
     using Table;
     using Transactions;
@@ -44,5 +45,14 @@ namespace Apache.Ignite.Sql
         /// <typeparam name="T">Row type.</typeparam>
         /// <returns>SQL result set.</returns>
         Task<IResultSet<T>> ExecuteAsync<T>(ITransaction? transaction, SqlStatement statement, params object?[]? args);
+
+        /// <summary>
+        /// Executes single SQL statement and returns a <see cref="DbDataReader"/> to consume them in an efficient, forward-only way.
+        /// </summary>
+        /// <param name="transaction">Optional transaction.</param>
+        /// <param name="statement">Statement to execute.</param>
+        /// <param name="args">Arguments for the statement.</param>
+        /// <returns>Data reader.</returns>
+        Task<IgniteDbDataReader> ExecuteReaderAsync(ITransaction? transaction, SqlStatement statement, params object?[]? args);
     }
 }
diff --git a/modules/platforms/dotnet/Apache.Ignite/Sql/IgniteDbColumn.cs b/modules/platforms/dotnet/Apache.Ignite/Sql/IgniteDbColumn.cs
new file mode 100644
index 0000000000..f535676fcb
--- /dev/null
+++ b/modules/platforms/dotnet/Apache.Ignite/Sql/IgniteDbColumn.cs
@@ -0,0 +1,49 @@
+/*
+ * 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.
+ */
+
+namespace Apache.Ignite.Sql;
+
+using System.Data.Common;
+using Internal.Sql;
+
+/// <summary>
+/// Represents a column within Ignite result set.
+/// </summary>
+public sealed class IgniteDbColumn : DbColumn
+{
+    /// <summary>
+    /// Initializes a new instance of the <see cref="IgniteDbColumn"/> class.
+    /// </summary>
+    /// <param name="column">Column.</param>
+    /// <param name="ordinal">Column ordinal.</param>
+    internal IgniteDbColumn(IColumnMetadata column, int ordinal)
+    {
+        ColumnMetadata = column;
+        ColumnName = column.Name;
+        ColumnOrdinal = ordinal;
+        DataTypeName = column.Type.ToSqlTypeName();
+        DataType = column.Type.ToClrType();
+        AllowDBNull = column.Nullable;
+        NumericPrecision = column.Precision < 0 ? null : column.Precision;
+        NumericScale = column.Scale < 0 ? null : column.Scale;
+    }
+
+    /// <summary>
+    /// Gets Ignite-specific column metadata.
+    /// </summary>
+    public IColumnMetadata ColumnMetadata { get; }
+}
diff --git a/modules/platforms/dotnet/Apache.Ignite/Sql/IgniteDbDataReader.cs b/modules/platforms/dotnet/Apache.Ignite/Sql/IgniteDbDataReader.cs
new file mode 100644
index 0000000000..f18312ef2a
--- /dev/null
+++ b/modules/platforms/dotnet/Apache.Ignite/Sql/IgniteDbDataReader.cs
@@ -0,0 +1,537 @@
+/*
+ * 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.
+ */
+
+namespace Apache.Ignite.Sql;
+
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Data;
+using System.Data.Common;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+using Internal.Buffers;
+using Internal.Common;
+using Internal.Proto;
+using Internal.Proto.BinaryTuple;
+using Internal.Sql;
+using NodaTime;
+
+/// <summary>
+/// Reads a forward-only stream of rows from an Ignite result set.
+/// </summary>
+[SuppressMessage("Design", "CA1010:Generic interface should also be implemented", Justification = "Generic IEnumerable is not applicable.")]
+[SuppressMessage("Usage", "CA2215:Dispose methods should call base class dispose", Justification = "Base class dispose is empty.")]
+public sealed class IgniteDbDataReader : DbDataReader, IDbColumnSchemaGenerator
+{
+    private static readonly Task<bool> TrueTask = Task.FromResult(true);
+
+    private readonly ResultSet<object> _resultSet;
+
+    private readonly IAsyncEnumerator<PooledBuffer> _pageEnumerator;
+
+    private int _pageRowCount = -1;
+
+    private int _pageRowIndex = -1;
+
+    private int _pageRowOffset = -1;
+
+    private int _pageRowSize = -1;
+
+    private ReadOnlyCollection<DbColumn>? _schema;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="IgniteDbDataReader"/> class.
+    /// </summary>
+    /// <param name="resultSet">Result set.</param>
+    internal IgniteDbDataReader(ResultSet<object> resultSet)
+    {
+        Debug.Assert(resultSet.HasRowSet, "_resultSet.HasRowSet");
+
+        _resultSet = resultSet;
+
+        _pageEnumerator = _resultSet.EnumeratePagesInternal().GetAsyncEnumerator();
+    }
+
+    /// <inheritdoc/>
+    public override int FieldCount => Metadata.Columns.Count;
+
+    /// <inheritdoc/>
+    public override int RecordsAffected => checked((int)_resultSet.AffectedRows);
+
+    /// <inheritdoc/>
+    public override bool HasRows => _resultSet.HasRowSet;
+
+    /// <inheritdoc/>
+    public override bool IsClosed => _resultSet.IsDisposed;
+
+    /// <summary>
+    /// Gets a value indicating the depth of nesting for the current row. Always zero in Ignite.
+    /// </summary>
+    /// <returns>The level of nesting.</returns>
+    public override int Depth => 0;
+
+    /// <summary>
+    /// Gets Ignite-specific result set metadata.
+    /// </summary>
+    public IResultSetMetadata Metadata => _resultSet.Metadata!;
+
+    /// <inheritdoc/>
+    public override object this[int ordinal] => GetValue(ordinal);
+
+    /// <inheritdoc/>
+    [SuppressMessage(
+        "Design",
+        "CA1065:Do not raise exceptions in unexpected locations",
+        Justification = "Indexer must raise an exception on invalid column name.")]
+    public override object this[string name] =>
+        Metadata.IndexOf(name) is var index and >= 0
+            ? GetValue(index)
+            : throw new InvalidOperationException($"Column '{name}' is not present in this reader.");
+
+    /// <inheritdoc />
+    public override bool GetBoolean(int ordinal) => GetReader(ordinal, typeof(bool)).GetByteAsBool(ordinal);
+
+    /// <inheritdoc/>
+    public override byte GetByte(int ordinal) => Metadata.Columns[ordinal] switch
+    {
+        var c when c.Type.IsAnyInt() => unchecked((byte)GetReader().GetByte(ordinal)),
+        var c => throw GetInvalidColumnTypeException(typeof(byte), c)
+    };
+
+    /// <inheritdoc/>
+    public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int bufferOffset, int length)
+    {
+        if (dataOffset is < 0 or > int.MaxValue)
+        {
+            throw new ArgumentOutOfRangeException(
+                nameof(dataOffset),
+                dataOffset,
+                $"{nameof(dataOffset)} must be between {0} and {int.MaxValue}");
+        }
+
+        if (buffer != null && (bufferOffset < 0 || bufferOffset >= buffer.Length + 1))
+        {
+            throw new ArgumentOutOfRangeException($"{nameof(bufferOffset)} must be between {0} and {(buffer.Length)}");
+        }
+
+        if (buffer != null && (length < 0 || length > buffer.Length - bufferOffset))
+        {
+            throw new ArgumentOutOfRangeException($"{nameof(length)} must be between {0} and {buffer.Length - bufferOffset}");
+        }
+
+        var span = GetReader(ordinal, typeof(byte[])).GetBytesSpan(ordinal);
+
+        if (buffer == null)
+        {
+            return span.Length;
+        }
+
+        var slice = span.Slice(checked((int)dataOffset), length);
+        slice.CopyTo(buffer);
+
+        return slice.Length;
+    }
+
+    /// <inheritdoc/>
+    public override char GetChar(int ordinal) => throw new NotSupportedException("char data type is not supported");
+
+    /// <inheritdoc/>
+    public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length)
+    {
+        if (dataOffset is < 0 or > int.MaxValue)
+        {
+            throw new ArgumentOutOfRangeException(
+                nameof(dataOffset),
+                dataOffset,
+                $"{nameof(dataOffset)} must be between {0} and {int.MaxValue}");
+        }
+
+        if (buffer != null && (bufferOffset < 0 || bufferOffset >= buffer.Length + 1))
+        {
+            throw new ArgumentOutOfRangeException($"{nameof(bufferOffset)} must be between {0} and {(buffer.Length)}");
+        }
+
+        if (buffer != null && (length < 0 || length > buffer.Length - bufferOffset))
+        {
+            throw new ArgumentOutOfRangeException($"{nameof(length)} must be between {0} and {buffer.Length - bufferOffset}");
+        }
+
+        var span = GetReader(ordinal, typeof(string)).GetBytesSpan(ordinal);
+
+        if (buffer == null)
+        {
+            return ProtoCommon.StringEncoding.GetCharCount(span);
+        }
+
+        return ProtoCommon.StringEncoding.GetChars(
+            span.Slice(checked((int)dataOffset)),
+            buffer.AsSpan().Slice(bufferOffset, length));
+    }
+
+    /// <inheritdoc/>
+    public override string GetDataTypeName(int ordinal) => Metadata.Columns[ordinal].Type.ToSqlTypeName();
+
+    /// <inheritdoc/>
+    public override DateTime GetDateTime(int ordinal)
+    {
+        var column = Metadata.Columns[ordinal];
+
+        return column.Type switch
+        {
+            SqlColumnType.Date => GetReader().GetDate(ordinal).ToDateTimeUnspecified(),
+            SqlColumnType.Datetime => GetReader().GetDateTime(ordinal).ToDateTimeUnspecified(),
+            SqlColumnType.Timestamp => GetReader().GetTimestamp(ordinal).ToDateTimeUtc(),
+            _ => throw GetInvalidColumnTypeException(typeof(DateTime), column)
+        };
+    }
+
+    /// <inheritdoc/>
+    public override decimal GetDecimal(int ordinal)
+    {
+        var column = Metadata.Columns[ordinal];
+
+        ValidateColumnType(typeof(decimal), column);
+
+        return GetReader().GetDecimal(ordinal, column.Scale);
+    }
+
+    /// <inheritdoc/>
+    public override double GetDouble(int ordinal) => Metadata.Columns[ordinal] switch
+    {
+        var c when c.Type.IsAnyFloat() => GetReader().GetDouble(ordinal),
+        var c => throw GetInvalidColumnTypeException(typeof(double), c)
+    };
+
+    /// <inheritdoc/>
+    public override float GetFloat(int ordinal) => Metadata.Columns[ordinal] switch
+    {
+        var c when c.Type.IsAnyFloat() => GetReader().GetFloat(ordinal),
+        var c => throw GetInvalidColumnTypeException(typeof(float), c)
+    };
+
+    /// <inheritdoc/>
+    public override Guid GetGuid(int ordinal) => GetReader(ordinal, typeof(Guid)).GetGuid(ordinal);
+
+    /// <inheritdoc/>
+    public override short GetInt16(int ordinal) => Metadata.Columns[ordinal] switch
+    {
+        var c when c.Type.IsAnyInt() => GetReader().GetShort(ordinal),
+        var c => throw GetInvalidColumnTypeException(typeof(short), c)
+    };
+
+    /// <inheritdoc/>
+    public override int GetInt32(int ordinal) => Metadata.Columns[ordinal] switch
+    {
+        var c when c.Type.IsAnyInt() => GetReader().GetInt(ordinal),
+        var c => throw GetInvalidColumnTypeException(typeof(int), c)
+    };
+
+    /// <inheritdoc/>
+    public override long GetInt64(int ordinal) => Metadata.Columns[ordinal] switch
+    {
+        var c when c.Type.IsAnyInt() => GetReader().GetLong(ordinal),
+        var c => throw GetInvalidColumnTypeException(typeof(long), c)
+    };
+
+    /// <inheritdoc/>
+    public override string GetName(int ordinal) => Metadata.Columns[ordinal].Name;
+
+    /// <inheritdoc/>
+    public override int GetOrdinal(string name) => Metadata.IndexOf(name);
+
+    /// <inheritdoc/>
+    public override string GetString(int ordinal) => GetReader(ordinal, typeof(string)).GetString(ordinal);
+
+    /// <inheritdoc/>
+    public override object GetValue(int ordinal)
+    {
+        var reader = GetReader();
+
+        return Sql.ReadColumnValue(ref reader, Metadata.Columns[ordinal], ordinal)!;
+    }
+
+    /// <inheritdoc/>
+    public override int GetValues(object[] values)
+    {
+        IgniteArgumentCheck.NotNull(values, nameof(values));
+
+        var cols = Metadata.Columns;
+        var count = Math.Min(values.Length, cols.Count);
+
+        var reader = GetReader();
+
+        for (int i = 0; i < count; i++)
+        {
+            values[i] = Sql.ReadColumnValue(ref reader, cols[i], i)!;
+        }
+
+        return count;
+    }
+
+    /// <inheritdoc/>
+    public override bool IsDBNull(int ordinal) => GetReader().IsNull(ordinal);
+
+    /// <inheritdoc/>
+    public override bool NextResult() => throw new NotSupportedException("Batched result sets are not supported.");
+
+    /// <inheritdoc/>
+    public override bool Read() => ReadNextRowInCurrentPage() || FetchNextPage().GetAwaiter().GetResult();
+
+    /// <inheritdoc/>
+    public override Task<bool> ReadAsync(CancellationToken cancellationToken) => ReadNextRowInCurrentPage() ? TrueTask : FetchNextPage();
+
+    /// <inheritdoc/>
+    public override IEnumerator GetEnumerator() => new DbEnumerator(this);
+
+    /// <inheritdoc/>
+    public ReadOnlyCollection<DbColumn> GetColumnSchema()
+    {
+        if (_schema == null)
+        {
+            var schema = new List<DbColumn>(FieldCount);
+
+            for (var i = 0; i < Metadata.Columns.Count; i++)
+            {
+                schema.Add(new IgniteDbColumn(Metadata.Columns[i], i));
+            }
+
+            _schema = schema.AsReadOnly();
+        }
+
+        return _schema;
+    }
+
+    /// <inheritdoc/>
+    public override DataTable GetSchemaTable()
+    {
+        var table = new DataTable("SchemaTable");
+
+        table.Columns.Add(SchemaTableColumn.ColumnName, typeof(string));
+        table.Columns.Add(SchemaTableColumn.ColumnOrdinal, typeof(int));
+        table.Columns.Add(SchemaTableColumn.ColumnSize, typeof(int));
+        table.Columns.Add(SchemaTableColumn.NumericPrecision, typeof(int));
+        table.Columns.Add(SchemaTableColumn.NumericScale, typeof(int));
+        table.Columns.Add(SchemaTableColumn.IsUnique, typeof(bool));
+        table.Columns.Add(SchemaTableColumn.IsKey, typeof(bool));
+        table.Columns.Add(SchemaTableColumn.BaseColumnName, typeof(string));
+        table.Columns.Add(SchemaTableColumn.BaseSchemaName, typeof(string));
+        table.Columns.Add(SchemaTableColumn.BaseTableName, typeof(string));
+        table.Columns.Add(SchemaTableColumn.DataType, typeof(Type));
+        table.Columns.Add(SchemaTableColumn.AllowDBNull, typeof(bool));
+        table.Columns.Add(SchemaTableColumn.ProviderType, typeof(int));
+        table.Columns.Add(SchemaTableColumn.IsAliased, typeof(bool));
+        table.Columns.Add(SchemaTableColumn.IsExpression, typeof(bool));
+        table.Columns.Add(SchemaTableColumn.IsLong, typeof(bool));
+
+        foreach (var column in GetColumnSchema())
+        {
+            var row = table.NewRow();
+
+            row[SchemaTableColumn.ColumnName] = column.ColumnName;
+            row[SchemaTableColumn.ColumnOrdinal] = column.ColumnOrdinal ?? -1;
+            row[SchemaTableColumn.ColumnSize] = column.ColumnSize ?? -1;
+            row[SchemaTableColumn.NumericPrecision] = column.NumericPrecision ?? 0;
+            row[SchemaTableColumn.NumericScale] = column.NumericScale ?? 0;
+            row[SchemaTableColumn.IsUnique] = column.IsUnique == true;
+            row[SchemaTableColumn.IsKey] = column.IsKey == true;
+            row[SchemaTableColumn.BaseColumnName] = column.BaseColumnName;
+            row[SchemaTableColumn.BaseSchemaName] = column.BaseSchemaName;
+            row[SchemaTableColumn.BaseTableName] = column.BaseTableName;
+            row[SchemaTableColumn.DataType] = column.DataType;
+            row[SchemaTableColumn.AllowDBNull] = column.AllowDBNull == null ? DBNull.Value : column.AllowDBNull.Value;
+            row[SchemaTableColumn.ProviderType] = (int)((IgniteDbColumn)column).ColumnMetadata.Type;
+            row[SchemaTableColumn.IsAliased] = column.IsAliased == true;
+            row[SchemaTableColumn.IsExpression] = column.IsExpression == true;
+            row[SchemaTableColumn.IsLong] = column.IsLong == true;
+
+            table.Rows.Add(row);
+        }
+
+        return table;
+    }
+
+    /// <inheritdoc/>
+    public override async ValueTask DisposeAsync()
+    {
+        await _pageEnumerator.DisposeAsync().ConfigureAwait(false);
+        await _resultSet.DisposeAsync().ConfigureAwait(false);
+    }
+
+    /// <inheritdoc/>
+    public override void Close() => Dispose();
+
+    /// <inheritdoc/>
+    public override Task CloseAsync() => DisposeAsync().AsTask();
+
+    /// <inheritdoc/>
+    public override T GetFieldValue<T>(int ordinal)
+    {
+        if (typeof(T) == typeof(string))
+        {
+            return (T)(object)GetString(ordinal);
+        }
+
+        if (typeof(T) == typeof(int))
+        {
+            return (T)(object)GetInt32(ordinal);
+        }
+
+        if (typeof(T) == typeof(long))
+        {
+            return (T)(object)GetInt64(ordinal);
+        }
+
+        if (typeof(T) == typeof(short))
+        {
+            return (T)(object)GetInt16(ordinal);
+        }
+
+        if (typeof(T) == typeof(float))
+        {
+            return (T)(object)GetFloat(ordinal);
+        }
+
+        if (typeof(T) == typeof(double))
+        {
+            return (T)(object)GetDouble(ordinal);
+        }
+
+        if (typeof(T) == typeof(decimal))
+        {
+            return (T)(object)GetDecimal(ordinal);
+        }
+
+        if (typeof(T) == typeof(byte))
+        {
+            return (T)(object)GetByte(ordinal);
+        }
+
+        if (typeof(T) == typeof(byte[]))
+        {
+            return (T)(object)GetReader(ordinal, typeof(byte[])).GetBytes(ordinal);
+        }
+
+        if (typeof(T) == typeof(LocalTime))
+        {
+            return (T)(object)GetReader(ordinal, typeof(LocalTime)).GetTime(ordinal);
+        }
+
+        if (typeof(T) == typeof(LocalDate))
+        {
+            return (T)(object)GetReader(ordinal, typeof(LocalDate)).GetDate(ordinal);
+        }
+
+        if (typeof(T) == typeof(LocalDateTime))
+        {
+            return (T)(object)GetReader(ordinal, typeof(LocalDateTime)).GetDateTime(ordinal);
+        }
+
+        if (typeof(T) == typeof(DateTime))
+        {
+            return (T)(object)GetDateTime(ordinal);
+        }
+
+        if (typeof(T) == typeof(Instant))
+        {
+            return (T)(object)GetReader(ordinal, typeof(Instant)).GetTimestamp(ordinal);
+        }
+
+        throw GetInvalidColumnTypeException(typeof(T), Metadata.Columns[ordinal]);
+    }
+
+    /// <inheritdoc/>
+    public override Type GetFieldType(int ordinal) => Metadata.Columns[ordinal].Type.ToClrType();
+
+    /// <inheritdoc/>
+    protected override void Dispose(bool disposing) => DisposeAsync().AsTask().GetAwaiter().GetResult();
+
+    private static void ValidateColumnType(Type type, IColumnMetadata column)
+    {
+        if (column.Type != type.ToSqlColumnType())
+        {
+            throw GetInvalidColumnTypeException(type, column);
+        }
+    }
+
+    private static InvalidCastException GetInvalidColumnTypeException(Type type, IColumnMetadata column) =>
+        new($"Column {column.Name} of type {column.Type.ToSqlTypeName()} ({column.Type.ToClrType()}) can not be cast to {type}.");
+
+    private BinaryTupleReader GetReader(int ordinal, Type type)
+    {
+        var column = Metadata.Columns[ordinal];
+
+        ValidateColumnType(type, column);
+
+        return GetReader();
+    }
+
+    private BinaryTupleReader GetReader()
+    {
+        if (_pageRowCount < 0)
+        {
+            throw new InvalidOperationException(
+                $"No data exists for the row/column. Reading has not started. Call {nameof(ReadAsync)} or {nameof(Read)}.");
+        }
+
+        var reader = _pageEnumerator.Current.GetReader(_pageRowOffset);
+        var tupleSpan = reader.ReadBinary();
+
+        return new BinaryTupleReader(tupleSpan, FieldCount);
+    }
+
+    private bool ReadNextRowInCurrentPage()
+    {
+        if (_pageRowCount <= 0 || _pageRowIndex >= _pageRowCount - 1)
+        {
+            return false;
+        }
+
+        _pageRowIndex++;
+        _pageRowOffset += _pageRowSize;
+
+        var reader = _pageEnumerator.Current.GetReader(_pageRowOffset);
+        _pageRowSize = reader.ReadBinaryHeader() + reader.Consumed;
+
+        return true;
+    }
+
+    private async Task<bool> FetchNextPage()
+    {
+        if (!await _pageEnumerator.MoveNextAsync().ConfigureAwait(false))
+        {
+            return false;
+        }
+
+        ReadFirstRowInCurrentPage();
+
+        return true;
+
+        void ReadFirstRowInCurrentPage()
+        {
+            var reader = _pageEnumerator.Current.GetReader();
+
+            _pageRowCount = reader.ReadArrayHeader();
+            _pageRowOffset = reader.Consumed;
+            _pageRowSize = reader.ReadBinaryHeader() + reader.Consumed - _pageRowOffset;
+            _pageRowIndex = 0;
+        }
+    }
+}
diff --git a/modules/platforms/dotnet/DEVNOTES.md b/modules/platforms/dotnet/DEVNOTES.md
index 5928d00a8a..ddd5d9a7b6 100644
--- a/modules/platforms/dotnet/DEVNOTES.md
+++ b/modules/platforms/dotnet/DEVNOTES.md
@@ -14,14 +14,18 @@ In this dir: `dotnet build`
 
 ## Run Tests
 In this dir: `dotnet test --logger "console;verbosity=normal"`
+
 Specific test: `dotnet test --logger "console;verbosity=normal" --filter ClientSocketTests`
 
 ## Start a Test Node
-* `gradlew :ignite-runner:runnerPlatformTest --no-daemon`
+`gradlew :ignite-runner:runnerPlatformTest --no-daemon`
 
 To debug or profile Java side of the tests, run `org.apache.ignite.internal.runner.app.PlatformTestNodeRunner` class in IDEA with a debugger or profiler,
 then run .NET tests with `dotnet test` or `dotnet test --filter TEST_NAME`. When a server node is present, .NET tests will use it instead of starting a new one.
 
+The test node will stop after 30 minutes by default.
+To change this, set `IGNITE_PLATFORM_TEST_NODE_RUNNER_RUN_TIME_MINUTES` environment variable.
+
 ## Static Code Analysis
 
 Static code analysis (Roslyn-based) runs as part of the build and includes code style check. Build fails on any warning.