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

[ignite-3] branch main updated: IGNITE-16226 .NET: Add KeyValueBinaryView (#1212)

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 5ff6f1ecb9 IGNITE-16226 .NET: Add KeyValueBinaryView (#1212)
5ff6f1ecb9 is described below

commit 5ff6f1ecb981b379f591b922759c9676e1e6f38a
Author: Pavel Tupitsyn <pt...@apache.org>
AuthorDate: Mon Oct 17 10:34:15 2022 +0300

    IGNITE-16226 .NET: Add KeyValueBinaryView (#1212)
    
    Added `IKeyValueView<IIgniteTuple, IIgniteTuple> KeyValueBinaryView` to `ITable`.
    
    KV view is just a different representation of the `RecordView`, where every record is split into two parts.
    To reuse `RecordView` code, we wrap key and value into `KvPair` at the API entry point, and unwrap back only at the serializer level.
---
 .../dotnet/Apache.Ignite.Tests/IgniteTestsBase.cs  |   9 +-
 .../dotnet/Apache.Ignite.Tests/OptionTests.cs      |   4 +-
 .../Table/KeyValueViewBinaryTests.cs               | 291 +++++++++++++++++++++
 .../Table/RecordViewBinaryTests.cs                 |   2 +-
 .../Table/RecordViewDefaultMappingTest.cs          |   3 +-
 .../Table/RecordViewPrimitiveTests.cs              |   4 +-
 .../dotnet/Apache.Ignite/ClientOperationType.cs    |   5 +
 .../dotnet/Apache.Ignite/Compute/ICompute.cs       |   3 +-
 .../Apache.Ignite/Internal/Compute/Compute.cs      |   4 +-
 .../Apache.Ignite/Internal/Proto/ClientOp.cs       |   3 +
 .../Internal/Proto/ClientOpExtensions.cs           |   1 +
 .../Apache.Ignite/Internal/Table/KeyValueView.cs   | 166 ++++++++++++
 .../Apache.Ignite/Internal/Table/RecordView.cs     | 141 +++++++---
 .../dotnet/Apache.Ignite/Internal/Table/Schema.cs  |   8 +-
 .../Table/{Schema.cs => Serialization/KvPair.cs}   |  28 +-
 .../Table/Serialization/ObjectSerializerHandler.cs |   2 +-
 .../Table/Serialization/RecordSerializer.cs        |  40 ++-
 .../Serialization/TuplePairSerializerHandler.cs    | 114 ++++++++
 .../Table/Serialization/TupleSerializerHandler.cs  |   2 +-
 .../dotnet/Apache.Ignite/Internal/Table/Table.cs   |  16 +-
 modules/platforms/dotnet/Apache.Ignite/Option.cs   |  21 +-
 .../dotnet/Apache.Ignite/RetryReadPolicy.cs        |   1 +
 .../dotnet/Apache.Ignite/Table/IKeyValueView.cs    | 203 ++++++++++++++
 .../dotnet/Apache.Ignite/Table/IRecordView.cs      |   9 +-
 .../platforms/dotnet/Apache.Ignite/Table/ITable.cs |   8 +-
 25 files changed, 1002 insertions(+), 86 deletions(-)

diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/IgniteTestsBase.cs b/modules/platforms/dotnet/Apache.Ignite.Tests/IgniteTestsBase.cs
index 1ee92bc602..5e4f997359 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/IgniteTestsBase.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/IgniteTestsBase.cs
@@ -58,6 +58,8 @@ namespace Apache.Ignite.Tests
 
         protected IRecordView<IIgniteTuple> TupleView { get; private set; } = null!;
 
+        protected IKeyValueView<IIgniteTuple, IIgniteTuple> KvView => Table.KeyValueBinaryView;
+
         protected IRecordView<Poco> PocoView { get; private set; } = null!;
 
         [OneTimeSetUp]
@@ -89,8 +91,11 @@ namespace Apache.Ignite.Tests
             Assert.AreEqual(_eventListener.BuffersReturned, _eventListener.BuffersRented);
         }
 
-        protected static IIgniteTuple GetTuple(long id, string? val = null) =>
-            new IgniteTuple { [KeyCol] = id, [ValCol] = val };
+        protected static IIgniteTuple GetTuple(long id) => new IgniteTuple { [KeyCol] = id };
+
+        protected static IIgniteTuple GetTuple(long id, string? val) => new IgniteTuple { [KeyCol] = id, [ValCol] = val };
+
+        protected static IIgniteTuple GetTuple(string? val) => new IgniteTuple { [ValCol] = val };
 
         protected static Poco GetPoco(long id, string? val = null) => new() {Key = id, Val = val};
 
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/OptionTests.cs b/modules/platforms/dotnet/Apache.Ignite.Tests/OptionTests.cs
index 955b8f8414..14b9c1d458 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/OptionTests.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/OptionTests.cs
@@ -49,7 +49,7 @@ public sealed class OptionTests
     [Test]
     public void TestEquality()
     {
-        Assert.AreEqual(Option.Some(123), (Option<int>)123);
+        Assert.AreEqual(Option.Some(123), Option.Some(123));
         Assert.AreNotEqual(Option.Some(123), Option.Some(124));
     }
 
@@ -102,4 +102,4 @@ public sealed class OptionTests
         Assert.AreEqual("Option { HasValue = True, Value = 123 }", Option.Some(123).ToString());
         Assert.AreEqual("Option { HasValue = True, Value = Foo }", Option.Some("Foo").ToString());
     }
-}
\ No newline at end of file
+}
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/KeyValueViewBinaryTests.cs b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/KeyValueViewBinaryTests.cs
new file mode 100644
index 0000000000..de36205632
--- /dev/null
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/KeyValueViewBinaryTests.cs
@@ -0,0 +1,291 @@
+/*
+ * 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.Table;
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Ignite.Table;
+using NUnit.Framework;
+
+/// <summary>
+/// Tests for key-value tuple view.
+/// </summary>
+public class KeyValueViewBinaryTests : IgniteTestsBase
+{
+    [TearDown]
+    public async Task CleanTable()
+    {
+        await TupleView.DeleteAllAsync(null, Enumerable.Range(-1, 12).Select(x => GetTuple(x)));
+    }
+
+    [Test]
+    public async Task TestPutGet()
+    {
+        await KvView.PutAsync(null, GetTuple(1L), GetTuple("val"));
+
+        (IIgniteTuple res, _) = await KvView.GetAsync(null, GetTuple(1L));
+
+        Assert.AreEqual("val", res[0]);
+        Assert.AreEqual("val", res[ValCol]);
+    }
+
+    [Test]
+    public async Task TestGetNonExistentKeyReturnsEmptyOption()
+    {
+        (IIgniteTuple res, bool hasRes) = await KvView.GetAsync(null, GetTuple(-111L));
+
+        Assert.IsFalse(hasRes);
+        Assert.IsNull(res);
+    }
+
+    [Test]
+    public async Task TestGetAll()
+    {
+        await KvView.PutAsync(null, GetTuple(7L), GetTuple("val1"));
+        await KvView.PutAsync(null, GetTuple(8L), GetTuple("val2"));
+
+        IDictionary<IIgniteTuple, IIgniteTuple> res = await KvView.GetAllAsync(null, Enumerable.Range(-1, 100).Select(x => GetTuple(x)).ToList());
+        IDictionary<IIgniteTuple, IIgniteTuple> resEmpty = await KvView.GetAllAsync(null, Array.Empty<IIgniteTuple>());
+
+        Assert.AreEqual(2, res.Count);
+        Assert.AreEqual("val1", res[GetTuple(7L)][0]);
+        Assert.AreEqual("val2", res[GetTuple(8L)][0]);
+
+        Assert.AreEqual(0, resEmpty.Count);
+    }
+
+    [Test]
+    public void TestGetAllWithNullKeyThrowsArgumentException()
+    {
+        var ex = Assert.ThrowsAsync<ArgumentNullException>(async () =>
+            await KvView.GetAllAsync(null, new[] { GetTuple(1L), null! }));
+
+        Assert.AreEqual("Value cannot be null. (Parameter 'key')", ex!.Message);
+    }
+
+    [Test]
+    public void TestPutNullThrowsArgumentException()
+    {
+        var keyEx = Assert.ThrowsAsync<ArgumentNullException>(async () => await KvView.PutAsync(null, null!, null!));
+        Assert.AreEqual("Value cannot be null. (Parameter 'key')", keyEx!.Message);
+
+        var valEx = Assert.ThrowsAsync<ArgumentNullException>(async () => await KvView.PutAsync(null, GetTuple(1L), null!));
+        Assert.AreEqual("Value cannot be null. (Parameter 'val')", valEx!.Message);
+    }
+
+    [Test]
+    public async Task TestContains()
+    {
+        await KvView.PutAsync(null, GetTuple(7L), GetTuple("val1"));
+
+        bool res1 = await KvView.ContainsAsync(null, GetTuple(7L));
+        bool res2 = await KvView.ContainsAsync(null, GetTuple(8L));
+
+        Assert.IsTrue(res1);
+        Assert.IsFalse(res2);
+    }
+
+    [Test]
+    public async Task TestPutAll()
+    {
+        await KvView.PutAllAsync(null, new Dictionary<IIgniteTuple, IIgniteTuple>());
+        await KvView.PutAllAsync(
+            null,
+            Enumerable.Range(-1, 7).Select(x => new KeyValuePair<IIgniteTuple, IIgniteTuple>(GetTuple(x), GetTuple("v" + x))));
+
+        IDictionary<IIgniteTuple, IIgniteTuple> res = await KvView.GetAllAsync(null, Enumerable.Range(-10, 20).Select(x => GetTuple(x)));
+
+        Assert.AreEqual(7, res.Count);
+
+        for (int i = -1; i < 6; i++)
+        {
+            IIgniteTuple val = res[GetTuple(i)];
+            Assert.AreEqual("v" + i, val[ValCol]);
+        }
+    }
+
+    [Test]
+    public async Task TestGetAndPut()
+    {
+        Option<IIgniteTuple> res1 = await KvView.GetAndPutAsync(null, GetTuple(1), GetTuple("1"));
+        Option<IIgniteTuple> res2 = await KvView.GetAndPutAsync(null, GetTuple(1), GetTuple("2"));
+        Option<IIgniteTuple> res3 = await KvView.GetAsync(null, GetTuple(1));
+
+        Assert.IsFalse(res1.HasValue);
+        Assert.IsTrue(res2.HasValue);
+        Assert.IsTrue(res3.HasValue);
+
+        Assert.AreEqual("1", res2.Value[0]);
+        Assert.AreEqual("2", res3.Value[0]);
+    }
+
+    [Test]
+    public async Task TestPutIfAbsent()
+    {
+        await KvView.PutAsync(null, GetTuple(1), GetTuple("1"));
+
+        bool res1 = await KvView.PutIfAbsentAsync(null, GetTuple(1), GetTuple("11"));
+        Option<IIgniteTuple> res2 = await KvView.GetAsync(null, GetTuple(1));
+
+        bool res3 = await KvView.PutIfAbsentAsync(null, GetTuple(2), GetTuple("2"));
+        Option<IIgniteTuple> res4 = await KvView.GetAsync(null, GetTuple(2));
+
+        Assert.IsFalse(res1);
+        Assert.AreEqual("1", res2.Value[0]);
+
+        Assert.IsTrue(res3);
+        Assert.AreEqual("2", res4.Value[0]);
+    }
+
+    [Test]
+    public async Task TestRemove()
+    {
+        await KvView.PutAsync(null, GetTuple(1), GetTuple("1"));
+
+        bool res1 = await KvView.RemoveAsync(null, GetTuple(1));
+        bool res2 = await KvView.RemoveAsync(null, GetTuple(2));
+        bool res3 = await KvView.ContainsAsync(null, GetTuple(1));
+
+        Assert.IsTrue(res1);
+        Assert.IsFalse(res2);
+        Assert.IsFalse(res3);
+    }
+
+    [Test]
+    public async Task TestRemoveExact()
+    {
+        await KvView.PutAsync(null, GetTuple(1), GetTuple("1"));
+
+        bool res1 = await KvView.RemoveAsync(null, GetTuple(1), GetTuple("111"));
+        bool res2 = await KvView.RemoveAsync(null, GetTuple(1), GetTuple("1"));
+        bool res3 = await KvView.ContainsAsync(null, GetTuple(1));
+
+        Assert.IsFalse(res1);
+        Assert.IsTrue(res2);
+        Assert.IsFalse(res3);
+    }
+
+    [Test]
+    public async Task TestRemoveAll()
+    {
+        await KvView.PutAsync(null, GetTuple(1), GetTuple("1"));
+
+        IList<IIgniteTuple> res1 = await KvView.RemoveAllAsync(null, Enumerable.Range(-1, 8).Select(x => GetTuple(x, "foo")));
+        bool res2 = await KvView.ContainsAsync(null, GetTuple(1));
+
+        Assert.AreEqual(new[] { -1, 0, 2, 3, 4, 5, 6 }, res1.Select(x => x[0]).OrderBy(x => x));
+        Assert.IsFalse(res2);
+    }
+
+    [Test]
+    public async Task TestRemoveAllExact()
+    {
+        await KvView.PutAsync(null, GetTuple(1), GetTuple("1"));
+
+        IList<IIgniteTuple> res1 = await KvView.RemoveAllAsync(
+            null,
+            Enumerable.Range(-1, 8).Select(x => new KeyValuePair<IIgniteTuple, IIgniteTuple>(GetTuple(x), GetTuple(x.ToString()))));
+
+        bool res2 = await KvView.ContainsAsync(null, GetTuple(1));
+
+        Assert.AreEqual(new[] { -1, 0, 2, 3, 4, 5, 6 }, res1.Select(x => x[0]).OrderBy(x => x));
+        Assert.IsFalse(res2);
+    }
+
+    [Test]
+    public async Task TestGetAndRemove()
+    {
+        await KvView.PutAsync(null, GetTuple(1), GetTuple("1"));
+
+        (IIgniteTuple val1, bool hasVal1) = await KvView.GetAndRemoveAsync(null, GetTuple(1));
+        (IIgniteTuple val2, bool hasVal2) = await KvView.GetAndRemoveAsync(null, GetTuple(1));
+
+        Assert.IsTrue(hasVal1);
+        Assert.AreEqual("1", val1[0]);
+
+        Assert.IsFalse(hasVal2);
+        Assert.IsNull(val2);
+    }
+
+    [Test]
+    public async Task TestReplace()
+    {
+        await KvView.PutAsync(null, GetTuple(1), GetTuple("1"));
+
+        bool res1 = await KvView.ReplaceAsync(null, GetTuple(0), GetTuple("00"));
+        Option<IIgniteTuple> res2 = await KvView.GetAsync(null, GetTuple(0));
+
+        bool res3 = await KvView.ReplaceAsync(null, GetTuple(1), GetTuple("11"));
+        Option<IIgniteTuple> res4 = await KvView.GetAsync(null, GetTuple(1));
+
+        Assert.IsFalse(res1);
+        Assert.IsFalse(res2.HasValue);
+
+        Assert.IsTrue(res3);
+        Assert.IsTrue(res4.HasValue);
+        Assert.AreEqual("11", res4.Value[0]);
+    }
+
+    [Test]
+    public async Task TestReplaceExact()
+    {
+        await KvView.PutAsync(null, GetTuple(1), GetTuple("1"));
+
+        bool res1 = await KvView.ReplaceAsync(transaction: null, key: GetTuple(0), oldVal: GetTuple("0"), newVal: GetTuple("00"));
+        Option<IIgniteTuple> res2 = await KvView.GetAsync(null, GetTuple(0));
+
+        bool res3 = await KvView.ReplaceAsync(transaction: null, key: GetTuple(1), oldVal: GetTuple("1"), newVal: GetTuple("11"));
+        Option<IIgniteTuple> res4 = await KvView.GetAsync(null, GetTuple(1));
+
+        bool res5 = await KvView.ReplaceAsync(transaction: null, key: GetTuple(2), oldVal: GetTuple("1"), newVal: GetTuple("22"));
+        Option<IIgniteTuple> res6 = await KvView.GetAsync(null, GetTuple(1));
+
+        Assert.IsFalse(res1);
+        Assert.IsFalse(res2.HasValue);
+
+        Assert.IsTrue(res3);
+        Assert.IsTrue(res4.HasValue);
+        Assert.AreEqual("11", res4.Value[0]);
+
+        Assert.IsFalse(res5);
+        Assert.AreEqual("11", res6.Value[0]);
+    }
+
+    [Test]
+    public async Task TestGetAndReplace()
+    {
+        await KvView.PutAsync(null, GetTuple(1), GetTuple("1"));
+
+        Option<IIgniteTuple> res1 = await KvView.GetAndReplaceAsync(null, GetTuple(0), GetTuple("00"));
+        Option<IIgniteTuple> res2 = await KvView.GetAsync(null, GetTuple(0));
+
+        Option<IIgniteTuple> res3 = await KvView.GetAndReplaceAsync(null, GetTuple(1), GetTuple("11"));
+        Option<IIgniteTuple> res4 = await KvView.GetAsync(null, GetTuple(1));
+
+        Assert.IsFalse(res1.HasValue);
+        Assert.IsFalse(res2.HasValue);
+
+        Assert.IsTrue(res3.HasValue);
+        Assert.AreEqual("1", res3.Value[0]);
+
+        Assert.IsTrue(res4.HasValue);
+        Assert.AreEqual("11", res4.Value[0]);
+    }
+}
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewBinaryTests.cs b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewBinaryTests.cs
index 5264aa42ff..553ca549be 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewBinaryTests.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewBinaryTests.cs
@@ -35,7 +35,7 @@ namespace Apache.Ignite.Tests.Table
         [TearDown]
         public async Task CleanTable()
         {
-            await TupleView.DeleteAllAsync(null, Enumerable.Range(-1, 12).Select(x => GetTuple(x)));
+            await TupleView.DeleteAllAsync(null, Enumerable.Range(-1, 50).Select(x => GetTuple(x)));
         }
 
         [Test]
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewDefaultMappingTest.cs b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewDefaultMappingTest.cs
index 639584283d..39eb7dd43a 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewDefaultMappingTest.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewDefaultMappingTest.cs
@@ -74,7 +74,8 @@ namespace Apache.Ignite.Tests.Table
             Assert.AreEqual("2", res.Val);
         }
 
-        private T Get<T>(T key) => Table.GetRecordView<T>().GetAsync(null, key).GetAwaiter().GetResult().Value;
+        private T Get<T>(T key)
+            where T : notnull => Table.GetRecordView<T>().GetAsync(null, key).GetAwaiter().GetResult().Value;
 
         private class FieldsTest
         {
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewPrimitiveTests.cs b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewPrimitiveTests.cs
index 97bfb330ef..ea7fe988e3 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewPrimitiveTests.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewPrimitiveTests.cs
@@ -67,6 +67,7 @@ public class RecordViewPrimitiveTests : IgniteTestsBase
     }
 
     private static async Task TestKey<T>(T val, IRecordView<T> recordView)
+        where T : notnull
     {
         // Tests EmitWriter.
         await recordView.UpsertAsync(null, val);
@@ -82,6 +83,7 @@ public class RecordViewPrimitiveTests : IgniteTestsBase
     }
 
     private async Task TestKey<T>(T val, string tableName)
+        where T : notnull
     {
         var table = await Client.Tables.GetTableAsync(tableName);
 
@@ -89,4 +91,4 @@ public class RecordViewPrimitiveTests : IgniteTestsBase
 
         await TestKey(val, table!.GetRecordView<T>());
     }
-}
\ No newline at end of file
+}
diff --git a/modules/platforms/dotnet/Apache.Ignite/ClientOperationType.cs b/modules/platforms/dotnet/Apache.Ignite/ClientOperationType.cs
index fffd72639a..4d544cf5c9 100644
--- a/modules/platforms/dotnet/Apache.Ignite/ClientOperationType.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/ClientOperationType.cs
@@ -111,6 +111,11 @@ namespace Apache.Ignite
         /// </summary>
         TupleGetAndDelete,
 
+        /// <summary>
+        /// Contains key (<see cref="IKeyValueView{TK,TV}.ContainsAsync"/>).
+        /// </summary>
+        TupleContainsKey,
+
         /// <summary>
         /// Compute (<see cref="ICompute.ExecuteAsync{T}"/>, <see cref="ICompute.BroadcastAsync{T}"/>).
         /// </summary>
diff --git a/modules/platforms/dotnet/Apache.Ignite/Compute/ICompute.cs b/modules/platforms/dotnet/Apache.Ignite/Compute/ICompute.cs
index 7ab817b05f..4e37892e59 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Compute/ICompute.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Compute/ICompute.cs
@@ -58,7 +58,8 @@ namespace Apache.Ignite.Compute
         /// <typeparam name="T">Job result type.</typeparam>
         /// <typeparam name="TKey">Key type.</typeparam>
         /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
-        Task<T> ExecuteColocatedAsync<T, TKey>(string tableName, TKey key, string jobClassName, params object[] args);
+        Task<T> ExecuteColocatedAsync<T, TKey>(string tableName, TKey key, string jobClassName, params object[] args)
+            where TKey : notnull;
 
         /// <summary>
         /// Executes a compute job represented by the given class on all of the specified nodes.
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Compute/Compute.cs b/modules/platforms/dotnet/Apache.Ignite/Internal/Compute/Compute.cs
index 24b0ada1d7..b3dc4a8b0f 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Compute/Compute.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Compute/Compute.cs
@@ -76,7 +76,8 @@ namespace Apache.Ignite.Internal.Compute
                 .ConfigureAwait(false);
 
         /// <inheritdoc/>
-        public async Task<T> ExecuteColocatedAsync<T, TKey>(string tableName, TKey key, string jobClassName, params object[] args) =>
+        public async Task<T> ExecuteColocatedAsync<T, TKey>(string tableName, TKey key, string jobClassName, params object[] args)
+            where TKey : notnull =>
             await ExecuteColocatedAsync<T, TKey>(
                     tableName,
                     key,
@@ -198,6 +199,7 @@ namespace Apache.Ignite.Internal.Compute
             Func<Table, IRecordSerializerHandler<TKey>> serializerHandlerFunc,
             string jobClassName,
             params object[] args)
+            where TKey : notnull
         {
             // TODO: IGNITE-16990 - implement partition awareness.
             IgniteArgumentCheck.NotNull(tableName, nameof(tableName));
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Proto/ClientOp.cs b/modules/platforms/dotnet/Apache.Ignite/Internal/Proto/ClientOp.cs
index bc79c32a90..86f85c6342 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Proto/ClientOp.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Proto/ClientOp.cs
@@ -79,6 +79,9 @@ namespace Apache.Ignite.Internal.Proto
         /** Get and delete tuple. */
         TupleGetAndDelete = 32,
 
