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 6bf133d8598 IGNITE-24258 .NET: Add QualifiedName API (#5565)
6bf133d8598 is described below

commit 6bf133d8598a700283773e87aac85c5a3fffb124
Author: Pavel Tupitsyn <ptupit...@apache.org>
AuthorDate: Thu Apr 3 18:47:11 2025 +0300

    IGNITE-24258 .NET: Add QualifiedName API (#5565)
    
    * Add `QualifiedName` class to represent parsed database object name
    * Add `ITable.QualifiedName`
    * Add `ITables.GetTableAsync(QualifiedName)`
    * Add `JobTarget.Colocated(QualifiedName)`
    * Port test cases for name parsing from Java
---
 .../apache/ignite/lang/util/IgniteNameUtils.java   |   4 +-
 .../org/apache/ignite/table/QualifiedName.java     |   2 +-
 .../IgniteDistributedCacheTests.cs                 |   9 +-
 .../Apache.Ignite.Tests/Compute/ComputeTests.cs    |   9 +-
 .../dotnet/Apache.Ignite.Tests/FakeServer.cs       |   6 +-
 .../dotnet/Apache.Ignite.Tests/IgniteProxyTests.cs |   2 +-
 .../dotnet/Apache.Ignite.Tests/MetricsTests.cs     |   2 +-
 .../Apache.Ignite.Tests/ProjectFilesTests.cs       |   2 +-
 .../Proto/ColocationHashTests.cs                   |   1 -
 .../dotnet/Apache.Ignite.Tests/RetryPolicyTests.cs |   2 +-
 .../Table/IgniteNameUtilsTests.cs                  | 104 ++++++++++
 .../Table/KeyColumnOrderTests.cs                   |   3 +-
 .../Table/KeyValueViewBinaryTests.cs               |   2 +-
 .../Table/KeyValueViewPocoTests.cs                 |   2 +-
 .../Table/KeyValueViewPrimitiveTests.cs            |   2 +-
 .../Table/QualifiedNameTests.cs                    | 193 ++++++++++++++++++
 .../Table/RecordViewBinaryTests.cs                 |   2 +-
 .../Table/RecordViewPocoTests.cs                   |   3 +-
 .../Table/SchemaSynchronizationTest.cs             |   2 +-
 .../Table/SchemaValidationTest.cs                  |   3 +-
 .../Apache.Ignite.Tests/Table/TablesTests.cs       |  43 +++-
 .../dotnet/Apache.Ignite/ClientOperationType.cs    |   2 +-
 .../dotnet/Apache.Ignite/Compute/JobTarget.cs      |  17 +-
 .../Apache.Ignite/Internal/ClientFailoverSocket.cs |  37 +++-
 .../dotnet/Apache.Ignite/Internal/ClientSocket.cs  |   9 +-
 .../Apache.Ignite/Internal/Compute/Compute.cs      |   8 +-
 .../Apache.Ignite/Internal/ConnectionContext.cs    |  13 +-
 .../Internal/Linq/ExpressionWalker.cs              |   2 +-
 .../Internal/Linq/IIgniteQueryableInternal.cs      |   3 +-
 .../Internal/Linq/IgniteQueryProvider.cs           |   5 +-
 .../Apache.Ignite/Internal/Linq/IgniteQueryable.cs |   3 +-
 .../Apache.Ignite/Internal/Proto/ClientOp.cs       |   8 +-
 .../Internal/Proto/ClientOpExtensions.cs           |   4 +-
 .../ProtocolBitmaskFeature.cs}                     |  28 +--
 .../Internal/Table/IgniteNameUtils.cs              | 227 +++++++++++++++++++++
 .../Apache.Ignite/Internal/Table/KeyValueView.cs   |   2 +-
 .../Apache.Ignite/Internal/Table/RecordView.cs     |   2 +-
 .../dotnet/Apache.Ignite/Internal/Table/Table.cs   |  11 +-
 .../dotnet/Apache.Ignite/Internal/Table/Tables.cs  | 107 +++++++---
 .../platforms/dotnet/Apache.Ignite/Table/ITable.cs |   5 +
 .../dotnet/Apache.Ignite/Table/ITables.cs          |   7 +
 .../dotnet/Apache.Ignite/Table/QualifiedName.cs    | 151 ++++++++++++++
 42 files changed, 939 insertions(+), 110 deletions(-)

diff --git 
a/modules/api/src/main/java/org/apache/ignite/lang/util/IgniteNameUtils.java 
b/modules/api/src/main/java/org/apache/ignite/lang/util/IgniteNameUtils.java
index 83a905e965f..082cf731c1e 100644
--- a/modules/api/src/main/java/org/apache/ignite/lang/util/IgniteNameUtils.java
+++ b/modules/api/src/main/java/org/apache/ignite/lang/util/IgniteNameUtils.java
@@ -82,8 +82,8 @@ public final class IgniteNameUtils {
     }
 
     /**
-     * Wraps the given name with double quotes if it not uppercased non-quoted 
name, e.g. "myColumn" -&gt; "\"myColumn\"", "MYCOLUMN" -&gt;
-     * "MYCOLUMN"
+     * Wraps the given name with double quotes if it is not uppercased 
non-quoted name, e.g. "myColumn" -&gt; "\"myColumn\"",
+     * "MYCOLUMN" -&gt; "MYCOLUMN"
      *
      * @param identifier Object identifier.
      * @return Quoted object name.
diff --git 
a/modules/api/src/main/java/org/apache/ignite/table/QualifiedName.java 
b/modules/api/src/main/java/org/apache/ignite/table/QualifiedName.java
index 8ec974475e1..33230efcb7e 100644
--- a/modules/api/src/main/java/org/apache/ignite/table/QualifiedName.java
+++ b/modules/api/src/main/java/org/apache/ignite/table/QualifiedName.java
@@ -32,7 +32,7 @@ import org.jetbrains.annotations.Nullable;
  *
  * <p>Factory methods expects that given names (both: schema name and object 
name) respect SQL syntax rules for identifiers.
  * <ul>
- * <li>Identifier must starts from any character in the Unicode General 
Category classes “Lu”, “Ll”, “Lt”, “Lm”, “Lo”, or “Nl”.
+ * <li>Identifier must start from any character in the Unicode General 
Category classes “Lu”, “Ll”, “Lt”, “Lm”, “Lo”, or “Nl”.
  * <li>Identifier character (expect the first one) may be U+00B7 (middle dot), 
or any character in the Unicode General Category classes
  * “Mn”, “Mc”, “Nd”, “Pc”, or “Cf”.
  * <li>Identifier that contains any other characters must be quoted with 
double-quotes.
diff --git 
a/modules/platforms/dotnet/Apache.Extensions.Caching.Ignite.Tests/IgniteDistributedCacheTests.cs
 
b/modules/platforms/dotnet/Apache.Extensions.Caching.Ignite.Tests/IgniteDistributedCacheTests.cs
index b555da85eaa..1016c095ffb 100644
--- 
a/modules/platforms/dotnet/Apache.Extensions.Caching.Ignite.Tests/IgniteDistributedCacheTests.cs
+++ 
b/modules/platforms/dotnet/Apache.Extensions.Caching.Ignite.Tests/IgniteDistributedCacheTests.cs
@@ -151,10 +151,9 @@ public class IgniteDistributedCacheTests : IgniteTestsBase
 
         await Client.Sql.ExecuteAsync(null, $"INSERT INTO {tableName} (K, V) 
VALUES ('x', x'010203')");
 
-        // TODO https://issues.apache.org/jira/browse/IGNITE-24258: Remove 
unnecessary uppercasing for tableName.
         var options = new IgniteDistributedCacheOptions
         {
-            TableName = tableName.ToUpperInvariant(),
+            TableName = tableName,
             KeyColumnName = "K",
             ValueColumnName = "V"
         };
@@ -171,8 +170,7 @@ public class IgniteDistributedCacheTests : IgniteTestsBase
 
         await Client.Sql.ExecuteAsync(null, $"DROP TABLE IF EXISTS 
{tableName}");
 
-        // TODO https://issues.apache.org/jira/browse/IGNITE-24258: Remove 
unnecessary uppercasing for tableName.
-        IDistributedCache cache = GetCache(new() { TableName = 
tableName.ToUpperInvariant() });
+        IDistributedCache cache = GetCache(new() { TableName = tableName });
 
         await cache.SetAsync("x", [1]);
         Assert.AreEqual(new[] { 1 }, await cache.GetAsync("x"));
@@ -183,8 +181,7 @@ public class IgniteDistributedCacheTests : IgniteTestsBase
     {
         var cacheOptions = new IgniteDistributedCacheOptions
         {
-            // TODO https://issues.apache.org/jira/browse/IGNITE-24258: Remove 
unnecessary uppercasing for tableName.
-            TableName = 
nameof(TestCustomTableAndColumnNames).ToUpperInvariant(),
+            TableName = nameof(TestCustomTableAndColumnNames),
             KeyColumnName = "_K",
             ValueColumnName = "_V"
         };
diff --git 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Compute/ComputeTests.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Compute/ComputeTests.cs
index a143ceac8a0..d82a757e919 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/Compute/ComputeTests.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Compute/ComputeTests.cs
@@ -302,7 +302,7 @@ namespace Apache.Ignite.Tests.Compute
             var requestTargetNodeName = GetRequestTargetNodeName(proxies, 
ClientOp.ComputeExecuteColocated);
 
             var keyPoco = new Poco { Key = key };
-            var resNodeName2 = await 
client.Compute.SubmitAsync(JobTarget.Colocated(TableName, keyPoco), 
NodeNameJob, null);
+            var resNodeName2 = await 
client.Compute.SubmitAsync(JobTarget.Colocated(QualifiedName.Parse(TableName), 
keyPoco), NodeNameJob, null);
             var requestTargetNodeName2 = GetRequestTargetNodeName(proxies, 
ClientOp.ComputeExecuteColocated);
 
             var keyPocoStruct = new PocoStruct(key, null);
@@ -331,7 +331,7 @@ namespace Apache.Ignite.Tests.Compute
             var ex = Assert.ThrowsAsync<IgniteClientException>(async () =>
                 await 
Client.Compute.SubmitAsync(JobTarget.Colocated("unknownTable", new 
IgniteTuple()), EchoJob, null));
 
-            Assert.AreEqual("Table 'unknownTable' does not exist.", 
ex!.Message);
+            Assert.AreEqual("Table 'PUBLIC.UNKNOWNTABLE' does not exist.", 
ex!.Message);
         }
 
         [Test]
@@ -351,7 +351,6 @@ namespace Apache.Ignite.Tests.Compute
             // Create table and use it in ExecuteColocated.
             var nodes = await GetNodeAsync(0);
 
-            // TODO https://issues.apache.org/jira/browse/IGNITE-24258 revert 
change that had uppercased names.
             var tableNameExec = await Client.Compute.SubmitAsync(nodes, 
CreateTableJob, "DROP_ME");
             var tableName = await tableNameExec.GetResultAsync();
 
@@ -371,11 +370,11 @@ namespace Apache.Ignite.Tests.Compute
 
                 if (forceLoadAssignment)
                 {
-                    var table = 
Client.Compute.GetFieldValue<IDictionary>("_tableCache")[tableName]!;
+                    var table = 
Client.Compute.GetFieldValue<IDictionary>("_tableCache")[QualifiedName.Parse(tableName)]!;
                     table.SetFieldValue("_partitionAssignment", null);
                 }
 
-                var resNodeName2Exec = await 
Client.Compute.SubmitAsync(JobTarget.Colocated(tableName, keyTuple), 
NodeNameJob, null);
+                var resNodeName2Exec = await 
Client.Compute.SubmitAsync(JobTarget.Colocated(QualifiedName.Parse(tableName), 
keyTuple), NodeNameJob, null);
                 var resNodeName2 = await resNodeName2Exec.GetResultAsync();
 
                 Assert.AreEqual(resNodeName, resNodeName2);
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/FakeServer.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests/FakeServer.cs
index 8060b6820b5..0027eef923a 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/FakeServer.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/FakeServer.cs
@@ -46,11 +46,11 @@ namespace Apache.Ignite.Tests
     {
         public const string Err = "Err!";
 
-        public const string ExistingTableName = "TBL1";
+        public const string ExistingTableName = "PUBLIC.TBL1";
 
-        public const string CompositeKeyTableName = "TBL2";
+        public const string CompositeKeyTableName = "PUBLIC.TBL2";
 
-        public const string CustomColocationKeyTableName = "TBL3";
+        public const string CustomColocationKeyTableName = "PUBLIC.TBL3";
 
         public const string GetDetailsJob = "get-details";
 
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/IgniteProxyTests.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests/IgniteProxyTests.cs
index 0519da02a0b..79e834ec301 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/IgniteProxyTests.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/IgniteProxyTests.cs
@@ -39,6 +39,6 @@ public class IgniteProxyTests : IgniteTestsBase
 
         Assert.Greater(tables.Count, 1);
         Assert.IsNotNull(table);
-        Assert.AreEqual(new[] { ClientOp.TablesGet, ClientOp.TableGet }, 
proxy.ClientOps);
+        Assert.AreEqual(new[] { ClientOp.TablesGetQualified, 
ClientOp.TableGetQualified }, proxy.ClientOps);
     }
 }
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/MetricsTests.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests/MetricsTests.cs
index 384cf059bd6..383a762ed47 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/MetricsTests.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/MetricsTests.cs
@@ -176,7 +176,7 @@ public class MetricsTests
         AssertMetric(MetricNames.RequestsFailed, 0);
         AssertMetric(MetricNames.RequestsCompleted, 1);
 
-        Assert.ThrowsAsync<IgniteException>(async () => await 
client.Tables.GetTableAsync("bad-table"));
+        Assert.ThrowsAsync<IgniteException>(async () => await 
client.Tables.GetTableAsync("bad_table"));
 
         AssertMetric(MetricNames.RequestsSent, 2);
         AssertMetric(MetricNames.RequestsFailed, 1);
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/ProjectFilesTests.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests/ProjectFilesTests.cs
index 8e9764db734..fe19d6c7ea3 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/ProjectFilesTests.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/ProjectFilesTests.cs
@@ -80,7 +80,7 @@ namespace Apache.Ignite.Tests
                 {
                     if (!text.Contains("public record struct"))
                     {
-                        Assert.Fail("Public classes must be sealed: " + file);
+                        Assert.Fail("Public types must be sealed: " + file);
                     }
                 }
             }
diff --git 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Proto/ColocationHashTests.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Proto/ColocationHashTests.cs
index 8abd58c4979..ab15c85a56c 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/Proto/ColocationHashTests.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Proto/ColocationHashTests.cs
@@ -166,7 +166,6 @@ public class ColocationHashTests : IgniteTestsBase
         await Client.Sql.ExecuteAsync(null, sql);
 
         // Perform get to populate schema.
-        // TODO https://issues.apache.org/jira/browse/IGNITE-24258: Remove 
uppercase.
         var table = await 
Client.Tables.GetTableAsync(tableName.ToUpperInvariant());
         var view = table!.RecordBinaryView;
         await view.GetAsync(null, new IgniteTuple{["id"] = 1, ["id0"] = 2L, 
["id1"] = "3"});
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/RetryPolicyTests.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests/RetryPolicyTests.cs
index 7c7201886a0..ee9279e8171 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/RetryPolicyTests.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/RetryPolicyTests.cs
@@ -55,7 +55,7 @@ namespace Apache.Ignite.Tests
             using var server = new FakeServer(ctx => ctx.RequestCount % 2 == 
0);
             using var client = await server.ConnectClientAsync(cfg);
 
-            var ex = Assert.ThrowsAsync<IgniteException>(async () => await 
client.Tables.GetTableAsync("bad-table"));
+            var ex = Assert.ThrowsAsync<IgniteException>(async () => await 
client.Tables.GetTableAsync("bad_table"));
             StringAssert.Contains(FakeServer.Err, ex!.Message);
         }
 
diff --git 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/IgniteNameUtilsTests.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/IgniteNameUtilsTests.cs
new file mode 100644
index 00000000000..a50aa0cd326
--- /dev/null
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/IgniteNameUtilsTests.cs
@@ -0,0 +1,104 @@
+/*
+ * 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 Internal.Table;
+using NUnit.Framework;
+
+/// <summary>
+/// Tests for <see cref="IgniteNameUtils"/>.
+/// </summary>
+public class IgniteNameUtilsTests
+{
+    [TestCaseSource(nameof(ValidUnquotedIdentifiers))]
+    [TestCaseSource(nameof(ValidQuotedIdentifiers))]
+    public void TestValidIdentifiers(string source, string expected)
+    {
+        string parsed = IgniteNameUtils.ParseIdentifier(source);
+
+        Assert.AreEqual(expected, parsed);
+        Assert.AreEqual(parsed, 
IgniteNameUtils.ParseIdentifier(IgniteNameUtils.QuoteIfNeeded(parsed)));
+    }
+
+    [TestCaseSource(nameof(MalformedIdentifiers))]
+    public void TestMalformedIdentifiers(string source)
+    {
+        Assert.Throws<ArgumentException>(() => 
IgniteNameUtils.ParseIdentifier(source));
+    }
+
+    [TestCaseSource(nameof(QuoteIfNeeded))]
+    public void TestQuoteIfNeeded(string source, string expected)
+    {
+        string quoted = IgniteNameUtils.QuoteIfNeeded(source);
+
+        Assert.AreEqual(expected, quoted);
+        Assert.AreEqual(expected, 
IgniteNameUtils.QuoteIfNeeded(IgniteNameUtils.ParseIdentifier(quoted)));
+    }
+
+    private static TestCaseData[] QuoteIfNeeded() =>
+    [
+        new("foo", "\"foo\""),
+        new("fOo", "\"fOo\""),
+        new("FOO", "FOO"),
+        new("1o0", "\"1o0\""),
+        new("@#$", "\"@#$\""),
+        new("f16", "\"f16\""),
+        new("F16", "F16"),
+        new("Ff16", "\"Ff16\""),
+        new("FF16", "FF16"),
+        new(" ", "\" \""),
+        new(" F", "\" F\""),
+        new(" ,", "\" ,\""),
+        new("😅", "\"😅\""),
+        new("\"foo\"", "\"\"\"foo\"\"\""),
+        new("\"fOo\"", "\"\"\"fOo\"\"\""),
+        new("\"f.f\"", "\"\"\"f.f\"\"\""),
+        new("foo\"bar\"", "\"foo\"\"bar\"\"\""),
+        new("foo\"bar", "\"foo\"\"bar\""),
+    ];
+
+    private static string[] MalformedIdentifiers() =>
+    [
+        " ", "foo-1", "f.f", "f f", "f\"f", "f\"\"f", "\"foo", "\"fo\"o\"", 
"1o0", "@#$", "😅", "f😅", "$foo", "foo$"
+    ];
+
+    private static TestCaseData[] ValidUnquotedIdentifiers() =>
+    [
+        new("foo", "FOO"),
+        new("fOo", "FOO"),
+        new("FOO", "FOO"),
+        new("fo_o", "FO_O"),
+        new("_foo", "_FOO"),
+    ];
+
+    private static TestCaseData[] ValidQuotedIdentifiers() =>
+    [
+        new("\"FOO\"", "FOO"),
+        new("\"foo\"", "foo"),
+        new("\"fOo\"", "fOo"),
+        new("\"$fOo\"", "$fOo"),
+        new("\"f.f\"", "f.f"),
+        new("\"f\"\"f\"", "f\"f"),
+        new("\" \"", " "),
+        new("\"   \"", "   "),
+        new("\",\"", ","),
+        new("\"😅\"", "😅"),
+        new("\"f😅\"", "f😅"),
+    ];
+}
diff --git 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/KeyColumnOrderTests.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/KeyColumnOrderTests.cs
index 1566b69017b..9ddaebf0be5 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/KeyColumnOrderTests.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/KeyColumnOrderTests.cs
@@ -26,8 +26,7 @@ using NUnit.Framework;
 /// </summary>
 public class KeyColumnOrderTests : IgniteTestsBase
 {
-    // TODO https://issues.apache.org/jira/browse/IGNITE-24258: Revert 
changing names to uppercase.
-    private static readonly string[] Tables = { "TEST1", "TEST2", "TEST3", 
"TEST4" };
+    private static readonly string[] Tables = ["TEST1", "TEST2", "TEST3", 
"TEST4"];
 
     private int _key;
 
diff --git 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/KeyValueViewBinaryTests.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/KeyValueViewBinaryTests.cs
index f00480bf3d6..59233b716c7 100644
--- 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/KeyValueViewBinaryTests.cs
+++ 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/KeyValueViewBinaryTests.cs
@@ -294,6 +294,6 @@ public class KeyValueViewBinaryTests : IgniteTestsBase
     [Test]
     public void TestToString()
     {
-        StringAssert.StartsWith("KeyValueView`2[IIgniteTuple, IIgniteTuple] { 
Table = Table { Name = TBL1, Id =", KvView.ToString());
+        StringAssert.StartsWith("KeyValueView`2[IIgniteTuple, IIgniteTuple] { 
Table = Table { Name = PUBLIC.TBL1, Id =", KvView.ToString());
     }
 }
diff --git 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/KeyValueViewPocoTests.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/KeyValueViewPocoTests.cs
index a8269ddb6ab..ae86dbd11c9 100644
--- 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/KeyValueViewPocoTests.cs
+++ 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/KeyValueViewPocoTests.cs
@@ -294,6 +294,6 @@ public class KeyValueViewPocoTests : IgniteTestsBase
     [Test]
     public void TestToString()
     {
-        StringAssert.StartsWith("KeyValueView`2[KeyPoco, ValPoco] { Table = 
Table { Name = TBL1, Id =", KvView.ToString());
+        StringAssert.StartsWith("KeyValueView`2[KeyPoco, ValPoco] { Table = 
Table { Name = PUBLIC.TBL1, Id =", KvView.ToString());
     }
 }
diff --git 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/KeyValueViewPrimitiveTests.cs
 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/KeyValueViewPrimitiveTests.cs
index d0c9fba9f30..9fd2ac71830 100644
--- 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/KeyValueViewPrimitiveTests.cs
+++ 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/KeyValueViewPrimitiveTests.cs
@@ -385,7 +385,7 @@ public class KeyValueViewPrimitiveTests : IgniteTestsBase
     [Test]
     public void TestToString()
     {
-        StringAssert.StartsWith("KeyValueView`2[Int64, String] { Table = Table 
{ Name = TBL1, Id =", KvView.ToString());
+        StringAssert.StartsWith("KeyValueView`2[Int64, String] { Table = Table 
{ Name = PUBLIC.TBL1, Id =", KvView.ToString());
     }
 
     private static async Task TestKey<TK, TV>(TK key, TV val, 
IKeyValueView<TK, TV> kvView)
diff --git 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/QualifiedNameTests.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/QualifiedNameTests.cs
new file mode 100644
index 00000000000..6b85dbfabdd
--- /dev/null
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/QualifiedNameTests.cs
@@ -0,0 +1,193 @@
+/*
+ * 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.Diagnostics.CodeAnalysis;
+using Ignite.Table;
+using NUnit.Framework;
+
+/// <summary>
+/// Tests for <see cref="QualifiedName"/>.
+/// </summary>
+[SuppressMessage("ReSharper", "ObjectCreationAsStatement", Justification = 
"Tests")]
+public class QualifiedNameTests
+{
+    [Test]
+    public void TestInvalidNullNames()
+    {
+        Assert.Throws<ArgumentNullException>(() => QualifiedName.Parse(null!));
+        Assert.Throws<ArgumentNullException>(() => QualifiedName.Of("s1", 
null!));
+        Assert.Throws<ArgumentNullException>(() => QualifiedName.Of(null, 
null!));
+    }
+
+    [Test]
+    public void TestDefaultSchemaName()
+    {
+        Assert.AreEqual(QualifiedName.DefaultSchemaName, 
QualifiedName.Parse("foo").SchemaName);
+        Assert.AreEqual(QualifiedName.DefaultSchemaName, 
QualifiedName.Of(null, "foo").SchemaName);
+    }
+
+    [Test]
+    public void TestCanonicalForm()
+    {
+        Assert.AreEqual("FOO.BAR", 
QualifiedName.Parse("foo.bar").CanonicalName);
+        Assert.AreEqual("\"foo\".\"bar\"", 
QualifiedName.Parse("\"foo\".\"bar\"").CanonicalName);
+    }
+
+    [Test]
+    [TestCaseSource(nameof(ValidSimpleNames))]
+    public void TestValidSimpleNames(string actual, string expectedIdentifier)
+    {
+        var simple = QualifiedName.Of(null, actual);
+        var parsed = QualifiedName.Parse(actual);
+
+        Assert.AreEqual(expectedIdentifier, simple.ObjectName);
+        Assert.AreEqual(expectedIdentifier, parsed.ObjectName);
+
+        Assert.AreEqual(parsed, simple);
+    }
+
+    [Test]
+    [TestCaseSource(nameof(ValidCanonicalNames))]
+    public void TestValidCanonicalNames(string source, string 
schemaIdentifier, string objectIdentifier)
+    {
+        var parsed = QualifiedName.Parse(source);
+
+        Assert.AreEqual(schemaIdentifier, parsed.SchemaName);
+        Assert.AreEqual(objectIdentifier, parsed.ObjectName);
+
+        Assert.AreEqual(parsed, QualifiedName.Parse(parsed.CanonicalName));
+    }
+
+    [Test]
+    [TestCaseSource(nameof(MalformedSimpleNames))]
+    public void TestMalformedSimpleNames(string source)
+    {
+        Assert.Throws<ArgumentException>(() => QualifiedName.Of(null, source));
+        Assert.Throws<ArgumentException>(() => QualifiedName.Parse(source));
+        Assert.Throws<ArgumentException>(() => QualifiedName.Of(source, 
"bar"));
+    }
+
+    [Test]
+    public void TestUnexpectedCanonicalName()
+    {
+        string canonicalName = "f.f";
+
+        Assert.Throws<ArgumentException>(() => QualifiedName.Of(canonicalName, 
"bar"));
+        Assert.Throws<ArgumentException>(() => QualifiedName.Of(null, 
canonicalName));
+    }
+
+    [Test]
+    [TestCaseSource(nameof(MalformedCanonicalNames))]
+    public void TestMalformedCanonicalNames(string source)
+    {
+        Assert.Throws<ArgumentException>(() => QualifiedName.Parse(source));
+    }
+
+    [Test]
+    [TestCase("x.", "Canonical name can't have empty parts: 'x.'")]
+    [TestCase(".x", "Invalid identifier start '.' at position 0: '.x'. 
Unquoted identifiers must begin with a letter or an underscore.")]
+    [TestCase("\"x", "Missing closing quote: '\"x")]
+    [TestCase("y.\"x", "Missing closing quote: 'y.\"x")]
+    [TestCase("\"xx\"yy\"", "Unexpected character '\"' after quote at position 
3: '\"xx\"yy\"'")]
+    [TestCase("123", "Invalid identifier start '1' at position 0: '123'. 
Unquoted identifiers must begin with a letter or an underscore.")]
+    [TestCase("x.y.z", "Canonical name should have at most two parts: 
'x.y.z'")]
+    public void TestParsingErrors(string name, string expectedError)
+    {
+        var ex = Assert.Throws<ArgumentException>(() => 
QualifiedName.Parse(name));
+        Assert.AreEqual(expectedError, ex.Message);
+    }
+
+    private static TestCaseData[] ValidSimpleNames() =>
+    [
+        new("foo", "FOO"),
+        new("fOo", "FOO"),
+        new("FOO", "FOO"),
+        new("f23", "F23"),
+        new("\"23f\"", "23f"),
+        new("foo_", "FOO_"),
+        new("foo_1", "FOO_1"),
+        new("_foo", "_FOO"),
+        new("__foo", "__FOO"),
+        new("\"FOO\"", "FOO"),
+        new("\"foo\"", "foo"),
+        new("\"fOo\"", "fOo"),
+        new("\"_foo\"", "_foo"),
+        new("\"$foo\"", "$foo"),
+        new("\"%foo\"", "%foo"),
+        new("\"foo_\"", "foo_"),
+        new("\"foo$\"", "foo$"),
+        new("\"foo%\"", "foo%"),
+        new("\"@#$\"", "@#$"),
+        new("\"f.f\"", "f.f"),
+        new("\"   \"", "   "),
+        new("\"😅\"", "😅"),
+        new("\"f\"\"f\"", "f\"f"),
+        new("\"f\"\"\"\"f\"", "f\"\"f"),
+        new("\"\"\"bar\"\"\"", "\"bar\""),
+        new("\"\"\"\"\"bar\"\"\"", "\"\"bar\"")
+    ];
+
+    private static TestCaseData[] MalformedSimpleNames() =>
+    [
+        new(string.Empty),
+        new(" "),
+        new(".f"),
+        new("f."),
+        new("."),
+        new("f f"),
+        new("1o0"),
+        new("@#$"),
+        new("foo$"),
+        new("foo%"),
+        new("foo&"),
+        new("f😅"),
+        new("😅f"),
+        new("f\"f"),
+        new("f\"\"f"),
+        new("\"foo"),
+        new("\"fo\"o\""),
+    ];
+
+    private static TestCaseData[] MalformedCanonicalNames() =>
+    [
+        new("foo."),
+        new(".bar"),
+        new("."),
+        new("foo..bar"),
+        new("foo.bar."),
+        new("foo.."),
+        new("@#$.bar"),
+        new("foo.@#$"),
+        new("@#$"),
+        new("1oo.bar"),
+        new("foo.1ar"),
+        new("1oo")
+    ];
+
+    private static TestCaseData[] ValidCanonicalNames() =>
+    [
+        new("\"foo.bar\".baz", "foo.bar", "BAZ"),
+        new("foo.\"bar.baz\"", "FOO", "bar.baz"),
+        new("\"foo.\"\"bar\"\"\".baz", "foo.\"bar\"", "BAZ"),
+        new("foo.\"bar.\"\"baz\"", "FOO", "bar.\"baz"),
+        new("_foo.bar", "_FOO", "BAR"),
+        new("foo._bar", "FOO", "_BAR")
+    ];
+}
diff --git 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewBinaryTests.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewBinaryTests.cs
index c8fe1527fc3..ca386ad802f 100644
--- 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewBinaryTests.cs
+++ 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewBinaryTests.cs
@@ -600,7 +600,7 @@ namespace Apache.Ignite.Tests.Table
         [Test]
         public void TestToString()
         {
-            StringAssert.StartsWith("RecordView`1[IIgniteTuple] { Table = 
Table { Name = TBL1, Id =", TupleView.ToString());
+            StringAssert.StartsWith("RecordView`1[IIgniteTuple] { Table = 
Table { Name = PUBLIC.TBL1, Id =", TupleView.ToString());
         }
     }
 }
diff --git 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewPocoTests.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewPocoTests.cs
index f4d00de97ee..59b3853245d 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewPocoTests.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/RecordViewPocoTests.cs
@@ -572,7 +572,6 @@ namespace Apache.Ignite.Tests.Table
             using var deferDropTable = new DisposeAction(
                 () => Client.Sql.ExecuteAsync(null, "DROP TABLE 
TestBigPoco").GetAwaiter().GetResult());
 
-            // TODO https://issues.apache.org/jira/browse/IGNITE-24258: Remove 
uppercase.
             var table = await 
Client.Tables.GetTableAsync("TestBigPoco".ToUpperInvariant());
             var pocoView = table!.GetRecordView<Poco2>();
 
@@ -869,7 +868,7 @@ namespace Apache.Ignite.Tests.Table
         [Test]
         public void TestToString()
         {
-            StringAssert.StartsWith("RecordView`1[Poco] { Table = Table { Name 
= TBL1, Id =", PocoView.ToString());
+            StringAssert.StartsWith("RecordView`1[Poco] { Table = Table { Name 
= PUBLIC.TBL1, Id =", PocoView.ToString());
         }
 
         // ReSharper disable NotAccessedPositionalProperty.Local
diff --git 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/SchemaSynchronizationTest.cs
 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/SchemaSynchronizationTest.cs
index d2e15319f7e..7974889d406 100644
--- 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/SchemaSynchronizationTest.cs
+++ 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/SchemaSynchronizationTest.cs
@@ -48,7 +48,7 @@ public class SchemaSynchronizationTest : IgniteTestsBase
         .Replace("(", "_")
         .Replace(",", "_")
         .Replace(")", string.Empty)
-        .ToUpperInvariant(); // TODO 
https://issues.apache.org/jira/browse/IGNITE-24258: Remove uppercase.
+        .ToUpperInvariant();
 
     [TearDown]
     public async Task DeleteTable() => await Client.Sql.ExecuteAsync(null, 
$"DROP TABLE {TestTableName}");
diff --git 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/SchemaValidationTest.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/SchemaValidationTest.cs
index 5d9c5f76e89..41d0d03be93 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/SchemaValidationTest.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/SchemaValidationTest.cs
@@ -40,8 +40,7 @@ public class SchemaValidationTest : IgniteTestsBase
     {
         await Client.Sql.ExecuteAsync(null, $"CREATE TABLE 
{TableNameRequiredVal} (KEY BIGINT PRIMARY KEY, VAL VARCHAR NOT NULL)");
 
-        // TODO https://issues.apache.org/jira/browse/IGNITE-24258: Remove 
uppercase.
-        TableRequiredVal = (await 
Client.Tables.GetTableAsync(TableNameRequiredVal.ToUpperInvariant()))!;
+        TableRequiredVal = (await 
Client.Tables.GetTableAsync(TableNameRequiredVal))!;
     }
 
     [OneTimeTearDown]
diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/TablesTests.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/TablesTests.cs
index 9c7749682ad..c3ba4f7db8e 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/TablesTests.cs
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/TablesTests.cs
@@ -32,10 +32,14 @@ namespace Apache.Ignite.Tests.Table
         public async Task TestGetTables()
         {
             var tables = (await Client.Tables.GetTablesAsync()).OrderBy(x => 
x.Name).ToList();
+            var tableNames = tables.Select(x => 
x.QualifiedName.ObjectName).ToArray();
 
             Assert.GreaterOrEqual(tables.Count, 2);
-            CollectionAssert.Contains(tables.Select(x => x.Name), TableName);
-            CollectionAssert.Contains(tables.Select(x => x.Name), 
TableAllColumnsName);
+
+            CollectionAssert.Contains(tableNames, TableName);
+            CollectionAssert.Contains(tableNames, TableAllColumnsName);
+
+            Assert.AreEqual(QualifiedName.DefaultSchemaName, 
tables[0].QualifiedName.SchemaName);
         }
 
         [Test]
@@ -44,7 +48,20 @@ namespace Apache.Ignite.Tests.Table
             var table = await Client.Tables.GetTableAsync(TableName);
 
             Assert.IsNotNull(table);
-            Assert.AreEqual(TableName, table!.Name);
+            Assert.AreEqual(TableName, table!.QualifiedName.ObjectName);
+            Assert.AreEqual(QualifiedName.DefaultSchemaName, 
table.QualifiedName.SchemaName);
+            Assert.AreEqual(QualifiedName.Parse(TableName), 
table.QualifiedName);
+        }
+
+        [Test]
+        public async Task TestGetExistingTableQuoted()
+        {
+            var table = await 
Client.Tables.GetTableAsync("\"PUBLIC\".\"TBL1\"");
+
+            Assert.IsNotNull(table);
+            Assert.AreEqual(TableName, table!.QualifiedName.ObjectName);
+            Assert.AreEqual(QualifiedName.DefaultSchemaName, 
table.QualifiedName.SchemaName);
+            Assert.AreEqual(QualifiedName.Parse(TableName), 
table.QualifiedName);
         }
 
         [Test]
@@ -53,7 +70,17 @@ namespace Apache.Ignite.Tests.Table
             var table = await Client.Tables.GetTableAsync("tBl1");
 
             Assert.IsNotNull(table);
-            Assert.AreEqual("TBL1", table!.Name);
+            Assert.AreEqual("PUBLIC.TBL1", table!.Name);
+        }
+
+        [Test]
+        public async Task TestGetTableByQualifiedNameReturnsActualName()
+        {
+            var table = await 
Client.Tables.GetTableAsync(QualifiedName.Of("public", "tbL1"));
+
+            Assert.IsNotNull(table);
+            Assert.AreEqual("PUBLIC.TBL1", table!.Name);
+            Assert.AreEqual("TBL1", table.QualifiedName.ObjectName);
         }
 
         [Test]
@@ -61,11 +88,17 @@ namespace Apache.Ignite.Tests.Table
         {
             var table = await Client.Tables.GetTableAsync(TableName);
             var table2 = await Client.Tables.GetTableAsync(TableName);
+            var table3 = await 
Client.Tables.GetTableAsync(QualifiedName.Parse(TableName));
 
             // Tables and views are cached to avoid extra allocations and 
serializer handler initializations.
             Assert.AreSame(table, table2);
+            Assert.AreSame(table, table3);
+
             Assert.AreSame(table!.RecordBinaryView, table2!.RecordBinaryView);
+            Assert.AreSame(table!.RecordBinaryView, table3!.RecordBinaryView);
+
             Assert.AreSame(table.GetRecordView<Poco>(), 
table2.GetRecordView<Poco>());
+            Assert.AreSame(table.GetRecordView<Poco>(), 
table3.GetRecordView<Poco>());
         }
 
         [Test]
@@ -83,7 +116,7 @@ namespace Apache.Ignite.Tests.Table
             _ = await Client.Tables.GetTablesAsync();
 
             StringAssert.StartsWith("Tables { CachedTables = [ Table { Name = 
", Client.Tables.ToString());
-            StringAssert.Contains("{ Name = TBL_STRING, Id = ", 
Client.Tables.ToString());
+            StringAssert.Contains("{ Name = PUBLIC.TBL_STRING, Id = ", 
Client.Tables.ToString());
         }
     }
 }
diff --git a/modules/platforms/dotnet/Apache.Ignite/ClientOperationType.cs 
b/modules/platforms/dotnet/Apache.Ignite/ClientOperationType.cs
index 037b47afe6d..fe49f196552 100644
--- a/modules/platforms/dotnet/Apache.Ignite/ClientOperationType.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/ClientOperationType.cs
@@ -32,7 +32,7 @@ namespace Apache.Ignite
         TablesGet,
 
         /// <summary>
-        /// Get table (<see cref="ITables.GetTableAsync"/>).
+        /// Get table (<see cref="ITables.GetTableAsync(string)"/>).
         /// </summary>
         TableGet,
 
diff --git a/modules/platforms/dotnet/Apache.Ignite/Compute/JobTarget.cs 
b/modules/platforms/dotnet/Apache.Ignite/Compute/JobTarget.cs
index d1b0e8e3b0b..0c635f89960 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Compute/JobTarget.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Compute/JobTarget.cs
@@ -20,6 +20,7 @@ namespace Apache.Ignite.Compute;
 using System.Collections.Generic;
 using Internal.Common;
 using Network;
+using Table;
 
 /// <summary>
 /// Compute job target.
@@ -69,15 +70,25 @@ public static class JobTarget
     /// <param name="key">Key.</param>
     /// <typeparam name="TKey">Key type.</typeparam>
     /// <returns>Colocated job target.</returns>
-    public static IJobTarget<TKey> Colocated<TKey>(string tableName, TKey key)
+    public static IJobTarget<TKey> Colocated<TKey>(QualifiedName tableName, 
TKey key)
         where TKey : notnull
     {
-        IgniteArgumentCheck.NotNull(tableName);
         IgniteArgumentCheck.NotNull(key);
 
         return new ColocatedTarget<TKey>(tableName, key);
     }
 
+    /// <summary>
+    /// Creates a colocated job target for a specific table and key.
+    /// </summary>
+    /// <param name="tableName">Table name.</param>
+    /// <param name="key">Key.</param>
+    /// <typeparam name="TKey">Key type.</typeparam>
+    /// <returns>Colocated job target.</returns>
+    public static IJobTarget<TKey> Colocated<TKey>(string tableName, TKey key)
+        where TKey : notnull =>
+        Colocated(QualifiedName.Parse(tableName), key);
+
     /// <summary>
     /// Single node job target.
     /// </summary>
@@ -96,6 +107,6 @@ public static class JobTarget
     /// <param name="TableName">Table name.</param>
     /// <param name="Data">Key.</param>
     /// <typeparam name="TKey">Key type.</typeparam>
-    internal sealed record ColocatedTarget<TKey>(string TableName, TKey Data) 
: IJobTarget<TKey>
+    internal sealed record ColocatedTarget<TKey>(QualifiedName TableName, TKey 
Data) : IJobTarget<TKey>
         where TKey : notnull;
 }
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/ClientFailoverSocket.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/ClientFailoverSocket.cs
index 5678025dba9..7c8fb5f0b6a 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/ClientFailoverSocket.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/ClientFailoverSocket.cs
@@ -205,6 +205,37 @@ namespace Apache.Ignite.Internal
                 return (buffer, tx.Socket);
             }
 
+            return await DoWithRetryAsync(
+                (clientOp, request, expectNotifications),
+                static (_, arg) => arg.clientOp,
+                async static (socket, arg) =>
+                {
+                    var res = await socket.DoOutInOpAsync(arg.clientOp, 
arg.request, arg.expectNotifications).ConfigureAwait(false);
+                    return (Buffer: res, Socket: socket);
+                },
+                preferredNode,
+                retryPolicyOverride)
+                .ConfigureAwait(false);
+        }
+
+        /// <summary>
+        /// Performs a socket operation with retry and reconnect.
+        /// </summary>
+        /// <param name="arg">Func argument.</param>
+        /// <param name="opFunc">Client op func.</param>
+        /// <param name="func">Result func.</param>
+        /// <param name="preferredNode">Preferred node.</param>
+        /// <param name="retryPolicyOverride">Retry policy.</param>
+        /// <typeparam name="T">Result type.</typeparam>
+        /// <typeparam name="TArg">Arg type.</typeparam>
+        /// <returns>Result.</returns>
+        public async Task<T> DoWithRetryAsync<T, TArg>(
+            TArg arg,
+            Func<ClientSocket?, TArg, ClientOp> opFunc,
+            Func<ClientSocket, TArg, Task<T>> func,
+            PreferredNode preferredNode = default,
+            IRetryPolicy? retryPolicyOverride = null)
+        {
             var attempt = 0;
             List<Exception>? errors = null;
 
@@ -216,9 +247,7 @@ namespace Apache.Ignite.Internal
                 {
                     socket = await 
GetSocketAsync(preferredNode).ConfigureAwait(false);
 
-                    var buffer = await socket.DoOutInOpAsync(clientOp, 
request, expectNotifications).ConfigureAwait(false);
-
-                    return (buffer, socket);
+                    return await func(socket, arg).ConfigureAwait(false);
                 }
                 catch (Exception e)
                 {
@@ -232,7 +261,7 @@ namespace Apache.Ignite.Internal
 
                     IRetryPolicy retryPolicy = retryPolicyOverride ?? 
Configuration.RetryPolicy;
 
-                    if (!HandleOpError(e, clientOp, ref attempt, ref errors, 
retryPolicy, metricsContext))
+                    if (!HandleOpError(e, opFunc(socket, arg), ref attempt, 
ref errors, retryPolicy, metricsContext))
                     {
                         throw;
                     }
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/ClientSocket.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/ClientSocket.cs
index 2f30e2f316c..566fd92cd99 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/ClientSocket.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/ClientSocket.cs
@@ -19,6 +19,7 @@ namespace Apache.Ignite.Internal
 {
     using System;
     using System.Buffers.Binary;
+    using System.Collections;
     using System.Collections.Concurrent;
     using System.Diagnostics;
     using System.Diagnostics.CodeAnalysis;
@@ -410,7 +411,10 @@ namespace Apache.Ignite.Internal
             reader.Skip(); // Patch.
             reader.Skip(); // Pre-release.
 
-            reader.Skip(); // Features, binary.
+            ReadOnlySpan<byte> featureBits = reader.ReadBinary();
+            ProtocolBitmaskFeature features = featureBits.Length > 0
+                ? (ProtocolBitmaskFeature)featureBits[0] // Only one byte is 
used for now.
+                : 0;
 
             int extensionMapSize = reader.ReadInt32();
             for (int i = 0; i < extensionMapSize; i++)
@@ -425,7 +429,8 @@ namespace Apache.Ignite.Internal
                 new ClusterNode(clusterNodeId, clusterNodeName, 
endPoint.EndPoint, endPoint.MetricsContext),
                 clusterIds,
                 clusterName,
-                sslInfo);
+                sslInfo,
+                features);
         }
 
         private static IgniteException ReadError(ref MsgPackReader reader)
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Compute/Compute.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Compute/Compute.cs
index 82fcaef2be8..5924b639b92 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Compute/Compute.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Compute/Compute.cs
@@ -49,7 +49,7 @@ namespace Apache.Ignite.Internal.Compute
         private readonly Tables _tables;
 
         /** Cached tables. */
-        private readonly ConcurrentDictionary<string, Table> _tableCache = 
new();
+        private readonly ConcurrentDictionary<QualifiedName, Table> 
_tableCache = new();
 
         /// <summary>
         /// Initializes a new instance of the <see cref="Compute"/> class.
@@ -414,7 +414,7 @@ namespace Apache.Ignite.Internal.Compute
             }
         }
 
