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" -> "\"myColumn\"", "MYCOLUMN" -> - * "MYCOLUMN" + * Wraps the given name with double quotes if it is not uppercased non-quoted name, e.g. "myColumn" -> "\"myColumn\"", + * "MYCOLUMN" -> "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); +}