+        /** Contains tuple. */
+        TupleContainsKey = 33,
+
         /** Begin transaction. */
         TxBegin = 43,
 
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Proto/ClientOpExtensions.cs b/modules/platforms/dotnet/Apache.Ignite/Internal/Proto/ClientOpExtensions.cs
index 95120369d5..a5e8961a1f 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Proto/ClientOpExtensions.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Proto/ClientOpExtensions.cs
@@ -51,6 +51,7 @@ namespace Apache.Ignite.Internal.Proto
                 ClientOp.TupleDeleteExact => ClientOperationType.TupleDeleteExact,
                 ClientOp.TupleDeleteAllExact => ClientOperationType.TupleDeleteAllExact,
                 ClientOp.TupleGetAndDelete => ClientOperationType.TupleGetAndDelete,
+                ClientOp.TupleContainsKey => ClientOperationType.TupleContainsKey,
                 ClientOp.ComputeExecute => ClientOperationType.ComputeExecute,
                 ClientOp.ComputeExecuteColocated => ClientOperationType.ComputeExecute,
                 ClientOp.SqlExec => ClientOperationType.SqlExecute,
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/KeyValueView.cs b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/KeyValueView.cs
new file mode 100644
index 0000000000..d45fc615d4
--- /dev/null
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/KeyValueView.cs
@@ -0,0 +1,166 @@
+/*
+ * 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.Internal.Table;
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Apache.Ignite.Transactions;
+using Common;
+using Ignite.Table;
+using Serialization;
+
+/// <summary>
+/// Generic key-value view.
+/// </summary>
+/// <typeparam name="TK">Key type.</typeparam>
+/// <typeparam name="TV">Value type.</typeparam>
+internal sealed class KeyValueView<TK, TV> : IKeyValueView<TK, TV>
+    where TK : notnull
+    where TV : notnull
+{
+    /** Record view. */
+    private readonly RecordView<KvPair<TK, TV>> _recordView;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="KeyValueView{TK, TV}"/> class.
+    /// </summary>
+    /// <param name="recordView">Record view.</param>
+    public KeyValueView(RecordView<KvPair<TK, TV>> recordView)
+    {
+        _recordView = recordView;
+    }
+
+    /// <inheritdoc/>
+    public async Task<Option<TV>> GetAsync(ITransaction? transaction, TK key) =>
+        (await _recordView.GetAsync(transaction, ToKv(key))).Select(static x => x.Val);
+
+    /// <inheritdoc/>
+    public async Task<IDictionary<TK, TV>> GetAllAsync(ITransaction? transaction, IEnumerable<TK> keys) =>
+        await _recordView.GetAllAsync(
+            transaction,
+            keys.Select(static k => ToKv(k)),
+            count => new Dictionary<TK, TV>(count),
+            (dict, item) =>
+            {
+                var ((key, val), hasVal) = item;
+
+                if (hasVal)
+                {
+                    dict[key] = val;
+                }
+            });
+
+    /// <inheritdoc/>
+    public async Task<bool> ContainsAsync(ITransaction? transaction, TK key) =>
+        await _recordView.ContainsKey(transaction, ToKv(key));
+
+    /// <inheritdoc/>
+    public async Task PutAsync(ITransaction? transaction, TK key, TV val) =>
+        await _recordView.UpsertAsync(transaction, ToKv(key, val));
+
+    /// <inheritdoc/>
+    public async Task PutAllAsync(ITransaction? transaction, IEnumerable<KeyValuePair<TK, TV>> pairs) =>
+        await _recordView.UpsertAllAsync(transaction, pairs.Select(static x => ToKv(x)));
+
+    /// <inheritdoc/>
+    public async Task<Option<TV>> GetAndPutAsync(ITransaction? transaction, TK key, TV val) =>
+        (await _recordView.GetAndUpsertAsync(transaction, new KvPair<TK, TV>(key, val))).Select(static x => x.Val);
+
+    /// <inheritdoc/>
+    public async Task<bool> PutIfAbsentAsync(ITransaction? transaction, TK key, TV val) =>
+        await _recordView.InsertAsync(transaction, ToKv(key, val));
+
+    /// <inheritdoc/>
+    public async Task<bool> RemoveAsync(ITransaction? transaction, TK key) =>
+        await _recordView.DeleteAsync(transaction, ToKv(key));
+
+    /// <inheritdoc/>
+    public async Task<bool> RemoveAsync(ITransaction? transaction, TK key, TV val) =>
+        await _recordView.DeleteExactAsync(transaction, ToKv(key, val));
+
+    /// <inheritdoc/>
+    public async Task<IList<TK>> RemoveAllAsync(ITransaction? transaction, IEnumerable<TK> keys)
+    {
+        IgniteArgumentCheck.NotNull(keys, nameof(keys));
+
+        return await _recordView.DeleteAllAsync(
+            transaction,
+            keys.Select(static k => ToKv(k)),
+            resultFactory: static count => count == 0
+                ? (IList<TK>)Array.Empty<TK>()
+                : new List<TK>(count),
+            addAction: static (res, item) => res.Add(item.Key),
+            exact: false);
+    }
+
+    /// <inheritdoc/>
+    public async Task<IList<TK>> RemoveAllAsync(ITransaction? transaction, IEnumerable<KeyValuePair<TK, TV>> pairs)
+    {
+        IgniteArgumentCheck.NotNull(pairs, nameof(pairs));
+
+        return await _recordView.DeleteAllAsync(
+            transaction,
+            pairs.Select(static k => ToKv(k)),
+            resultFactory: static count => count == 0
+                ? (IList<TK>)Array.Empty<TK>()
+                : new List<TK>(count),
+            addAction: static (res, item) => res.Add(item.Key),
+            exact: true);
+    }
+
+    /// <inheritdoc/>
+    public async Task<Option<TV>> GetAndRemoveAsync(ITransaction? transaction, TK key) =>
+        (await _recordView.GetAndDeleteAsync(transaction, ToKv(key))).Select(static x => x.Val);
+
+    /// <inheritdoc/>
+    public async Task<bool> ReplaceAsync(ITransaction? transaction, TK key, TV val) =>
+        await _recordView.ReplaceAsync(transaction, ToKv(key, val));
+
+    /// <inheritdoc/>
+    public async Task<bool> ReplaceAsync(ITransaction? transaction, TK key, TV oldVal, TV newVal) =>
+        await _recordView.ReplaceAsync(transaction, ToKv(key, oldVal), ToKv(key, newVal));
+
+    /// <inheritdoc/>
+    public async Task<Option<TV>> GetAndReplaceAsync(ITransaction? transaction, TK key, TV val) =>
+        (await _recordView.GetAndReplaceAsync(transaction, ToKv(key, val))).Select(static x => x.Val);
+
+    private static KvPair<TK, TV> ToKv(KeyValuePair<TK, TV> x)
+    {
+        IgniteArgumentCheck.NotNull(x.Key, "key");
+        IgniteArgumentCheck.NotNull(x.Value, "val");
+
+        return new(x.Key, x.Value);
+    }
+
+    private static KvPair<TK, TV> ToKv(TK k)
+    {
+        IgniteArgumentCheck.NotNull(k, "key");
+
+        return new(k);
+    }
+
+    private static KvPair<TK, TV> ToKv(TK k, TV v)
+    {
+        IgniteArgumentCheck.NotNull(k, "key");
+        IgniteArgumentCheck.NotNull(v, "val");
+
+        return new(k, v);
+    }
+}
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/RecordView.cs b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/RecordView.cs
index f0bd09aab9..6b3b40e9bf 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/RecordView.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/RecordView.cs
@@ -33,6 +33,7 @@ namespace Apache.Ignite.Internal.Table
     /// </summary>
     /// <typeparam name="T">Record type.</typeparam>
     internal sealed class RecordView<T> : IRecordView<T>