-        private async Task<Table> GetTableAsync(string tableName)
+        private async Task<Table> GetTableAsync(QualifiedName tableName)
         {
             if (_tableCache.TryGetValue(tableName, out var cachedTable))
             {
@@ -431,12 +431,12 @@ namespace Apache.Ignite.Internal.Compute
 
             _tableCache.TryRemove(tableName, out _);
 
-            throw new 
IgniteClientException(ErrorGroups.Client.TableIdNotFound, $"Table '{tableName}' 
does not exist.");
+            throw new 
IgniteClientException(ErrorGroups.Client.TableIdNotFound, $"Table 
'{tableName.CanonicalName}' does not exist.");
         }
 
         [SuppressMessage("Maintainability", "CA1508:Avoid dead conditional 
code", Justification = "False positive")]
         private async Task<IJobExecution<TResult>> ExecuteColocatedAsync<TArg, 
TResult, TKey>(
-            string tableName,
+            QualifiedName tableName,
             TKey key,
             Func<Table, IRecordSerializerHandler<TKey>> serializerHandlerFunc,
             JobDescriptor<TArg, TResult> descriptor,
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/ConnectionContext.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/ConnectionContext.cs
index 413febc1609..61ff2cedd5b 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/ConnectionContext.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/ConnectionContext.cs
@@ -21,6 +21,7 @@ namespace Apache.Ignite.Internal
     using System.Collections.Generic;
     using Ignite.Network;
     using Network;
+    using Proto;
 
     /// <summary>
     /// Socket connection context.
@@ -31,17 +32,27 @@ namespace Apache.Ignite.Internal
     /// <param name="ClusterIds">Cluster ids, from oldest to newest.</param>
     /// <param name="ClusterName">Cluster name.</param>
     /// <param name="SslInfo">SSL info.</param>
+    /// <param name="Features">Protocol features.</param>
     internal record ConnectionContext(
         ClientProtocolVersion Version,
         TimeSpan IdleTimeout,
         ClusterNode ClusterNode,
         IReadOnlyList<Guid> ClusterIds,
         string ClusterName,
-        ISslInfo? SslInfo)
+        ISslInfo? SslInfo,
+        ProtocolBitmaskFeature Features)
     {
         /// <summary>
         /// Gets the current cluster id.
         /// </summary>
         public Guid ClusterId => ClusterIds[^1];
+
+        /// <summary>
+        /// Gets a value indicating whether the server supports the specified 
feature.
+        /// </summary>
+        /// <param name="feature">Feature flag.</param>
+        /// <returns>True if the server supports the specified feature; false 
otherwise.</returns>
+        public bool ServerHasFeature(ProtocolBitmaskFeature feature) =>
+            (Features & feature) == feature;
     }
 }
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/ExpressionWalker.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/ExpressionWalker.cs
index 5869eb53ff9..7280ad9af03 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/ExpressionWalker.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/ExpressionWalker.cs
@@ -336,5 +336,5 @@ internal static class ExpressionWalker
     /// </summary>
     /// <param name="queryable">Queryable.</param>
     /// <returns>Table name with schema.</returns>
-    public static string GetTableNameWithSchema(IIgniteQueryableInternal 
queryable) => $"PUBLIC.{queryable.TableName}";
+    public static string GetTableNameWithSchema(IIgniteQueryableInternal 
queryable) => queryable.TableName.CanonicalName;
 }
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/IIgniteQueryableInternal.cs
 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/IIgniteQueryableInternal.cs
index 1a67698f1f7..1685b827d48 100644
--- 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/IIgniteQueryableInternal.cs
+++ 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/IIgniteQueryableInternal.cs
@@ -17,6 +17,7 @@
 
 namespace Apache.Ignite.Internal.Linq;
 
+using Ignite.Table;
 using Remotion.Linq;
 
 /// <summary>
@@ -27,7 +28,7 @@ internal interface IIgniteQueryableInternal
     /// <summary>
     /// Gets the table name.
     /// </summary>
-    string TableName { get; }
+    QualifiedName TableName { get; }
 
     /// <summary>
     /// Gets the provider.
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/IgniteQueryProvider.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/IgniteQueryProvider.cs
index fc0e2ca918c..21f2c5e6300 100644
--- 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/IgniteQueryProvider.cs
+++ 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/IgniteQueryProvider.cs
@@ -22,6 +22,7 @@ using System.Linq;
 using System.Linq.Expressions;
 using System.Reflection;
 using System.Threading.Tasks;
+using Ignite.Table;
 using Remotion.Linq;
 using Remotion.Linq.Clauses.StreamedData;
 using Remotion.Linq.Parsing.Structure;
@@ -48,7 +49,7 @@ internal sealed class IgniteQueryProvider : IQueryProvider
     public IgniteQueryProvider(
         IQueryParser queryParser,
         IgniteQueryExecutor executor,
-        string tableName)
+        QualifiedName tableName)
     {
         _parser = queryParser;
         Executor = executor;
@@ -58,7 +59,7 @@ internal sealed class IgniteQueryProvider : IQueryProvider
     /// <summary>
     /// Gets the name of the table.
     /// </summary>
-    public string TableName { get; }
+    public QualifiedName TableName { get; }
 
     /// <summary>
     /// Gets the executor.
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/IgniteQueryable.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/IgniteQueryable.cs
index 1fca9052972..cc0e39ddf1f 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/IgniteQueryable.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/IgniteQueryable.cs
@@ -20,6 +20,7 @@ namespace Apache.Ignite.Internal.Linq;
 using System.Linq;
 using System.Linq.Expressions;
 using Common;
+using Ignite.Table;
 using Remotion.Linq;
 
 /// <summary>
@@ -51,7 +52,7 @@ internal sealed class IgniteQueryable<T>
     }
 
     /// <inheritdoc/>
-    public string TableName => IgniteQueryProvider.TableName;
+    public QualifiedName TableName => IgniteQueryProvider.TableName;
 
     /// <inheritdoc/>
     IgniteQueryProvider IIgniteQueryableInternal.Provider => 
IgniteQueryProvider;
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Proto/ClientOp.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Proto/ClientOp.cs
index abd0434484e..914fe321e45 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Proto/ClientOp.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Proto/ClientOp.cs
@@ -140,6 +140,12 @@ namespace Apache.Ignite.Internal.Proto
         PrimaryReplicasGet = 65,
 
         /** Send streamer batch with receiver. */
-        StreamerWithReceiverBatchSend = 66
+        StreamerWithReceiverBatchSend = 66,
+
+        /** Get tables with qualified names. */
+        TablesGetQualified = 71,
+
+        /** Get table by qualified name. */
+        TableGetQualified = 72
     }
 }
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Proto/ClientOpExtensions.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Proto/ClientOpExtensions.cs
index 829832d31ac..0b8df454ee3 100644
--- 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Proto/ClientOpExtensions.cs
+++ 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Proto/ClientOpExtensions.cs
@@ -33,8 +33,8 @@ namespace Apache.Ignite.Internal.Proto
         {
             return op switch
             {
-                ClientOp.TablesGet => ClientOperationType.TablesGet,
-                ClientOp.TableGet => ClientOperationType.TableGet,
+                ClientOp.TablesGet or ClientOp.TablesGetQualified => 
ClientOperationType.TablesGet,
+                ClientOp.TableGet or ClientOp.TableGetQualified => 
ClientOperationType.TableGet,
                 ClientOp.SchemasGet => null,
                 ClientOp.TupleUpsert => ClientOperationType.TupleUpsert,
                 ClientOp.TupleGet => ClientOperationType.TupleGet,
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/IIgniteQueryableInternal.cs
 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Proto/ProtocolBitmaskFeature.cs
similarity index 65%
copy from 
modules/platforms/dotnet/Apache.Ignite/Internal/Linq/IIgniteQueryableInternal.cs
copy to 
modules/platforms/dotnet/Apache.Ignite/Internal/Proto/ProtocolBitmaskFeature.cs
index 1a67698f1f7..1643f5163e7 100644
--- 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/IIgniteQueryableInternal.cs
+++ 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Proto/ProtocolBitmaskFeature.cs
@@ -15,34 +15,28 @@
  * limitations under the License.
  */
 
-namespace Apache.Ignite.Internal.Linq;
+namespace Apache.Ignite.Internal.Proto;
 
-using Remotion.Linq;
+using System;
 
 /// <summary>
-/// Internal queryable interface.
+/// Protocol bitmask features.
 /// </summary>
-internal interface IIgniteQueryableInternal
+[Flags]
+internal enum ProtocolBitmaskFeature
 {
     /// <summary>
-    /// Gets the table name.
+    /// User attributes in handshake.
     /// </summary>
-    string TableName { get; }
+    UserAttributes = 1,
 
     /// <summary>
-    /// Gets the provider.
+    /// Qualified name table requests.
     /// </summary>
-    IgniteQueryProvider Provider { get; }
+    TableReqsUseQualifiedName = 2,
 
     /// <summary>
-    /// Gets the query model.
+    /// Transaction direct mapping.
     /// </summary>
-    /// <returns>Query model.</returns>
-    QueryModel GetQueryModel();
-
-    /// <summary>
-    /// Gets the query data.
-    /// </summary>
-    /// <returns>Query data.</returns>
-    QueryData GetQueryData();
+    TxDirectMapping = 4
 }
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/IgniteNameUtils.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/IgniteNameUtils.cs
new file mode 100644
index 00000000000..2683eb7e95c
--- /dev/null
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/IgniteNameUtils.cs
@@ -0,0 +1,227 @@
+/*
+ * 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.Globalization;
+
+/// <summary>
+/// Ignite name utilities.
+/// Logic converted from Java 
<c>org.apache.ignite.lang.util.IgniteNameUtils</c>.
+/// </summary>
+internal static class IgniteNameUtils
+{
+    /// <summary>
+    /// Separator character between schema and object names.
+    /// </summary>
+    public const char SeparatorChar = '.';
+
+    /// <summary>
+    /// Quote character for identifiers.
+    /// </summary>
+    public const char QuoteChar = '"';
+
+    /// <summary>
+    /// Verifies that the specified identifier is not null or empty.
+    /// </summary>
+    /// <param name="identifier">Identifier.</param>
+    public static void VerifyObjectIdentifier(string identifier) =>
+        ArgumentException.ThrowIfNullOrEmpty(identifier);
+
+    /// <summary>
+    /// Unquotes the specified identifier, or converts it to upper case if it 
is not quoted.
+    /// </summary>
+    /// <param name="identifier">Identifier.</param>
+    /// <returns>Unquoted or uppercased identifier.</returns>
+    public static string Unquote(ReadOnlyMemory<char> identifier)
+    {
+        if (identifier.IsEmpty)
+        {
+            return string.Empty;
+        }
+
+        return identifier.Span[0] == QuoteChar
+            ? identifier[1..^1].ToString().Replace("\"\"", "\"", 
StringComparison.Ordinal) // Escaped quotes are rare, don't optimize.
+            : identifier.ToStringUpperInvariant();
+    }
+
+    /// <summary>
+    /// Wraps the given name with double quotes if it is not uppercased 
non-quoted name,
+    /// e.g. "myColumn" -> "\"myColumn\"", "MYCOLUMN" -> "MYCOLUMN".
+    /// </summary>
+    /// <param name="identifier">Identifier.</param>
+    /// <returns>Quoted name.</returns>
+    public static string QuoteIfNeeded(string identifier)
+    {
+        ArgumentException.ThrowIfNullOrEmpty(identifier);
+
+        char ch = identifier[0];
+        if (!(char.IsUpper(ch) && IsIdentifierStart(ch)))
+        {
+            return Quote(identifier);
+        }
+
+        for (int pos = 1; pos < identifier.Length; pos++)
+        {
+            ch = identifier[pos];
+
+            if (!((char.IsUpper(ch) && IsIdentifierStart(ch)) || 
IsIdentifierExtend(ch)))
+            {
+                return Quote(identifier);
+            }
+        }
+
+        return identifier;
+    }
+
+    /// <summary>
+    /// Converts a memory of chars to an uppercase string.
+    /// </summary>
+    /// <param name="chars">Chars.</param>
+    /// <returns>Uppercased string.</returns>
+    public static string ToStringUpperInvariant(this ReadOnlyMemory<char> 
chars)
+    {
+        // In theory, converting to upper could produce a string longer than 
the original, but
+        // Span.ToUpperInvariant returns -1 only when target span is shorter 
than source span.
+        return string.Create(chars.Length, chars, static (span, args) => 
args.Span.ToUpperInvariant(span));
+    }
+
+    /// <summary>
+    /// Parses the identifier and returns it unquoted.
+    /// </summary>
+    /// <param name="identifier">Identifier.</param>
+    /// <returns>Parsed identifier.</returns>
+    public static string ParseIdentifier(string identifier)
+    {
+        if (IndexOfSeparatorChar(identifier, 0) is var separatorPos && 
separatorPos != -1)
+        {
+            throw new ArgumentException($"Unexpected separator at position 
{separatorPos}: '{identifier}'");
+        }
+
+        return Unquote(identifier.AsMemory());
+    }
+
+    /// <summary>
+    /// Returns a value indicating whether the specified character is a valid 
identifier start character.
+    /// An identifier start is any character in the Unicode General Category 
classes “Lu”, “Ll”, “Lt”, “Lm”, “Lo”, or “Nl”.
+    /// </summary>
+    /// <param name="c">Char.</param>
+    /// <returns>Whether the specified character is a valid identifier start 
character.</returns>
+    public static bool IsIdentifierStart(char c) =>
+        char.IsLetter(c) || c == '_';
+
+    /// <summary>
+    /// Returns a value indicating whether the specified character is a valid 
identifier extend character.
+    /// An identifier extend is U+00B7, or any character in the Unicode 
General Category classes “Mn”, “Mc”, “Nd”, “Pc”, or “Cf”.
+    /// </summary>
+    /// <param name="ch">Char.</param>
+    /// <returns>Whether the specified character is a valid identifier extend 
character.</returns>
+    public static bool IsIdentifierExtend(char ch)
+    {
+        return ch == ('·' & 0xff) || /* “Middle Dot” character */
+               char.GetUnicodeCategory(ch) == UnicodeCategory.NonSpacingMark ||
+               char.GetUnicodeCategory(ch) == 
UnicodeCategory.SpacingCombiningMark ||
+               char.GetUnicodeCategory(ch) == 
UnicodeCategory.DecimalDigitNumber ||
+               char.GetUnicodeCategory(ch) == 
UnicodeCategory.ConnectorPunctuation ||
+               char.GetUnicodeCategory(ch) == UnicodeCategory.Format;
+    }
+
+    /// <summary>
+    /// Finds the index of the first <see cref="SeparatorChar"/> in the 
specified identifier, respecting quotes.
+    /// </summary>
+    /// <param name="name">Identifier.</param>
+    /// <param name="startIndex">Start index.</param>
+    /// <returns>Index of the <see cref="SeparatorChar"/>, or -1 when not 
found.</returns>
+    public static int IndexOfSeparatorChar(string name, int startIndex)
+    {
+        if (startIndex >= name.Length)
+        {
+            return -1;
+        }
+
+        bool quoted = name[startIndex] == QuoteChar;
+        int pos = quoted ? startIndex + 1 : startIndex;
+
+        if (!quoted && !IsIdentifierStart(name[pos]))
+        {
+            throw new ArgumentException(
+                $"Invalid identifier start '{name[pos]}' at position {pos}: 
'{name}'. " +
+                $"Unquoted identifiers must begin with a letter or an 
underscore.");
+        }
+
+        for (; pos < name.Length; pos++)
+        {
+            char ch = name[pos];
+
+            if (ch == QuoteChar)
+            {
+                if (!quoted)
+                {
+                    throw new ArgumentException($"Identifier is not quoted, 
but contains quote character at position {pos}: '{name}'");
+                }
+
+                char? nextCh = pos + 1 < name.Length ? name[pos + 1] : null;
+                if (nextCh == QuoteChar)
+                {
+                    // Escaped quote.
+                    pos++;
+                    continue;
+                }
+
+                if (nextCh == SeparatorChar)
+                {
+                    // End of quoted identifier, separator follows.
+                    return pos + 1;
+                }
+
+                if (nextCh == null)
+                {
+                    // End of quoted identifier, no separator.
+                    return -1;
+                }
+
+                throw new ArgumentException($"Unexpected character '{ch}' 
after quote at position {pos}: '{name}'");
+            }
+
+            if (ch == SeparatorChar)
+            {
+                if (quoted)
+                {
+                    continue;
+                }
+
+                return pos;
+            }
+
+            if (!quoted && !IsIdentifierStart(ch) && !IsIdentifierExtend(ch))
+            {
+                throw new ArgumentException($"Unexpected character '{ch}' at 
position {pos}: '{name}'");
+            }
+        }
+
+        if (quoted)
+        {
+            throw new ArgumentException($"Missing closing quote: '{name}");
+        }
+
+        return -1;
+    }
+
+    private static string Quote(string name) =>
+        $"\"{name.Replace("\"", "\"\"", StringComparison.Ordinal)}\"";
+}
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/KeyValueView.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/KeyValueView.cs
index 058aebc1cb8..6c7c8376c88 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/KeyValueView.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/KeyValueView.cs
@@ -151,7 +151,7 @@ internal sealed class KeyValueView<TK, TV> : 
IKeyValueView<TK, TV>
     public IQueryable<KeyValuePair<TK, TV>> AsQueryable(ITransaction? 
transaction = null, QueryableOptions? options = null)
     {
         var executor = new IgniteQueryExecutor(_recordView.Sql, transaction, 
options, _recordView.Table.Socket.Configuration);
-        var provider = new IgniteQueryProvider(IgniteQueryParser.Instance, 
executor, _recordView.Table.Name);
+        var provider = new IgniteQueryProvider(IgniteQueryParser.Instance, 
executor, _recordView.Table.QualifiedName);
 
         return new IgniteQueryable<KeyValuePair<TK, TV>>(provider);
     }
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/RecordView.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/RecordView.cs
index 8245d13af64..71eb7c58c92 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/RecordView.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/RecordView.cs
@@ -89,7 +89,7 @@ namespace Apache.Ignite.Internal.Table
         public IQueryable<T> AsQueryable(ITransaction? transaction = null, 
QueryableOptions? options = null)
         {
             var executor = new IgniteQueryExecutor(_sql, transaction, options, 
Table.Socket.Configuration);
-            var provider = new IgniteQueryProvider(IgniteQueryParser.Instance, 
executor, _table.Name);
+            var provider = new IgniteQueryProvider(IgniteQueryParser.Instance, 
executor, _table.QualifiedName);
 
             if (typeof(T).IsKeyValuePair())
             {
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Table.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Table.cs
index 82d02ace5cc..6c7584fb1d9 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Table.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Table.cs
@@ -88,16 +88,16 @@ namespace Apache.Ignite.Internal.Table
         /// <summary>
         /// Initializes a new instance of the <see cref="Table"/> class.
         /// </summary>
-        /// <param name="name">Table name.</param>
+        /// <param name="qualifiedName">Table name.</param>
         /// <param name="id">Table id.</param>
         /// <param name="socket">Socket.</param>
         /// <param name="sql">SQL.</param>
-        public Table(string name, int id, ClientFailoverSocket socket, Sql sql)
+        public Table(QualifiedName qualifiedName, int id, ClientFailoverSocket 
socket, Sql sql)
         {
             _socket = socket;
             _sql = sql;
 
-            Name = name;
+            QualifiedName = qualifiedName;
             Id = id;
 
             _logger = socket.Configuration.LoggerFactory.CreateLogger<Table>();
@@ -120,7 +120,10 @@ namespace Apache.Ignite.Internal.Table
         }
 
         /// <inheritdoc/>
-        public string Name { get; }
+        public string Name => QualifiedName.CanonicalName;
+
+        /// <inheritdoc/>
+        public QualifiedName QualifiedName { get; }
 
         /// <inheritdoc/>
         public IRecordView<IIgniteTuple> RecordBinaryView { get; }
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Tables.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Tables.cs
index afa729aab79..7610470b30c 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Tables.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Tables.cs
@@ -20,6 +20,7 @@ namespace Apache.Ignite.Internal.Table
     using System.Collections.Concurrent;
     using System.Collections.Generic;
     using System.Threading.Tasks;
+    using Buffers;
     using Common;
     using Ignite.Table;
     using Proto;
@@ -52,39 +53,53 @@ namespace Apache.Ignite.Internal.Table
         }
 
         /// <inheritdoc/>
-        public async Task<ITable?> GetTableAsync(string name)
-        {
-            return await GetTableInternalAsync(name).ConfigureAwait(false);
-        }
+        public async Task<ITable?> GetTableAsync(string name) =>
+            await 
GetTableAsync(QualifiedName.Parse(name)).ConfigureAwait(false);
+
+        /// <inheritdoc/>
+        public async Task<ITable?> GetTableAsync(QualifiedName name) =>
+            await GetTableInternalAsync(name).ConfigureAwait(false);
 
         /// <inheritdoc/>
         public async Task<IList<ITable>> GetTablesAsync()
         {
-            using var resBuf = await 
_socket.DoOutInOpAsync(ClientOp.TablesGet).ConfigureAwait(false);
-            return Read(resBuf.GetReader());
+            return await _socket.DoWithRetryAsync(
+                this,
+                static (socket, _) => Op(socket),
+                async static (socket, tables) =>
+                {
+                    var op = Op(socket);
+                    using var resBuf = await 
socket.DoOutInOpAsync(op).ConfigureAwait(false);
+                    return Read(resBuf.GetReader(), tables, op);
+                })
+                .ConfigureAwait(false);
 
-            IList<ITable> Read(MsgPackReader r)
+            static IList<ITable> Read(MsgPackReader r, Tables tables, ClientOp 
op)
             {
                 var len = r.ReadInt32();
 
                 var res = new List<ITable>(len);
+                bool packedAsQualified = op == ClientOp.TablesGetQualified;
 
                 for (var i = 0; i < len; i++)
                 {
                     var id = r.ReadInt32();
-                    var name = r.ReadString();
+                    var qualifiedName = UnpackQualifiedName(ref r, 
packedAsQualified);
 
-                    var table = _cachedTables.GetOrAdd(
+                    var table = tables._cachedTables.GetOrAdd(
                         id,
-                        static (int id0, (string Name, Tables Tables) arg) =>
-                            new Table(arg.Name, id0, arg.Tables._socket, 
arg.Tables._sql),
-                        (name, this));
+                        static (int id0, (QualifiedName QualifiedName, Tables 
Tables) arg) =>
+                            new Table(arg.QualifiedName, id0, 
arg.Tables._socket, arg.Tables._sql),
+                        (qualifiedName, tables));
 
                     res.Add(table);
                 }
 
                 return res;
             }
+
+            static ClientOp Op(ClientSocket? socket) =>
+                UseQualifiedNames(socket) ? ClientOp.TablesGetQualified : 
ClientOp.TablesGet;
         }
 
         /// <inheritdoc />
@@ -98,18 +113,37 @@ namespace Apache.Ignite.Internal.Table
         /// </summary>
         /// <param name="name">Name.</param>
         /// <returns>Table.</returns>
-        internal async Task<Table?> GetTableInternalAsync(string name)
+        internal async Task<Table?> GetTableInternalAsync(QualifiedName name)
         {
-            IgniteArgumentCheck.NotNull(name);
-
-            using var writer = ProtoCommon.GetMessageWriter();
-            writer.MessageWriter.Write(name);
-
-            using var resBuf = await _socket.DoOutInOpAsync(ClientOp.TableGet, 
writer).ConfigureAwait(false);
-            return Read(resBuf.GetReader());
+            return await _socket.DoWithRetryAsync(
+                    (Tables: this, Name: name),
+                    static (socket, _) => Op(socket),
+                    async static (socket, arg) =>
+                    {
+                        var op = Op(socket);
+
+                        using var writer = ProtoCommon.GetMessageWriter();
+                        Write(writer.MessageWriter, op, arg);
+
+                        using var resBuf = await socket.DoOutInOpAsync(op, 
writer).ConfigureAwait(false);
+                        return Read(resBuf.GetReader(), arg.Tables, op);
+                    })
+                .ConfigureAwait(false);
+
+            static void Write(MsgPackWriter w, ClientOp op, (Tables Tables, 
QualifiedName Name) arg)
+            {
+                if (op == ClientOp.TableGetQualified)
+                {
+                    w.Write(arg.Name.SchemaName);
+                    w.Write(arg.Name.ObjectName);
+                }
+                else
+                {
+                    w.Write(arg.Name.CanonicalName);
+                }
+            }
 
-            // ReSharper disable once LambdaExpressionMustBeStatic (requires 
.NET 5+)
-            Table? Read(MsgPackReader r)
+            static Table? Read(MsgPackReader r, Tables tables, ClientOp op)
             {
                 if (r.TryReadNil())
                 {
@@ -117,14 +151,35 @@ namespace Apache.Ignite.Internal.Table
                 }
 
                 var tableId = r.ReadInt32();
-                var actualName = r.ReadString();
+                var actualName = UnpackQualifiedName(ref r, op == 
ClientOp.TableGetQualified);
 
-                return _cachedTables.GetOrAdd(
+                return tables._cachedTables.GetOrAdd(
                     tableId,
-                    static (int id, (string ActualName, Tables Tables) arg) =>
+                    static (int id, (QualifiedName ActualName, Tables Tables) 
arg) =>
                         new Table(arg.ActualName, id, arg.Tables._socket, 
arg.Tables._sql),
-                    (actualName, this));
+                    (actualName, tables));
+            }
+
+            static ClientOp Op(ClientSocket? socket) =>
+                UseQualifiedNames(socket) ? ClientOp.TableGetQualified : 
ClientOp.TableGet;
+        }
+
+        private static QualifiedName UnpackQualifiedName(ref MsgPackReader r, 
bool packedAsQualified)
+        {
+            if (packedAsQualified)
+            {
+                var schemaName = r.ReadString();
+                var objectName = r.ReadString();
+
+                return QualifiedName.FromNormalizedInternal(schemaName, 
objectName);
             }
+
+            var canonicalName = r.ReadString();
+
+            return QualifiedName.Parse(canonicalName);
         }
+
+        private static bool UseQualifiedNames(ClientSocket? socket) =>
+            socket != null && 
socket.ConnectionContext.ServerHasFeature(ProtocolBitmaskFeature.TableReqsUseQualifiedName);
     }
 }