+        where T : notnull
     {
         /** Table. */
         private readonly Table _table;
@@ -68,7 +69,34 @@ namespace Apache.Ignite.Internal.Table
         }
 
         /// <inheritdoc/>
-        public async Task<IList<Option<T>>> GetAllAsync(ITransaction? transaction, IEnumerable<T> keys)
+        public async Task<IList<Option<T>>> GetAllAsync(ITransaction? transaction, IEnumerable<T> keys) =>
+            await GetAllAsync(
+                transaction: transaction,
+                keys: keys,
+                resultFactory: static count => count == 0
+                    ? (IList<Option<T>>)Array.Empty<Option<T>>()
+                    : new List<Option<T>>(count),
+                addAction: static (res, item) => res.Add(item));
+
+        /// <summary>
+        /// Gets multiple records by keys.
+        /// </summary>
+        /// <param name="transaction">The transaction or <c>null</c> to auto commit.</param>
+        /// <param name="keys">Collection of records with key columns set.</param>
+        /// <param name="resultFactory">Result factory.</param>
+        /// <param name="addAction">Add action.</param>
+        /// <typeparam name="TRes">Result type.</typeparam>
+        /// <returns>
+        /// A <see cref="Task"/> representing the asynchronous operation.
+        /// The task result contains matching records with all columns filled from the table. The order of collection
+        /// elements is guaranteed to be the same as the order of <paramref name="keys"/>. If a record does not exist,
+        /// the element at the corresponding index of the resulting collection will be empty <see cref="Option{T}"/>.
+        /// </returns>
+        public async Task<TRes> GetAllAsync<TRes>(
+            ITransaction? transaction,
+            IEnumerable<T> keys,
+            Func<int, TRes> resultFactory,
+            Action<TRes, Option<T>> addAction)
         {
             IgniteArgumentCheck.NotNull(keys, nameof(keys));
 
@@ -76,7 +104,7 @@ namespace Apache.Ignite.Internal.Table
 
             if (!iterator.MoveNext())
             {
-                return Array.Empty<Option<T>>();
+                return resultFactory(0);
             }
 
             var schema = await _table.GetLatestSchemaAsync().ConfigureAwait(false);
@@ -89,7 +117,7 @@ namespace Apache.Ignite.Internal.Table
             var resSchema = await _table.ReadSchemaAsync(resBuf).ConfigureAwait(false);
 
             // TODO: Read value parts only (IGNITE-16022).
-            return _ser.ReadMultipleNullable(resBuf, resSchema);
+            return _ser.ReadMultipleNullable(resBuf, resSchema, resultFactory, addAction);
         }
 
         /// <inheritdoc/>
@@ -163,7 +191,14 @@ namespace Apache.Ignite.Internal.Table
             var resSchema = await _table.ReadSchemaAsync(resBuf).ConfigureAwait(false);
 
             // TODO: Read value parts only (IGNITE-16022).
-            return _ser.ReadMultiple(resBuf, resSchema);
+            return _ser.ReadMultiple(
+                buf: resBuf,
+                schema: resSchema,
+                keyOnly: false,
+                resultFactory: static count => count == 0
+                    ? (IList<T>)Array.Empty<T>()
+                    : new List<T>(count),
+                addAction: static (res, item) => res.Add(item));
         }
 
         /// <inheritdoc/>
@@ -231,32 +266,52 @@ namespace Apache.Ignite.Internal.Table
         }
 
         /// <inheritdoc/>
-        public async Task<IList<T>> DeleteAllAsync(ITransaction? transaction, IEnumerable<T> keys)
-        {
-            IgniteArgumentCheck.NotNull(keys, nameof(keys));
-
-            using var iterator = keys.GetEnumerator();
-
-            if (!iterator.MoveNext())
-            {
-                return Array.Empty<T>();
-            }
-
-            var schema = await _table.GetLatestSchemaAsync().ConfigureAwait(false);
-            var tx = transaction.ToInternal();
+        public async Task<IList<T>> DeleteAllAsync(ITransaction? transaction, IEnumerable<T> keys) =>
+            await DeleteAllAsync(transaction, keys, exact: false);
 
-            using var writer = ProtoCommon.GetMessageWriter();
-            _ser.WriteMultiple(writer, tx, schema, iterator, keyOnly: true);
-
-            using var resBuf = await DoOutInOpAsync(ClientOp.TupleDeleteAll, tx, writer).ConfigureAwait(false);
-            var resSchema = await _table.ReadSchemaAsync(resBuf).ConfigureAwait(false);
+        /// <inheritdoc/>
+        public async Task<IList<T>> DeleteAllExactAsync(ITransaction? transaction, IEnumerable<T> records) =>
+            await DeleteAllAsync(transaction, records, exact: true);
 
-            // TODO: Read value parts only (IGNITE-16022).
-            return _ser.ReadMultiple(resBuf, resSchema, keyOnly: true);
-        }
+        /// <summary>
+        /// Deletes multiple records. If one or more keys do not exist, other records are still deleted.
+        /// </summary>
+        /// <param name="transaction">The transaction or <c>null</c> to auto commit.</param>
+        /// <param name="records">Record keys to delete.</param>
+        /// <param name="exact">Whether to match on both key and value.</param>
+        /// <returns>
+        /// A <see cref="Task"/> representing the asynchronous operation.
+        /// The task result contains records from <paramref name="records"/> that did not exist.
+        /// </returns>
+        public async Task<IList<T>> DeleteAllAsync(ITransaction? transaction, IEnumerable<T> records, bool exact) =>
+            await DeleteAllAsync(
+                transaction,
+                records,
+                resultFactory: static count => count == 0
+                    ? (IList<T>)Array.Empty<T>()
+                    : new List<T>(count),
+                addAction: static (res, item) => res.Add(item),
+                exact: exact);
 