diff --git a/modules/platforms/dotnet/Apache.Ignite/Table/ITable.cs 
b/modules/platforms/dotnet/Apache.Ignite/Table/ITable.cs
index 61635972e32..38f87b85064 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Table/ITable.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Table/ITable.cs
@@ -27,6 +27,11 @@ namespace Apache.Ignite.Table
         /// </summary>
         public string Name { get; }
 
+        /// <summary>
+        /// Gets the table qualified name.
+        /// </summary>
+        public QualifiedName QualifiedName { get; }
+
         /// <summary>
         /// Gets the record binary view.
         /// </summary>
diff --git a/modules/platforms/dotnet/Apache.Ignite/Table/ITables.cs 
b/modules/platforms/dotnet/Apache.Ignite/Table/ITables.cs
index 630dc1a0a7e..dbc9711a112 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Table/ITables.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Table/ITables.cs
@@ -32,6 +32,13 @@ namespace Apache.Ignite.Table
         /// <returns>A <see cref="Task"/> representing the asynchronous 
operation.</returns>
         Task<ITable?> GetTableAsync(string name);
 
+        /// <summary>
+        /// Gets a table by qualified name.
+        /// </summary>
+        /// <param name="name">Table name.</param>
+        /// <returns>A <see cref="Task"/> representing the asynchronous 
operation.</returns>
+        Task<ITable?> GetTableAsync(QualifiedName name);
+
         /// <summary>
         /// Gets all tables.
         /// </summary>
diff --git a/modules/platforms/dotnet/Apache.Ignite/Table/QualifiedName.cs 
b/modules/platforms/dotnet/Apache.Ignite/Table/QualifiedName.cs
new file mode 100644
index 00000000000..5700cf25ba6
--- /dev/null
+++ b/modules/platforms/dotnet/Apache.Ignite/Table/QualifiedName.cs
@@ -0,0 +1,151 @@
+/*
+ * 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;
+using Internal.Table;
+using Sql;
+using static Internal.Table.IgniteNameUtils;
+
+/// <summary>
+/// Represents a qualified name of a database object.
+/// <para />
+/// Schema name and object name should conform to SQL syntax rules for 
identifiers.
+/// <list type="bullet">
+/// <item>
+/// Identifier must start from any character in the Unicode General Category 
classes “Lu”, “Ll”, “Lt”, “Lm”, “Lo”, or “Nl”.</item>
+/// <item>
+/// Identifier character (expect the first one) may be U+00B7 (middle dot), or 
any character in the Unicode General Category
+/// classes “Mn”, “Mc”, “Nd”, “Pc”, or “Cf”.
+/// </item>
+/// <item>
+/// Identifier that contains any other characters must be quoted with 
double-quotes.
+/// </item>
+/// <item>
+/// Double-quote inside the identifier must be encoded as 2 consequent 
double-quote chars.
+/// </item>
+/// </list>
+/// </summary>
+public sealed record QualifiedName
+{
+    /// <summary>
+    /// Default schema name.
+    /// </summary>
+    public const string DefaultSchemaName = SqlStatement.DefaultSchema;
+
+    /// <summary>
+    /// Separator character between schema and object names.
+    /// </summary>
+    public const char SeparatorChar = IgniteNameUtils.SeparatorChar;
+
+    /// <summary>
+    /// Quote character for identifiers.
+    /// </summary>
+    public const char QuoteChar = IgniteNameUtils.QuoteChar;
+
+    /// <summary>
+    /// Initializes a new instance of the <see cref="QualifiedName"/> class.
+    /// </summary>
+    /// <param name="schemaName">Schema name. When null, default schema name 
is assumed (see <see cref="DefaultSchemaName"/>).</param>
+    /// <param name="objectName">Object name. Can not be null or empty.</param>
+    private QualifiedName(string schemaName, string objectName)
+    {
+        VerifyObjectIdentifier(schemaName);
+        VerifyObjectIdentifier(objectName);
+
+        SchemaName = schemaName;
+        ObjectName = objectName;
+        CanonicalName = 
$"{QuoteIfNeeded(schemaName)}{SeparatorChar}{QuoteIfNeeded(objectName)}";
+    }
+
+    /// <summary>
+    /// Gets the schema name.
+    /// </summary>
+    public string SchemaName { get; }
+
+    /// <summary>
+    /// Gets the object name.
+    /// </summary>
+    public string ObjectName { get; }
+
+    /// <summary>
+    /// Gets a fully qualified name in canonical form, that is, enclosing each 
part of the identifier chain in double quotes.
+    /// </summary>
+    public string CanonicalName { get; }
+
+    /// <summary>
+    /// Creates a new instance of the <see cref="QualifiedName"/> struct.
+    /// </summary>
+    /// <param name="schemaName">Schema name.</param>
+    /// <param name="objectName">Object name.</param>
+    /// <returns>Qualified name.</returns>
+    public static QualifiedName Of(string? schemaName, string objectName)
+    {
+        schemaName ??= DefaultSchemaName;
+
+        VerifyObjectIdentifier(schemaName);
+        VerifyObjectIdentifier(objectName);
+
+        return new QualifiedName(
+            ParseIdentifier(schemaName),
+            ParseIdentifier(objectName));
+    }
+
+    /// <summary>
+    /// Parses a qualified name from a string.
+    /// </summary>
+    /// <param name="simpleOrCanonicalName">Simple or canonical name.</param>
+    /// <returns>Parsed qualified name.</returns>
+    public static QualifiedName Parse(string simpleOrCanonicalName)
+    {
+        VerifyObjectIdentifier(simpleOrCanonicalName);
+
+        ReadOnlyMemory<char> nameMem = simpleOrCanonicalName.AsMemory();
+        var separatorIndex = IndexOfSeparatorChar(simpleOrCanonicalName, 0);
+
+        if (separatorIndex == -1)
+        {
+            // No separator, use default schema name.
+            return new QualifiedName(DefaultSchemaName, Unquote(nameMem));
+        }
+
+        if (separatorIndex == 0 || separatorIndex == 
simpleOrCanonicalName.Length - 1)
+        {
+            throw new ArgumentException($"Canonical name can't have empty 
parts: '{simpleOrCanonicalName}'");
+        }
+
+        if (IndexOfSeparatorChar(simpleOrCanonicalName, separatorIndex + 1) != 
-1)
+        {
+            throw new ArgumentException($"Canonical name should have at most 
two parts: '{simpleOrCanonicalName}'");
+        }
+
+        return new QualifiedName(
+            Unquote(nameMem[..separatorIndex]),
+            Unquote(nameMem[(separatorIndex + 1)..]));
+    }
+
+    /// <summary>
+    /// Creates a new instance of the <see cref="QualifiedName"/> struct from 
normalized names.
+    /// Does not validate input.
+    /// </summary>
+    /// <param name="schemaName">Schema name. When null, default schema name 
is assumed (see <see cref="DefaultSchemaName"/>).</param>
+    /// <param name="objectName">Object name. Can not be null or empty.</param>
+    /// <returns>Qualified name.</returns>
+    internal static QualifiedName FromNormalizedInternal(string? schemaName, 
string objectName) =>
+        new(schemaName ?? DefaultSchemaName, objectName);
+}

Reply via email to