-        /// <inheritdoc/>
-        public async Task<IList<T>> DeleteAllExactAsync(ITransaction? transaction, IEnumerable<T> records)
+        /// <summary>
+        /// Deletes multiple records. If one or more keys do not exist, other records are still deleted.
+        /// </summary>
+        /// <param name="transaction">The transaction or <c>null</c> to auto commit.</param>
+        /// <param name="records">Record keys to delete.</param>
+        /// <param name="resultFactory">Result factory.</param>
+        /// <param name="addAction">Add action.</param>
+        /// <param name="exact">Whether to match on both key and value.</param>
+        /// <typeparam name="TRes">Result type.</typeparam>
+        /// <returns>
+        /// A <see cref="Task"/> representing the asynchronous operation.
+        /// The task result contains records from <paramref name="records"/> that did not exist.
+        /// </returns>
+        public async Task<TRes> DeleteAllAsync<TRes>(
+            ITransaction? transaction,
+            IEnumerable<T> records,
+            Func<int, TRes> resultFactory,
+            Action<TRes, T> addAction,
+            bool exact)
         {
             IgniteArgumentCheck.NotNull(records, nameof(records));
 
@@ -264,19 +319,43 @@ namespace Apache.Ignite.Internal.Table
 
             if (!iterator.MoveNext())
             {
-                return Array.Empty<T>();
+                return resultFactory(0);
             }
 
             var schema = await _table.GetLatestSchemaAsync().ConfigureAwait(false);
             var tx = transaction.ToInternal();
 
             using var writer = ProtoCommon.GetMessageWriter();
-            _ser.WriteMultiple(writer, tx, schema, iterator);
+            _ser.WriteMultiple(writer, tx, schema, iterator, keyOnly: !exact);
 
-            using var resBuf = await DoOutInOpAsync(ClientOp.TupleDeleteAllExact, tx, writer).ConfigureAwait(false);
+            var clientOp = exact ? ClientOp.TupleDeleteAllExact : ClientOp.TupleDeleteAll;
+            using var resBuf = await DoOutInOpAsync(clientOp, tx, writer).ConfigureAwait(false);
             var resSchema = await _table.ReadSchemaAsync(resBuf).ConfigureAwait(false);
 
-            return _ser.ReadMultiple(resBuf, resSchema);
+            // TODO: Read value parts only (IGNITE-16022).
+            return _ser.ReadMultiple(
+                buf: resBuf,
+                schema: resSchema,
+                keyOnly: !exact,
+                resultFactory: resultFactory,
+                addAction: addAction);
+        }
+
+        /// <summary>
+        /// Determines if the table contains an entry for the specified key.
+        /// </summary>
+        /// <param name="transaction">Transaction.</param>
+        /// <param name="key">Key.</param>
+        /// <returns>
+        /// A <see cref="Task"/> representing the asynchronous operation.
+        /// The task result contains a value indicating whether a record with the specified key exists in the table.
+        /// </returns>
+        internal async Task<bool> ContainsKey(ITransaction? transaction, T key)
+        {
+            IgniteArgumentCheck.NotNull(key, nameof(key));
+
+            using var resBuf = await DoRecordOutOpAsync(ClientOp.TupleContainsKey, transaction, key, keyOnly: true).ConfigureAwait(false);
+            return resBuf.GetReader().ReadBoolean();
         }
 
         private async Task<PooledBuffer> DoOutInOpAsync(
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Schema.cs b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Schema.cs
index f9af706204..42aae781e2 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Schema.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Schema.cs
@@ -28,5 +28,11 @@ namespace Apache.Ignite.Internal.Table
     internal record Schema(
         int Version,
         int KeyColumnCount,
-        IReadOnlyList<Column> Columns);
+        IReadOnlyList<Column> Columns)
+    {
+        /// <summary>
+        /// Gets the value column count.
+        /// </summary>
+        public int ValueColumnCount => Columns.Count - KeyColumnCount;
+    }
 }
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Schema.cs b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/KvPair.cs
similarity index 57%
copy from modules/platforms/dotnet/Apache.Ignite/Internal/Table/Schema.cs
copy to modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/KvPair.cs
index f9af706204..83a38b842c 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Schema.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/KvPair.cs
@@ -15,18 +15,18 @@
  * limitations under the License.
  */
 
-namespace Apache.Ignite.Internal.Table
-{
-    using System.Collections.Generic;
+namespace Apache.Ignite.Internal.Table.Serialization;
 
-    /// <summary>
-    /// Schema.
-    /// </summary>
-    /// <param name="Version">Version.</param>
-    /// <param name="KeyColumnCount">Key column count.</param>
-    /// <param name="Columns">Columns in schema order.</param>
-    internal record Schema(
-        int Version,
-        int KeyColumnCount,
-        IReadOnlyList<Column> Columns);
-}
+using System.Collections.Generic;
+using Ignite.Table;
+
+/// <summary>
+/// Key + Value wrapper for serializing data from <see cref="IKeyValueView{TK,TV}"/>.
+/// <para />
+/// We can't use built-in <see cref="KeyValuePair{TKey,TValue}"/>, because it can come from the user code.
+/// </summary>
+/// <param name="Key">Key.</param>
+/// <param name="Val">Value.</param>
+/// <typeparam name="TK">Key type.</typeparam>
+/// <typeparam name="TV">Value type.</typeparam>
+internal readonly record struct KvPair<TK, TV>(TK Key, TV Val = default!);
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/ObjectSerializerHandler.cs b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/ObjectSerializerHandler.cs
index ba9b923739..becfff017e 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/ObjectSerializerHandler.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/ObjectSerializerHandler.cs
@@ -68,7 +68,7 @@ namespace Apache.Ignite.Internal.Table.Serialization
                 ? w
                 : _valuePartReaders.GetOrAdd(schema.Version, EmitValuePartReader(schema));
 
-            var binaryTupleReader = new BinaryTupleReader(reader.ReadBytesAsMemory(), schema.Columns.Count - schema.KeyColumnCount);
+            var binaryTupleReader = new BinaryTupleReader(reader.ReadBytesAsMemory(), schema.ValueColumnCount);
 
             return readDelegate(ref binaryTupleReader, key);
         }
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/RecordSerializer.cs b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/RecordSerializer.cs
index 29412babf5..e8a24c0576 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/RecordSerializer.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/RecordSerializer.cs
@@ -72,7 +72,7 @@ namespace Apache.Ignite.Internal.Table.Serialization
             var r = buf.GetReader();
             r.Skip();
 
-            return _handler.ReadValuePart(ref r, schema, key);
+            return Option.Some(_handler.ReadValuePart(ref r, schema, key));
         }
 
         /// <summary>
@@ -81,13 +81,21 @@ namespace Apache.Ignite.Internal.Table.Serialization
         /// <param name="buf">Buffer.</param>
         /// <param name="schema">Schema or null when there is no value.</param>
         /// <param name="keyOnly">Key only mode.</param>
+        /// <param name="resultFactory">Result factory.</param>
+        /// <param name="addAction">Adds items to the result.</param>
+        /// <typeparam name="TRes">Result type.</typeparam>
         /// <returns>List of records.</returns>
-        public IList<T> ReadMultiple(PooledBuffer buf, Schema? schema, bool keyOnly = false)
+        public TRes ReadMultiple<TRes>(
+            PooledBuffer buf,
+            Schema? schema,
+            bool keyOnly,
+            Func<int, TRes> resultFactory,
+            Action<TRes, T> addAction)
         {
             if (schema == null)
             {
                 // Null schema means empty collection.
-                return Array.Empty<T>();
+                return resultFactory(0);
             }
 
             // Skip schema version.
@@ -95,11 +103,11 @@ namespace Apache.Ignite.Internal.Table.Serialization
             r.Skip();
 
             var count = r.ReadInt32();
-            var res = new List<T>(count);
+            var res = resultFactory(count);
 
             for (var i = 0; i < count; i++)
             {
-                res.Add(_handler.Read(ref r, schema, keyOnly));
+                addAction(res, _handler.Read(ref r, schema, keyOnly));
             }
 
             return res;
@@ -110,14 +118,20 @@ namespace Apache.Ignite.Internal.Table.Serialization
         /// </summary>
         /// <param name="buf">Buffer.</param>
         /// <param name="schema">Schema or null when there is no value.</param>
-        /// <param name="keyOnly">Key only mode.</param>
+        /// <param name="resultFactory">Result factory.</param>
+        /// <param name="addAction">Adds items to the result.</param>
+        /// <typeparam name="TRes">Result type.</typeparam>
         /// <returns>List of records.</returns>
-        public IList<Option<T>> ReadMultipleNullable(PooledBuffer buf, Schema? schema, bool keyOnly = false)
+        public TRes ReadMultipleNullable<TRes>(
+            PooledBuffer buf,
+            Schema? schema,
+            Func<int, TRes> resultFactory,
+            Action<TRes, Option<T>> addAction)
         {
             if (schema == null)
             {
                 // Null schema means empty collection.
-                return Array.Empty<Option<T>>();
+                return resultFactory(0);
             }
 
             // Skip schema version.
@@ -125,15 +139,15 @@ namespace Apache.Ignite.Internal.Table.Serialization
             r.Skip();
 
             var count = r.ReadInt32();
-            var res = new List<Option<T>>(count);
+            var res = resultFactory(count);
 
             for (var i = 0; i < count; i++)
             {
-                Option<T> option = r.ReadBoolean()
-                    ? _handler.Read(ref r, schema, keyOnly)
-                    : default(Option<T>);
+                var option = r.ReadBoolean()
+                    ? Option.Some(_handler.Read(ref r, schema))
+                    : default;
 
-                res.Add(option);
+                addAction(res, option);
             }
 
             return res;
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/TuplePairSerializerHandler.cs b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/TuplePairSerializerHandler.cs
new file mode 100644
index 0000000000..8d585685fb
--- /dev/null
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/TuplePairSerializerHandler.cs
@@ -0,0 +1,114 @@
+/*
+ * 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.Internal.Table.Serialization;
+
+using Ignite.Table;
+using MessagePack;
+using Proto;
+using Proto.BinaryTuple;
+
+/// <summary>
+/// Serializer handler for <see cref="IIgniteTuple"/>.
+/// </summary>
+internal class TuplePairSerializerHandler : IRecordSerializerHandler<KvPair<IIgniteTuple, IIgniteTuple>>
+{
+    /// <summary>
+    /// Singleton instance.
+    /// </summary>
+    public static readonly TuplePairSerializerHandler Instance = new();
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="TuplePairSerializerHandler"/> class.
+    /// </summary>
+    private TuplePairSerializerHandler()
+    {
+        // No-op.
+    }
+
+    /// <inheritdoc/>
+    public KvPair<IIgniteTuple, IIgniteTuple> Read(ref MessagePackReader reader, Schema schema, bool keyOnly = false)
+    {
+        var columns = schema.Columns;
+        var count = keyOnly ? schema.KeyColumnCount : columns.Count;
+        var keyTuple = new IgniteTuple(count);
+        var valTuple = keyOnly ? null! : new IgniteTuple(schema.ValueColumnCount);
+        var tupleReader = new BinaryTupleReader(reader.ReadBytesAsMemory(), count);
+
+        for (var index = 0; index < count; index++)
+        {
+            var column = columns[index];
+
+            var tuple = index < schema.KeyColumnCount ? keyTuple : valTuple;
+            tuple[column.Name] = tupleReader.GetObject(index, column.Type, column.Scale);
+        }
+
+        return new(keyTuple, valTuple);
+    }
+
+    /// <inheritdoc/>
+    public KvPair<IIgniteTuple, IIgniteTuple> ReadValuePart(ref MessagePackReader reader, Schema schema, KvPair<IIgniteTuple, IIgniteTuple> key)
+    {
+        var columns = schema.Columns;
+        var tuple = new IgniteTuple(columns.Count);
+        var tupleReader = new BinaryTupleReader(reader.ReadBytesAsMemory(), schema.ValueColumnCount);
+
+        for (var i = schema.KeyColumnCount; i < columns.Count; i++)
+        {
+            var column = columns[i];
+            tuple[column.Name] = tupleReader.GetObject(i - schema.KeyColumnCount, column.Type, column.Scale);
+        }
+
+        return key with { Val = tuple };
+    }
+
+    /// <inheritdoc/>
+    public void Write(ref MessagePackWriter writer, Schema schema, KvPair<IIgniteTuple, IIgniteTuple> record, bool keyOnly = false)
+    {
+        var columns = schema.Columns;
+        var count = keyOnly ? schema.KeyColumnCount : columns.Count;
+        var noValueSet = writer.WriteBitSet(count);
+
+        var tupleBuilder = new BinaryTupleBuilder(count);
+
+        try
+        {
+            for (var index = 0; index < count; index++)
+            {
+                var col = columns[index];
+                var rec = index < schema.KeyColumnCount ? record.Key : record.Val;
+                var colIdx = rec.GetOrdinal(col.Name);
+
+                if (colIdx >= 0)
+                {
+                    tupleBuilder.AppendObject(rec[colIdx], col.Type, col.Scale);
+                }
+                else
+                {
+                    tupleBuilder.AppendNoValue(noValueSet);
+                }
+            }
+
+            var binaryTupleMemory = tupleBuilder.Build();
+            writer.Write(binaryTupleMemory.Span);
+        }
+        finally
+        {
+            tupleBuilder.Dispose();
+        }
+    }
+}
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/TupleSerializerHandler.cs b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/TupleSerializerHandler.cs
index 8c274a4126..087fc04efc 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/TupleSerializerHandler.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/TupleSerializerHandler.cs
@@ -62,7 +62,7 @@ namespace Apache.Ignite.Internal.Table.Serialization
         {
             var columns = schema.Columns;
             var tuple = new IgniteTuple(columns.Count);
-            var tupleReader = new BinaryTupleReader(reader.ReadBytesAsMemory(), schema.Columns.Count - schema.KeyColumnCount);
+            var tupleReader = new BinaryTupleReader(reader.ReadBytesAsMemory(), schema.ValueColumnCount);
 
             for (var i = 0; i < columns.Count; i++)
             {
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Table.cs b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Table.cs
index 1f64ae8f45..0098f7b986 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Table.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Table.cs
@@ -62,6 +62,15 @@ namespace Apache.Ignite.Internal.Table
             RecordBinaryView = new RecordView<IIgniteTuple>(
                 this,
                 new RecordSerializer<IIgniteTuple>(this, TupleSerializerHandler.Instance));
+
+            // RecordView and KeyValueView are symmetric and perform the same operations on the protocol level.
+            // Only serialization is different - KeyValueView splits records into two parts.
+            // Therefore, KeyValueView below simply delegates to RecordView<KvPair>,
+            // and SerializerHandler writes KV pair as a single record and reads back record as two parts.
+            var pairSerializer = new RecordSerializer<KvPair<IIgniteTuple, IIgniteTuple>>(this, TuplePairSerializerHandler.Instance);
+
+            KeyValueBinaryView = new KeyValueView<IIgniteTuple, IIgniteTuple>(
+                new RecordView<KvPair<IIgniteTuple, IIgniteTuple>>(this, pairSerializer));
         }
 
         /// <inheritdoc/>
@@ -70,6 +79,9 @@ namespace Apache.Ignite.Internal.Table
         /// <inheritdoc/>
         public IRecordView<IIgniteTuple> RecordBinaryView { get; }
 
+        /// <inheritdoc/>
+        public IKeyValueView<IIgniteTuple, IIgniteTuple> KeyValueBinaryView { get; }
+
         /// <summary>
         /// Gets the associated socket.
         /// </summary>
@@ -81,7 +93,8 @@ namespace Apache.Ignite.Internal.Table
         internal Guid Id { get; }
 
         /// <inheritdoc/>
-        public IRecordView<T> GetRecordView<T>() => GetRecordViewInternal<T>();
+        public IRecordView<T> GetRecordView<T>()
+            where T : notnull => GetRecordViewInternal<T>();
 
         /// <summary>
         /// Gets the record view for the specified type.
@@ -89,6 +102,7 @@ namespace Apache.Ignite.Internal.Table
         /// <typeparam name="T">Record type.</typeparam>
         /// <returns>Record view.</returns>
         internal RecordView<T> GetRecordViewInternal<T>()
+            where T : notnull
         {
             // ReSharper disable once HeapView.CanAvoidClosure (generics prevent this)
             return (RecordView<T>)_recordViews.GetOrAdd(
diff --git a/modules/platforms/dotnet/Apache.Ignite/Option.cs b/modules/platforms/dotnet/Apache.Ignite/Option.cs
index 1ed1fb03fd..c021001d2f 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Option.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Option.cs
@@ -38,7 +38,7 @@ public readonly record struct Option<T>
         "StyleCop.CSharp.DocumentationRules",
         "SA1642:ConstructorSummaryDocumentationMustBeginWithStandardText",
         Justification = "False positive.")]
-    private Option(T value, bool hasValue)
+    internal Option(T value, bool hasValue)
     {
         _value = value;
         HasValue = hasValue;
@@ -56,13 +56,6 @@ public readonly record struct Option<T>
     /// </summary>
     public bool HasValue { get; }
 
-    /// <summary>
-    /// Wraps a value into an option.
-    /// </summary>
-    /// <param name="value">Value.</param>
-    /// <returns>Wrapped value.</returns>
-    public static implicit operator Option<T>(T value) => new(value, true);
-
     /// <summary>
     /// Deconstructs this instance.
     /// </summary>
@@ -74,6 +67,14 @@ public readonly record struct Option<T>
         hasValue = HasValue;
     }
 
+    /// <summary>
+    /// Maps this instance to another type.
+    /// </summary>
+    /// <param name="selector">Selector.</param>
+    /// <typeparam name="TRes">Result type.</typeparam>
+    /// <returns>Resulting option.</returns>
+    public Option<TRes> Select<TRes>(Func<T, TRes> selector) => HasValue ? Option.Some(selector(_value)) : default!;
+
     private bool PrintMembers(StringBuilder builder)
     {
         builder.Append("HasValue = ");
@@ -100,7 +101,7 @@ public static class Option
     /// <param name="val">Value.</param>
     /// <typeparam name="T">value type.</typeparam>
     /// <returns>Option of T.</returns>
-    public static Option<T> Some<T>(T val) => val;
+    public static Option<T> Some<T>(T val) => new(val, true);
 
     /// <summary>
     /// Returns an option without a value.
@@ -108,4 +109,4 @@ public static class Option
     /// <typeparam name="T">value type.</typeparam>
     /// <returns>Option of T.</returns>
     public static Option<T> None<T>() => default;
-}
\ No newline at end of file
+}
diff --git a/modules/platforms/dotnet/Apache.Ignite/RetryReadPolicy.cs b/modules/platforms/dotnet/Apache.Ignite/RetryReadPolicy.cs
index 09b2e7142b..ee656a8795 100644
--- a/modules/platforms/dotnet/Apache.Ignite/RetryReadPolicy.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/RetryReadPolicy.cs
@@ -54,6 +54,7 @@ namespace Apache.Ignite
                 ClientOperationType.TupleDeleteExact => false,
                 ClientOperationType.TupleDeleteAllExact => false,
                 ClientOperationType.TupleGetAndDelete => false,
+                ClientOperationType.TupleContainsKey => false,
                 ClientOperationType.ComputeExecute => false,
                 ClientOperationType.SqlExecute => false,
                 var unsupported => throw new NotSupportedException("Unsupported operation type: " + unsupported)
diff --git a/modules/platforms/dotnet/Apache.Ignite/Table/IKeyValueView.cs b/modules/platforms/dotnet/Apache.Ignite/Table/IKeyValueView.cs
new file mode 100644
index 0000000000..552c1d280f
--- /dev/null
+++ b/modules/platforms/dotnet/Apache.Ignite/Table/IKeyValueView.cs
@@ -0,0 +1,203 @@
+/*
+ * 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.Table;
+
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Transactions;
+
+/// <summary>
+/// Key-value view provides access to table records in form of separate key and value parts.
+/// </summary>
+/// <typeparam name="TK">Key type.</typeparam>
+/// <typeparam name="TV">Value type.</typeparam>
+public interface IKeyValueView<TK, TV>
+    where TK : notnull
+    where TV : notnull
+{
+    /// <summary>
+    /// Gets a value associated with the given key.
+    /// </summary>
+    /// <param name="transaction">The transaction or <c>null</c> to auto commit.</param>
+    /// <param name="key">Key.</param>
+    /// <returns>
+    /// A <see cref="Task{TResult}"/> representing the asynchronous operation.
+    /// The task result contains the value for the specified key, or an <see cref="Option{T}"/> instance without a value
+    /// when specified key is not present in the table.
+    /// </returns>
+    Task<Option<TV>> GetAsync(ITransaction? transaction, TK key);
+
+    /// <summary>
+    /// Gets multiple records by keys.
+    /// </summary>
+    /// <param name="transaction">The transaction or <c>null</c> to auto commit.</param>
+    /// <param name="keys">Keys.</param>
+    /// <returns>
+    /// A <see cref="Task"/> representing the asynchronous operation.
+    /// The task result contains a dictionary with specified keys and their values. If a record for a particular key does not exist,
+    /// it will not be present in the resulting dictionary.
+    /// </returns>
+    Task<IDictionary<TK, TV>> GetAllAsync(ITransaction? transaction, IEnumerable<TK> keys);
+
+    /// <summary>
+    /// Determines if the table contains an entry for the specified key.
+    /// </summary>
+    /// <param name="transaction">The transaction or <c>null</c> to auto commit.</param>
+    /// <param name="key">Keys.</param>
+    /// <returns>
+    /// A <see cref="Task"/> representing the asynchronous operation.
+    /// The task result is <c>true</c> if a value exists for the specified key, and <c>false</c> otherwise.
+    /// </returns>
+    Task<bool> ContainsAsync(ITransaction? transaction, TK key);
+
+    /// <summary>
+    /// Puts a value with a given key.
+    /// </summary>
+    /// <param name="transaction">The transaction or <c>null</c> to auto commit.</param>
+    /// <param name="key">Key.</param>
+    /// <param name="val">Value.</param>
+    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation.</returns>
+    Task PutAsync(ITransaction? transaction, TK key, TV val);
+
+    /// <summary>
+    /// Puts multiple key-value pairs.
+    /// </summary>
+    /// <param name="transaction">The transaction or <c>null</c> to auto commit.</param>
+    /// <param name="pairs">Pairs.</param>
+    /// <returns>A <see cref="Task{TResult}"/> representing the asynchronous operation.</returns>
+    Task PutAllAsync(ITransaction? transaction, IEnumerable<KeyValuePair<TK, TV>> pairs);
+
+    /// <summary>
+    /// Puts a value with a given key and returns previous value for that key.
+    /// </summary>
+    /// <param name="transaction">The transaction or <c>null</c> to auto commit.</param>
+    /// <param name="key">Key.</param>
+    /// <param name="val">Value.</param>
+    /// <returns>
+    /// A <see cref="Task{TResult}"/> representing the asynchronous operation.
+    /// The task result contains the value for the specified key, or an <see cref="Option{T}"/> instance without a value
+    /// when specified key is not present in the table.
+    /// </returns>
+    Task<Option<TV>> GetAndPutAsync(ITransaction? transaction, TK key, TV val);
+
+    /// <summary>
+    /// Puts a value with a given key if the specified key is not present in the table.
+    /// </summary>
+    /// <param name="transaction">The transaction or <c>null</c> to auto commit.</param>
+    /// <param name="key">Key.</param>
+    /// <param name="val">Value.</param>
+    /// <returns>
+    /// A <see cref="Task{TResult}"/> representing the asynchronous operation.
+    /// The task result contains a value indicating whether the value was added to the table.
+    /// </returns>
+    Task<bool> PutIfAbsentAsync(ITransaction? transaction, TK key, TV val);
+
+    /// <summary>
+    /// Removes a value with a given key from the table.
+    /// </summary>
+    /// <param name="transaction">The transaction or <c>null</c> to auto commit.</param>
+    /// <param name="key">Key.</param>
+    /// <returns>
+    /// A <see cref="Task{TResult}"/> representing the asynchronous operation.
+    /// The task result contains a value indicating whether the key was removed from the table.
+    /// </returns>
+    Task<bool> RemoveAsync(ITransaction? transaction, TK key);
+
+    /// <summary>
+    /// Removes a value with a given key from the table only if it is equal to the specified value.
+    /// </summary>
+    /// <param name="transaction">The transaction or <c>null</c> to auto commit.</param>
+    /// <param name="key">Key.</param>
+    /// <param name="val">Val.</param>
+    /// <returns>
+    /// A <see cref="Task{TResult}"/> representing the asynchronous operation.
+    /// The task result contains a value indicating whether the key was removed from the table.
+    /// </returns>
+    Task<bool> RemoveAsync(ITransaction? transaction, TK key, TV val);
+
+    /// <summary>
+    /// Removes values with given keys from the table.
+    /// </summary>
+    /// <param name="transaction">The transaction or <c>null</c> to auto commit.</param>
+    /// <param name="keys">Keys.</param>
+    /// <returns>
+    /// A <see cref="Task{TResult}"/> representing the asynchronous operation.
+    /// The task result contains skipped keys.
+    /// </returns>
+    Task<IList<TK>> RemoveAllAsync(ITransaction? transaction, IEnumerable<TK> keys);
+
+    /// <summary>
+    /// Removes records with given keys and values from the table.
+    /// </summary>
+    /// <param name="transaction">The transaction or <c>null</c> to auto commit.</param>
+    /// <param name="pairs">Keys.</param>
+    /// <returns>
+    /// A <see cref="Task{TResult}"/> representing the asynchronous operation.
+    /// The task result contains skipped keys.
+    /// </returns>
+    Task<IList<TK>> RemoveAllAsync(ITransaction? transaction, IEnumerable<KeyValuePair<TK, TV>> pairs);
+
+    /// <summary>
+    /// Gets and removes a value associated with the given key.
+    /// </summary>
+    /// <param name="transaction">The transaction or <c>null</c> to auto commit.</param>
+    /// <param name="key">Key.</param>
+    /// <returns>
+    /// A <see cref="Task{TResult}"/> representing the asynchronous operation.
+    /// The task result contains a value indicating whether the key was removed from the table.
+    /// </returns>
+    Task<Option<TV>> GetAndRemoveAsync(ITransaction? transaction, TK key);
+
+    /// <summary>
+    /// Replaces a record with the same key columns if it exists, otherwise does nothing.
+    /// </summary>
+    /// <param name="transaction">The transaction or <c>null</c> to auto commit.</param>
+    /// <param name="key">Key.</param>
+    /// <param name="val">Value.</param>
+    /// <returns>
+    /// A <see cref="Task"/> representing the asynchronous operation.
+    /// The task result contains a value indicating whether a record with the specified key was replaced.
+    /// </returns>
+    Task<bool> ReplaceAsync(ITransaction? transaction, TK key, TV val);
+
+    /// <summary>
+    /// Replaces a record with a new one only if all existing columns have the same values
+    /// as the specified <paramref name="oldVal"/>.
+    /// </summary>
+    /// <param name="transaction">The transaction or <c>null</c> to auto commit.</param>
+    /// <param name="key">Key.</param>
+    /// <param name="oldVal">Old value.</param>
+    /// <param name="newVal">New value.</param>
+    /// <returns>
+    /// A <see cref="Task"/> representing the asynchronous operation.
+    /// The task result contains a value indicating whether a record was replaced.
+    /// </returns>
+    Task<bool> ReplaceAsync(ITransaction? transaction, TK key, TV oldVal, TV newVal);
+
+    /// <summary>
+    /// Replaces a record with the same key columns if it exists.
+    /// </summary>
+    /// <param name="transaction">The transaction or <c>null</c> to auto commit.</param>
+    /// <param name="key">Key.</param>
+    /// <param name="val">Value.</param>
+    /// <returns>
+    /// A <see cref="Task"/> representing the asynchronous operation.
+    /// The task result contains the previous value for the given key, or empty <see cref="Option{T}"/> if it did not exist.
+    /// </returns>
+    Task<Option<TV>> GetAndReplaceAsync(ITransaction? transaction, TK key, TV val);
+}
diff --git a/modules/platforms/dotnet/Apache.Ignite/Table/IRecordView.cs b/modules/platforms/dotnet/Apache.Ignite/Table/IRecordView.cs
index b13c5295b0..6d06822718 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Table/IRecordView.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Table/IRecordView.cs
@@ -26,6 +26,7 @@ namespace Apache.Ignite.Table
     /// </summary>
     /// <typeparam name="T">Record type.</typeparam>
     public interface IRecordView<T>
+        where T : notnull
     {
         /// <summary>
         /// Gets a record by key.
@@ -47,7 +48,7 @@ namespace Apache.Ignite.Table
         /// A <see cref="Task"/> representing the asynchronous operation.
         /// The task result contains matching records with all columns filled from the table. The order of collection
         /// elements is guaranteed to be the same as the order of <paramref name="keys"/>. If a record does not exist,
-        /// the element at the corresponding index of the resulting collection will be <c>null</c>.
+        /// the element at the corresponding index of the resulting collection will be empty <see cref="Option{T}"/>.
         /// </returns>
         Task<IList<Option<T>>> GetAllAsync(ITransaction? transaction, IEnumerable<T> keys);
 
@@ -68,7 +69,7 @@ namespace Apache.Ignite.Table
         Task UpsertAllAsync(ITransaction? transaction, IEnumerable<T> records);
 
         /// <summary>
-        /// Inserts a record into the table if it does not exist or replaces the existing one.
+        /// Inserts a record into the table and returns previous record.
         /// </summary>
         /// <param name="transaction">The transaction or <c>null</c> to auto commit.</param>
         /// <param name="record">Record to upsert.</param>
@@ -132,7 +133,7 @@ namespace Apache.Ignite.Table
         /// <param name="record">Record to insert.</param>
         /// <returns>
         /// A <see cref="Task"/> representing the asynchronous operation.
-        /// The task result contains the previous value for the given key, or <c>null</c> if it did not exist.
+        /// The task result contains the previous value for the given key, or empty <see cref="Option{T}"/> if it did not exist.
         /// </returns>
         Task<Option<T>> GetAndReplaceAsync(ITransaction? transaction, T record);
 
@@ -165,7 +166,7 @@ namespace Apache.Ignite.Table
         /// <param name="key">A record with key columns set.</param>
         /// <returns>
         /// A <see cref="Task"/> representing the asynchronous operation.
-        /// The task result contains deleted record or <c>null</c> if it did not exist.
+        /// The task result contains deleted record or empty <see cref="Option{T}"/> if it did not exist.
         /// </returns>
         Task<Option<T>> GetAndDeleteAsync(ITransaction? transaction, T key);
 
diff --git a/modules/platforms/dotnet/Apache.Ignite/Table/ITable.cs b/modules/platforms/dotnet/Apache.Ignite/Table/ITable.cs
index e6c49a30ad..300f15243b 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Table/ITable.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Table/ITable.cs
@@ -32,6 +32,11 @@ namespace Apache.Ignite.Table
         /// </summary>
         public IRecordView<IIgniteTuple> RecordBinaryView { get; }
 
+        /// <summary>
+        /// Gets the key-value binary view.
+        /// </summary>
+        public IKeyValueView<IIgniteTuple, IIgniteTuple> KeyValueBinaryView { get; }
+
         /// <summary>
         /// Gets the record view mapped to specified type <typeparamref name="T"/>.
         /// <para />
@@ -40,6 +45,7 @@ namespace Apache.Ignite.Table
         /// </summary>
         /// <typeparam name="T">Record type.</typeparam>
         /// <returns>Record view.</returns>
-        public IRecordView<T> GetRecordView<T>(); // TODO: Custom mapping (IGNITE-16356)
+        public IRecordView<T> GetRecordView<T>() // TODO: Custom mapping (IGNITE-16356)
+            where T : notnull;
     }
 }