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 2a74a95182 IGNITE-18111 .NET: LINQ: Add KeyValueView support (#1373)
2a74a95182 is described below

commit 2a74a951826b1496c14dab22fbe31691dc1b36e6
Author: Pavel Tupitsyn <[email protected]>
AuthorDate: Thu Nov 24 12:01:49 2022 +0300

    IGNITE-18111 .NET: LINQ: Add KeyValueView support (#1373)
    
    * Implement `IKeyValueView.AsQueryable`.
    * Disallow primitive types in LINQ (e.g. `IKeyValueView<int, string>`): not 
possible to determine column names.
    * Fix single column queries - replace `SqlTypes` from 2.x with 
`SqlColumnTypeExtensions` to match new SQL engine.
---
 .../dotnet/Apache.Ignite.Tests/.editorconfig       |   3 +-
 .../Linq/LinqSqlGenerationTests.KvView.cs          | 114 +++++++++++++++
 .../Linq/LinqSqlGenerationTests.cs                 |  64 ++++++++-
 .../Apache.Ignite.Tests/Linq/LinqTests.KvView.cs   | 157 +++++++++++++++++++++
 .../Sql/SqlColumnTypeExtensionsTests.cs            |  50 +++++++
 .../Table/Serialization/ReflectionUtilsTests.cs    |   2 +-
 .../dotnet/Apache.Ignite/Internal/Linq/DEVNOTES.md |   1 +
 .../Internal/Linq/IgniteQueryExecutor.cs           |  49 +++++--
 .../Internal/Linq/IgniteQueryExpressionVisitor.cs  | 129 ++++++++++++-----
 .../dotnet/Apache.Ignite/Internal/Linq/SqlTypes.cs |  72 ----------
 .../Internal/Sql/SqlColumnTypeExtensions.cs        |  99 +++++++++++++
 .../Apache.Ignite/Internal/Table/KeyValueView.cs   |   7 +-
 .../Apache.Ignite/Internal/Table/RecordView.cs     |  18 +++
 .../Table/Serialization/ReflectionUtils.cs         |  27 +++-
 14 files changed, 663 insertions(+), 129 deletions(-)

diff --git a/modules/platforms/dotnet/Apache.Ignite.Tests/.editorconfig 
b/modules/platforms/dotnet/Apache.Ignite.Tests/.editorconfig
index 54d4f8a3c2..5cfad90769 100644
--- a/modules/platforms/dotnet/Apache.Ignite.Tests/.editorconfig
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/.editorconfig
@@ -39,6 +39,5 @@ dotnet_diagnostic.CA2201.severity = none # Do not raise 
reserved exception types
 dotnet_diagnostic.CA1508.severity = none # Avoid dead conditional code
 dotnet_diagnostic.CA1305.severity = none # Specify IFormatProvider
 dotnet_diagnostic.CA1819.severity = none # Properties should not return arrays
-
-
+dotnet_diagnostic.CA1812.severity = none # Avoid uninstantiated internal 
classes
 
diff --git 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Linq/LinqSqlGenerationTests.KvView.cs
 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Linq/LinqSqlGenerationTests.KvView.cs
new file mode 100644
index 0000000000..4f5f800623
--- /dev/null
+++ 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Linq/LinqSqlGenerationTests.KvView.cs
@@ -0,0 +1,114 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+namespace Apache.Ignite.Tests.Linq;
+
+using System;
+using System.Linq;
+using Ignite.Sql;
+using Ignite.Table;
+using NUnit.Framework;
+using Table;
+
+/// <summary>
+/// Tests LINQ to SQL conversion for <see cref="IKeyValueView{TK,TV}"/>.
+/// <para />
+/// Uses <see cref="FakeServer"/> to get the actual SQL sent from the client.
+/// </summary>
+public partial class LinqSqlGenerationTests
+{
+    [Test]
+    public void TestSelectPrimitiveKeyColumnKv() =>
+        AssertSqlKv("select _T0.VAL from PUBLIC.tbl1 as _T0", q => q.Select(x 
=> x.Value.Val).ToList());
+
+    [Test]
+    public void TestSelectPocoValColumnKv() =>
+        AssertSqlKv("select _T0.KEY, _T0.VAL from PUBLIC.tbl1 as _T0", q => 
q.Select(x => x.Value).ToList());
+
+    [Test]
+    public void TestSelectTwoColumnsKv() =>
+        AssertSqlKv(
+            "select (_T0.KEY + ?), _T0.VAL from PUBLIC.tbl1 as _T0",
+            q => q.Select(x => new { Key = x.Key.Key + 1, x.Value.Val 
}).ToList());
+
+    [Test]
+    public void TestSelectAllColumnsCustomNamesKv() =>
+        AssertSql(
+            "select _T0.\"KEY\", _T0.\"VAL\" from PUBLIC.tbl1 as _T0",
+            tbl => tbl.GetKeyValueView<PocoCustomNames, 
PocoCustomNames>().AsQueryable().ToList());
+
+    [Test]
+    public void TestSelectSameColumnFromPairKeyAndValKv()
+    {
+        // We avoid selecting same column twice if it is included in both Key 
and Value parts,
+        // but if the user requests it explicitly, we keep it.
+        AssertSqlKv(
+            "select _T0.KEY, _T0.KEY from PUBLIC.tbl1 as _T0",
+            q => q.Select(x => new { Key1 = x.Key.Key, Key2 = x.Value.Key 
}).ToList());
+    }
+
+    [Test]
+    public void TestSelectEntirePairKv() =>
+        AssertSqlKv("select _T0.KEY, _T0.VAL from PUBLIC.tbl1 as _T0 where 
(_T0.KEY > ?)", q => q.Where(x => x.Key.Key > 1).ToList());
+
+    [Test]
+    public void TestSelectPairKeyKv() =>
+        AssertSqlKv("select _T0.KEY from PUBLIC.tbl1 as _T0", q => q.Select(x 
=> x.Key).ToList());
+
+    [Test]
+    public void TestSelectPairValKv() =>
+        AssertSqlKv("select _T0.KEY, _T0.VAL from PUBLIC.tbl1 as _T0", q => 
q.Select(x => x.Value).ToList());
+
+    [Test]
+    public void TestPrimitiveTypeMappingNotSupportedKv()
+    {
+        // ReSharper disable once ReturnValueOfPureMethodIsNotUsed
+        var ex = Assert.Throws<NotSupportedException>(
+            () => _table.GetKeyValueView<long, 
string>().AsQueryable().Select(x => x.Key).ToList());
+
+        Assert.AreEqual(
+            "Primitive types are not supported in LINQ queries: System.Int64. 
" +
+            "Use a custom type (class, record, struct) with a single field 
instead.",
+            ex!.Message);
+    }
+
+    [Test]
+    public void TestDefaultQueryableOptionsKv()
+    {
+        _server.LastSqlTimeoutMs = null;
+        _server.LastSqlPageSize = null;
+
+        _ = _table.GetKeyValueView<Poco, Poco>().AsQueryable()
+            .Select(x => (int)x.Key.Key).ToArray();
+
+        Assert.AreEqual(SqlStatement.DefaultTimeout.TotalMilliseconds, 
_server.LastSqlTimeoutMs);
+        Assert.AreEqual(SqlStatement.DefaultPageSize, _server.LastSqlPageSize);
+    }
+
+    [Test]
+    public void TestCustomQueryableOptionsKv()
+    {
+        _server.LastSqlTimeoutMs = null;
+        _server.LastSqlPageSize = null;
+
+        _ = _table.GetKeyValueView<Poco, Poco>().AsQueryable(options: 
new(TimeSpan.FromSeconds(25), 128))
+            .Select(x => (int)x.Key.Key).ToArray();
+
+        Assert.AreEqual(25000, _server.LastSqlTimeoutMs);
+        Assert.AreEqual(128, _server.LastSqlPageSize);
+    }
+}
diff --git 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Linq/LinqSqlGenerationTests.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Linq/LinqSqlGenerationTests.cs
index 62493f44c4..df1e2a511d 100644
--- 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Linq/LinqSqlGenerationTests.cs
+++ 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Linq/LinqSqlGenerationTests.cs
@@ -18,7 +18,8 @@
 namespace Apache.Ignite.Tests.Linq;
 
 using System;
-using System.Diagnostics.CodeAnalysis;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations.Schema;
 using System.Linq;
 using System.Threading.Tasks;
 using Ignite.Sql;
@@ -31,7 +32,7 @@ using Table;
 /// <para />
 /// Uses <see cref="FakeServer"/> to get the actual SQL sent from the client.
 /// </summary>
-public class LinqSqlGenerationTests
+public partial class LinqSqlGenerationTests
 {
     private IIgniteClient _client = null!;
     private FakeServer _server = null!;
@@ -157,6 +158,55 @@ public class LinqSqlGenerationTests
                 .ToList());
     }
 
+    [Test]
+    public void TestPrimitiveTypeMappingNotSupported()
+    {
+        // ReSharper disable once ReturnValueOfPureMethodIsNotUsed
+        var ex = Assert.Throws<NotSupportedException>(
+            () => _table.GetRecordView<int>().AsQueryable().Where(x => x > 
0).ToList());
+
+        Assert.AreEqual(
+            "Primitive types are not supported in LINQ queries: System.Int32. 
" +
+            "Use a custom type (class, record, struct) with a single field 
instead.",
+            ex!.Message);
+    }
+
+    [Test]
+    public void TestEmptyTypeMappingNotSupported()
+    {
+        // ReSharper disable once ReturnValueOfPureMethodIsNotUsed
+        var ex = Assert.Throws<NotSupportedException>(() => 
_table.GetRecordView<EmptyPoco>().AsQueryable().ToList());
+
+        Assert.AreEqual(
+            "Type 'Apache.Ignite.Tests.Linq.LinqSqlGenerationTests+EmptyPoco' 
can not be mapped to SQL columns: " +
+            "it has no fields, or all fields are [NotMapped].",
+            ex!.Message);
+    }
+
+    [Test]
+    public void TestAllNotMappedTypeMappingNotSupported()
+    {
+        // ReSharper disable once ReturnValueOfPureMethodIsNotUsed
+        var ex = Assert.Throws<NotSupportedException>(() => 
_table.GetRecordView<UnmappedPoco>().AsQueryable().ToList());
+
+        Assert.AreEqual(
+            "Type 
'Apache.Ignite.Tests.Linq.LinqSqlGenerationTests+UnmappedPoco' can not be 
mapped to SQL columns: " +
+            "it has no fields, or all fields are [NotMapped].",
+            ex!.Message);
+    }
+
+    [Test]
+    public void TestRecordViewKeyValuePairNotSupported()
+    {
+        // ReSharper disable once ReturnValueOfPureMethodIsNotUsed
+        var ex = Assert.Throws<NotSupportedException>(() => 
_table.GetRecordView<KeyValuePair<int, int>>().AsQueryable().ToList());
+
+        Assert.AreEqual(
+            "Can't use System.Collections.Generic.KeyValuePair`2[TKey,TValue] 
for LINQ queries: " +
+            "it is reserved for 
Apache.Ignite.Table.IKeyValueView`2[TK,TV].AsQueryable. Use a custom type 
instead.",
+            ex!.Message);
+    }
+
     [OneTimeSetUp]
     public async Task OneTimeSetUp()
     {
@@ -175,6 +225,9 @@ public class LinqSqlGenerationTests
     private void AssertSql(string expectedSql, Func<IQueryable<Poco>, object?> 
query) =>
         AssertSql(expectedSql, t => 
query(t.GetRecordView<Poco>().AsQueryable()));
 
+    private void AssertSqlKv(string expectedSql, 
Func<IQueryable<KeyValuePair<OneColumnPoco, Poco>>, object?> query) =>
+        AssertSql(expectedSql, t => query(t.GetKeyValueView<OneColumnPoco, 
Poco>().AsQueryable()));
+
     private void AssertSql(string expectedSql, Func<ITable, object?> query)
     {
         _server.LastSql = string.Empty;
@@ -195,7 +248,10 @@ public class LinqSqlGenerationTests
         Assert.AreEqual(expectedSql, _server.LastSql, 
string.IsNullOrEmpty(_server.LastSql) ? ex?.ToString() : null);
     }
 
-    // ReSharper disable once NotAccessedPositionalProperty.Local
-    [SuppressMessage("Microsoft.Performance", 
"CA1812:AvoidUninstantiatedInternalClasses", Justification = "Query tests.")]
+    // ReSharper disable NotAccessedPositionalProperty.Local, 
ClassNeverInstantiated.Local
     private record OneColumnPoco(long Key);
+
+    private record EmptyPoco;
+
+    private record UnmappedPoco([property: NotMapped] long Key, [field: 
NotMapped] string Val);
 }
diff --git 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Linq/LinqTests.KvView.cs 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Linq/LinqTests.KvView.cs
new file mode 100644
index 0000000000..140794627d
--- /dev/null
+++ b/modules/platforms/dotnet/Apache.Ignite.Tests/Linq/LinqTests.KvView.cs
@@ -0,0 +1,157 @@
+/*
+ * 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.Linq;
+
+using System.Collections.Generic;
+using System.Linq;
+using Ignite.Table;
+using NUnit.Framework;
+
+/// <summary>
+/// Linq KvView tests.
+/// </summary>
+public partial class LinqTests
+{
+    private IKeyValueView<KeyPoco, ValPoco> KvView { get; set; } = null!;
+
+    [Test]
+    public void TestSelectPairKv()
+    {
+        var query = KvView.AsQueryable()
+            .Where(x => x.Key.Key > 3 && x.Value.Val != null)
+            .OrderBy(x => x.Key.Key);
+
+        List<KeyValuePair<KeyPoco, ValPoco>> res = query.ToList();
+
+        Assert.AreEqual(4, res[0].Key.Key);
+        Assert.AreEqual("v-4", res[0].Value.Val);
+
+        StringAssert.Contains(
+            "select _T0.KEY, _T0.VAL " +
+            "from PUBLIC.TBL1 as _T0 " +
+            "where ((_T0.KEY > ?) and (_T0.VAL IS DISTINCT FROM ?)) " +
+            "order by (_T0.KEY) asc",
+            query.ToString());
+    }
+
+    [Test]
+    public void TestSelectKeyKv()
+    {
+        var query = KvView.AsQueryable()
+            .Select(x => x.Key)
+            .Where(x => x.Key > 5)
+            .OrderBy(x => x.Key);
+
+        List<KeyPoco> res = query.ToList();
+
+        Assert.AreEqual(6, res[0].Key);
+
+        StringAssert.Contains(
+            "select _T0.KEY from PUBLIC.TBL1 as _T0 " +
+            "where (_T0.KEY > ?) " +
+            "order by (_T0.KEY) asc",
+            query.ToString());
+    }
+
+    [Test]
+    public void TestSelectValKv()
+    {
+        var query = KvView.AsQueryable()
+            .Select(x => x.Value)
+            .Where(x => x.Val != "foo")
+            .OrderBy(x => x.Val);
+
+        List<ValPoco> res = query.ToList();
+
+        Assert.AreEqual("v-0", res[0].Val);
+
+        StringAssert.Contains(
+            "select _T0.VAL from PUBLIC.TBL1 as _T0 " +
+            "where (_T0.VAL IS DISTINCT FROM ?) " +
+            "order by (_T0.VAL) asc",
+            query.ToString());
+    }
+
+    [Test]
+    public void TestSelectOneColumnKv()
+    {
+        var query = KvView.AsQueryable()
+            .Select(x => x.Value.Val)
+            .Where(x => x != "foo")
+            .OrderBy(x => x);
+
+        List<string?> res = query.ToList();
+
+        Assert.AreEqual("v-0", res[0]);
+
+        StringAssert.Contains(
+            "select _T0.VAL from PUBLIC.TBL1 as _T0 " +
+            "where (_T0.VAL IS DISTINCT FROM ?) " +
+            "order by (_T0.VAL) asc",
+            query.ToString());
+    }
+
+    [Test]
+    public void TestJoinRecordWithKv()
+    {
+        var query1 = KvView.AsQueryable()
+            .Where(x => x.Key.Key > 1);
+
+        var query2 = PocoView.AsQueryable()
+            .Where(x => x.Key > 7);
+
+        var query = query1.Join(
+                query2,
+                a => a.Key.Key,
+                b => b.Key,
+                (a, b) => new
+                {
+                    Key1 = a.Key.Key,
+                    Val1 = a.Value.Val,
+                    Key2 = b.Key,
+                    Val2 = b.Val
+                })
+            .OrderBy(x => x.Key1);
+
+        var res = query.ToList();
+
+        Assert.AreEqual(8, res[0].Key1);
+        Assert.AreEqual(8, res[0].Key2);
+
+        Assert.AreEqual("v-8", res[0].Val1);
+        Assert.AreEqual("v-8", res[0].Val2);
+
+        StringAssert.Contains(
+            "select _T0.KEY, _T0.VAL, _T1.KEY, _T1.VAL from PUBLIC.TBL1 as _T0 
" +
+            "inner join (select * from PUBLIC.TBL1 as _T2 where (_T2.KEY > ?) 
) as _T1 on (_T1.KEY = _T0.KEY) " +
+            "where (_T0.KEY > ?) " +
+            "order by (_T0.KEY) asc",
+            query.ToString());
+    }
+
+    [OneTimeSetUp]
+    protected void InitKvView()
+    {
+        KvView = Table.GetKeyValueView<KeyPoco, ValPoco>();
+    }
+
+    // ReSharper disable ClassNeverInstantiated.Local
+    private record KeyPoco(long Key);
+
+    private record ValPoco(string? Val);
+}
diff --git 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Sql/SqlColumnTypeExtensionsTests.cs
 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Sql/SqlColumnTypeExtensionsTests.cs
new file mode 100644
index 0000000000..fe16d89563
--- /dev/null
+++ 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Sql/SqlColumnTypeExtensionsTests.cs
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+namespace Apache.Ignite.Tests.Sql;
+
+using System;
+using Ignite.Sql;
+using Internal.Sql;
+using NUnit.Framework;
+
+/// <summary>
+/// Tests for <see cref="SqlColumnTypeExtensions"/>.
+/// </summary>
+public class SqlColumnTypeExtensionsTests
+{
+    private static readonly SqlColumnType[] SqlColumnTypes = 
Enum.GetValues<SqlColumnType>();
+
+    [TestCaseSource(nameof(SqlColumnTypes))]
+    public void TestToClrType(SqlColumnType sqlColumnType) =>
+        Assert.IsNotNull(sqlColumnType.ToClrType(), sqlColumnType.ToString());
+
+    [TestCaseSource(nameof(SqlColumnTypes))]
+    public void TestToSqlColumnType(SqlColumnType sqlColumnType) =>
+        Assert.AreEqual(sqlColumnType, 
sqlColumnType.ToClrType().ToSqlColumnType());
+
+    [TestCaseSource(nameof(SqlColumnTypes))]
+    public void TestToSqlTypeName(SqlColumnType sqlColumnType)
+    {
+        if (sqlColumnType is SqlColumnType.Duration or SqlColumnType.Period)
+        {
+            return;
+        }
+
+        Assert.IsNotNull(sqlColumnType.ToSqlTypeName(), 
sqlColumnType.ToString());
+    }
+}
diff --git 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/Serialization/ReflectionUtilsTests.cs
 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/Serialization/ReflectionUtilsTests.cs
index 7ab02bfc4e..06f53bcb2d 100644
--- 
a/modules/platforms/dotnet/Apache.Ignite.Tests/Table/Serialization/ReflectionUtilsTests.cs
+++ 
b/modules/platforms/dotnet/Apache.Ignite.Tests/Table/Serialization/ReflectionUtilsTests.cs
@@ -16,7 +16,7 @@
  */
 
 // ReSharper disable InconsistentNaming, UnusedMember.Local
-#pragma warning disable SA1306, SA1401, CS0649, CS0169, CA1823, CA1812, SA1201
+#pragma warning disable SA1306, SA1401, CS0649, CS0169, CA1823, SA1201
 namespace Apache.Ignite.Tests.Table.Serialization
 {
     using System;
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/DEVNOTES.md 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/DEVNOTES.md
index 938b372d10..d4bc050fab 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/DEVNOTES.md
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/DEVNOTES.md
@@ -25,6 +25,7 @@ There are two ways to map columns to user type members:
 
 We take the second approach for the sake of performance, simplicity and 
clarity.
 
+**Primitive type mapping is not supported**: not possible to determine column 
names without schema.
 
 ### Type Member to Column Mapping
 
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/IgniteQueryExecutor.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/IgniteQueryExecutor.cs
index 6b23750eda..f9815f3ac6 100644
--- 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/IgniteQueryExecutor.cs
+++ 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/IgniteQueryExecutor.cs
@@ -131,9 +131,9 @@ internal sealed class IgniteQueryExecutor : IQueryExecutor
     /// </summary>
     private static RowReader<T> 
GetResultSelector<T>(IReadOnlyList<IColumnMetadata> columns, Expression 
selectorExpression)
     {
+        // TODO: IGNITE-18136 Replace reflection with emitted delegates.
         if (selectorExpression is NewExpression newExpr)
         {
-            // TODO: IGNITE-18136 Replace reflection with emitted delegates.
             return (IReadOnlyList<IColumnMetadata> cols, ref BinaryTupleReader 
reader) =>
             {
                 var args = new object?[cols.Count];
@@ -154,13 +154,32 @@ internal sealed class IgniteQueryExecutor : IQueryExecutor
             };
         }
 
-        if (columns.Count == 1)
+        if (columns.Count == 1 && typeof(T).ToSqlColumnType() is not null)
         {
             return (IReadOnlyList<IColumnMetadata> cols, ref BinaryTupleReader 
reader) =>
                 (T)Convert.ChangeType(Sql.ReadColumnValue(ref reader, cols[0], 
0)!, typeof(T), CultureInfo.InvariantCulture);
         }
 
-        // TODO: IGNITE-18136 Replace reflection with emitted delegates.
+        if (typeof(T).GetKeyValuePairTypes() is var (keyType, valType))
+        {
+            return (IReadOnlyList<IColumnMetadata> cols, ref BinaryTupleReader 
reader) =>
+            {
+                var key = FormatterServices.GetUninitializedObject(keyType);
+                var val = FormatterServices.GetUninitializedObject(valType);
+
+                for (int i = 0; i < cols.Count; i++)
+                {
+                    var col = cols[i];
+                    var colVal = Sql.ReadColumnValue(ref reader, col, i);
+
+                    SetColumnValue(col, colVal, key, keyType);
+                    SetColumnValue(col, colVal, val, valType);
+                }
+
+                return (T)Activator.CreateInstance(typeof(T), key, val)!;
+            };
+        }
+
         return (IReadOnlyList<IColumnMetadata> cols, ref BinaryTupleReader 
reader) =>
         {
             var res = (T)FormatterServices.GetUninitializedObject(typeof(T));
@@ -169,23 +188,27 @@ internal sealed class IgniteQueryExecutor : IQueryExecutor
             {
                 var col = cols[i];
                 var val = Sql.ReadColumnValue(ref reader, col, i);
-                var field = typeof(T).GetFieldByColumnName(col.Name);
 
-                if (field != null)
-                {
-                    if (val != null)
-                    {
-                        val = Convert.ChangeType(val, field.FieldType, 
CultureInfo.InvariantCulture);
-                    }
-
-                    field.SetValue(res, val);
-                }
+                SetColumnValue(col, val, res, typeof(T));
             }
 
             return res;
         };
     }
 
+    private static void SetColumnValue<T>(IColumnMetadata col, object? val, T 
res, Type type)
+    {
+        if (type.GetFieldByColumnName(col.Name) is {} field)
+        {
+            if (val != null)
+            {
+                val = Convert.ChangeType(val, field.FieldType, 
CultureInfo.InvariantCulture);
+            }
+
+            field.SetValue(res, val);
+        }
+    }
+
     [SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on 
the awaited task", Justification = "False positive.")]
     private async Task<T?> ExecuteSingleInternalAsync<T>(QueryModel 
queryModel, bool returnDefaultWhenEmpty)
     {
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/IgniteQueryExpressionVisitor.cs
 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/IgniteQueryExpressionVisitor.cs
index 49b0112f84..e3a83daf11 100644
--- 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/IgniteQueryExpressionVisitor.cs
+++ 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/IgniteQueryExpressionVisitor.cs
@@ -31,6 +31,7 @@ using Remotion.Linq.Clauses;
 using Remotion.Linq.Clauses.Expressions;
 using Remotion.Linq.Clauses.ResultOperators;
 using Remotion.Linq.Parsing;
+using Sql;
 using Table.Serialization;
 
 /// <summary>
@@ -234,31 +235,8 @@ internal sealed class IgniteQueryExpressionVisitor : 
ThrowingExpressionVisitor
             else
             {
                 var tableName = Aliases.GetTableAlias(expression);
-                var columns = 
expression.ReferencedQuerySource.ItemType.GetColumns();
-                var first = true;
 
-                foreach (var col in columns)
-                {
-                    if (!first)
-                    {
-                        ResultBuilder.Append(", ");
-                    }
-
-                    first = false;
-
-                    ResultBuilder.Append(tableName).Append('.');
-
-                    if (col.HasColumnNameAttribute)
-                    {
-                        // Exact quoted name.
-                        ResultBuilder.Append('"').Append(col.Name).Append('"');
-                    }
-                    else
-                    {
-                        // Case-insensitive, unquoted, upper-case name.
-                        ResultBuilder.Append(col.Name.ToUpperInvariant());
-                    }
-                }
+                AppendColumnNames(expression.ReferencedQuerySource.ItemType, 
tableName);
             }
         }
 
@@ -289,9 +267,7 @@ internal sealed class IgniteQueryExpressionVisitor : 
ThrowingExpressionVisitor
             // Find where the projection comes from.
             expression = 
ExpressionWalker.GetProjectedMember(expression.Expression!, expression.Member) 
?? expression;
 
-            var columnName = GetColumnName(expression);
-
-            ResultBuilder.AppendFormat(CultureInfo.InvariantCulture, 
"{0}.{1}", Aliases.GetTableAlias(expression), columnName);
+            AppendColumnName(expression, Aliases.GetTableAlias(expression));
         }
         else
         {
@@ -347,7 +323,11 @@ internal sealed class IgniteQueryExpressionVisitor : 
ThrowingExpressionVisitor
         // Explicit type specification is required when all arguments of 
CASEWHEN are parameters
         ResultBuilder.Append(", cast(");
         Visit(expression.IfTrue);
-        ResultBuilder.AppendFormat(CultureInfo.InvariantCulture, " as {0}), ", 
SqlTypes.GetSqlTypeName(expression.Type) ?? "other");
+
+        ResultBuilder.Append(" as ");
+        var sqlColumnType = expression.Type.ToSqlColumnType() ?? throw new 
NotSupportedException("Unsupported type: " + expression.Type);
+        ResultBuilder.Append(sqlColumnType.ToSqlTypeName());
+        ResultBuilder.Append(')');
 
         Visit(expression.IfFalse);
         ResultBuilder.Append(')');
@@ -422,22 +402,103 @@ internal sealed class IgniteQueryExpressionVisitor : 
ThrowingExpressionVisitor
     }
 
     /// <summary>
-    /// Gets the name of the field from a member expression, with quotes when 
necessary.
+    /// Appends the name of the column from a member expression, with quotes 
when necessary.
     /// </summary>
-    private static string GetColumnName(MemberExpression expression)
+    private void AppendColumnName(MemberExpression expression, string 
tableName)
     {
-        if (ColumnNameMap.TryGetValue(expression.Member, out var fieldName))
+        if (ColumnNameMap.TryGetValue(expression.Member, out var columnName))
+        {
+            ResultBuilder.Append(tableName).Append('.').Append(columnName);
+            return;
+        }
+
+        if (expression.Member.DeclaringType.IsKeyValuePair())
         {
-            return fieldName;
+            AppendColumnNames(((PropertyInfo)expression.Member).PropertyType, 
tableName);
+            return;
         }
 
         // When there is a [Column] attribute with Name specified, use quoted 
identifier: exact match, allows whitespace.
         // Otherwise (most common case), use uppercase non-quoted identifier 
(case-insensitive).
-        var columnName = 
expression.Member.GetCustomAttribute<ColumnAttribute>() is { Name: { } 
columnAttributeName }
+        // NOTE: The same logic is used in AppendColumnNames below.
+        columnName = expression.Member.GetCustomAttribute<ColumnAttribute>() 
is { Name: { } columnAttributeName }
             ? '"' + columnAttributeName + '"'
             : expression.Member.Name.ToUpperInvariant();
 
-        return ColumnNameMap.GetOrAdd(expression.Member, columnName);
+        ColumnNameMap.GetOrAdd(expression.Member, columnName);
+
+        ResultBuilder.Append(tableName).Append('.').Append(columnName);
+    }
+
+    /// <summary>
+    /// Appends column names for all fields in the specified type.
+    /// </summary>
+    /// <param name="type">Type.</param>
+    /// <param name="tableName">Table name.</param>
+    /// <param name="first">Whether this is the first column and does not need 
a comma before.</param>
+    /// <param name="toSkip">Names to skip.</param>
+    /// <param name="populateToSkip">Whether to populate provided toSkip 
set.</param>
+    private void AppendColumnNames(Type type, string tableName, bool first = 
true, ISet<string>? toSkip = null, bool populateToSkip = false)
+    {
+        if (type.IsPrimitive)
+        {
+            throw new NotSupportedException(
+                $"Primitive types are not supported in LINQ queries: {type}. " 
+
+                "Use a custom type (class, record, struct) with a single field 
instead.");
+        }
+
+        if (type.GetKeyValuePairTypes() is var (keyType, valType))
+        {
+            var keyColumnNames = new HashSet<string>();
+
+            AppendColumnNames(keyType, tableName, first: true, toSkip: 
keyColumnNames, populateToSkip: true);
+            AppendColumnNames(valType, tableName, first: false, toSkip: 
keyColumnNames);
+
+            return;
+        }
+
+        var columns = type.GetColumns();
+
+        if (columns.Count == 0)
+        {
+            throw new NotSupportedException(
+                $"Type '{type}' can not be mapped to SQL columns: it has no 
fields, or all fields are [NotMapped].");
+        }
+
+        foreach (var col in columns)
+        {
+            if (toSkip != null)
+            {
+                if (populateToSkip)
+                {
+                    toSkip.Add(col.Name);
+                }
+                else if (toSkip.Contains(col.Name))
+                {
+                    continue;
+                }
+            }
+
+            if (!first)
+            {
+                ResultBuilder.Append(", ");
+            }
+
+            first = false;
+
+            ResultBuilder.Append(tableName).Append('.');
+
+            if (col.HasColumnNameAttribute)
+            {
+                // Exact quoted name.
+                ResultBuilder.Append('"').Append(col.Name).Append('"');
+            }
+            else
+            {
+                // Case-insensitive, unquoted, upper-case name.
+                ResultBuilder.Append(col.Name.ToUpperInvariant());
+            }
+        }
     }
 
     /// <summary>
diff --git a/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/SqlTypes.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/SqlTypes.cs
deleted file mode 100644
index 2bc67c9735..0000000000
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Linq/SqlTypes.cs
+++ /dev/null
@@ -1,72 +0,0 @@
-/*
- * 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.Linq;
-
-using System;
-using System.Collections.Generic;
-
-/// <summary>
-/// SQL type mapping.
-/// </summary>
-internal static class SqlTypes
-{
-    /** */
-    private static readonly Dictionary<Type, string> NetToSql = new()
-    {
-        {typeof(bool), "boolean"},
-        {typeof(byte), "smallint"},
-        {typeof(sbyte), "tinyint"},
-        {typeof(short), "smallint"},
-        {typeof(ushort), "int"},
-        {typeof(int), "int"},
-        {typeof(uint), "bigint"},
-        {typeof(long), "bigint"},
-        {typeof(ulong), "bigint"},
-        {typeof(float), "real"},
-        {typeof(double), "double"},
-        {typeof(string), "nvarchar"},
-        {typeof(decimal), "decimal"},
-        {typeof(Guid), "uuid"},
-        {typeof(DateTime), "timestamp"},
-    };
-
-    /** */
-    private static readonly HashSet<Type> NotSupportedTypes = new(new[] { 
typeof(char) });
-
-    /// <summary>
-    /// Gets the corresponding Java type name.
-    /// </summary>
-    /// <param name="type">CLR type.</param>
-    /// <returns>SQL type name.</returns>
-    public static string? GetSqlTypeName(Type? type)
-    {
-        if (type == null)
-        {
-            return null;
-        }
-
-        type = Nullable.GetUnderlyingType(type) ?? type;
-
-        if (NotSupportedTypes.Contains(type))
-        {
-            throw new NotSupportedException("Type is not supported for SQL 
mapping: " + type);
-        }
-
-        return NetToSql.TryGetValue(type, out var res) ? res : null;
-    }
-}
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/SqlColumnTypeExtensions.cs
 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/SqlColumnTypeExtensions.cs
new file mode 100644
index 0000000000..e8202716d0
--- /dev/null
+++ 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Sql/SqlColumnTypeExtensions.cs
@@ -0,0 +1,99 @@
+/*
+ * 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.Sql;
+
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+using Ignite.Sql;
+using NodaTime;
+
+/// <summary>
+/// Extension methods for <see cref="SqlColumnType"/>.
+/// </summary>
+internal static class SqlColumnTypeExtensions
+{
+    private static readonly IReadOnlyDictionary<Type, SqlColumnType> ClrToSql =
+        Enum.GetValues<SqlColumnType>().ToDictionary(x => x.ToClrType(), x => 
x);
+
+    /// <summary>
+    /// Gets corresponding .NET type.
+    /// </summary>
+    /// <param name="sqlColumnType">SQL column type.</param>
+    /// <returns>CLR type.</returns>
+    public static Type ToClrType(this SqlColumnType sqlColumnType) => 
sqlColumnType switch
+    {
+        SqlColumnType.Boolean => typeof(bool),
+        SqlColumnType.Int8 => typeof(sbyte),
+        SqlColumnType.Int16 => typeof(short),
+        SqlColumnType.Int32 => typeof(int),
+        SqlColumnType.Int64 => typeof(long),
+        SqlColumnType.Float => typeof(float),
+        SqlColumnType.Double => typeof(double),
+        SqlColumnType.Decimal => typeof(decimal),
+        SqlColumnType.Date => typeof(LocalDate),
+        SqlColumnType.Time => typeof(LocalTime),
+        SqlColumnType.Datetime => typeof(LocalDateTime),
+        SqlColumnType.Timestamp => typeof(Instant),
+        SqlColumnType.Uuid => typeof(Guid),
+        SqlColumnType.Bitmask => typeof(BitArray),
+        SqlColumnType.String => typeof(string),
+        SqlColumnType.ByteArray => typeof(byte[]),
+        SqlColumnType.Period => typeof(Period),
+        SqlColumnType.Duration => typeof(Duration),
+        SqlColumnType.Number => typeof(BigInteger),
+        _ => throw new InvalidOperationException($"Invalid 
{nameof(SqlColumnType)}: {sqlColumnType}")
+    };
+
+    /// <summary>
+    /// Gets corresponding SQL type name.
+    /// </summary>
+    /// <param name="sqlColumnType">SQL column type.</param>
+    /// <returns>CLR type.</returns>
+    public static string ToSqlTypeName(this SqlColumnType sqlColumnType) => 
sqlColumnType switch
+    {
+        SqlColumnType.Boolean => "boolean",
+        SqlColumnType.Int8 => "tinyint",
+        SqlColumnType.Int16 => "smallint",
+        SqlColumnType.Int32 => "int",
+        SqlColumnType.Int64 => "bigint",
+        SqlColumnType.Float => "real",
+        SqlColumnType.Double => "double",
+        SqlColumnType.Decimal => "decimal",
+        SqlColumnType.Date => "date",
+        SqlColumnType.Time => "time",
+        SqlColumnType.Datetime => "timestamp",
+        SqlColumnType.Timestamp => "timestamp_tz",
+        SqlColumnType.Uuid => "uuid",
+        SqlColumnType.Bitmask => "bitmap",
+        SqlColumnType.String => "varchar",
+        SqlColumnType.ByteArray => "varbinary",
+        SqlColumnType.Number => "number",
+        _ => throw new InvalidOperationException($"Unsupported 
{nameof(SqlColumnType)}: {sqlColumnType}")
+    };
+
+    /// <summary>
+    /// Gets corresponding <see cref="SqlColumnType"/>.
+    /// </summary>
+    /// <param name="type">Type.</param>
+    /// <returns>SQL column type, or null.</returns>
+    public static SqlColumnType? ToSqlColumnType(this Type type) =>
+        ClrToSql.TryGetValue(type, out var sqlType) ? sqlType : null;
+}
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/KeyValueView.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/KeyValueView.cs
index 5af9718e86..00afaa7b62 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/KeyValueView.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/KeyValueView.cs
@@ -25,6 +25,7 @@ using Apache.Ignite.Transactions;
 using Common;
 using Ignite.Sql;
 using Ignite.Table;
+using Linq;
 using Serialization;
 
 /// <summary>
@@ -149,8 +150,10 @@ internal sealed class KeyValueView<TK, TV> : 
IKeyValueView<TK, TV>
     /// <inheritdoc/>
     public IQueryable<KeyValuePair<TK, TV>> AsQueryable(ITransaction? 
transaction = null, QueryableOptions? options = null)
     {
-        // TODO IGNITE-18111 KeyValueView support
-        throw new NotImplementedException();
+        var executor = new IgniteQueryExecutor(_recordView.Sql, transaction, 
options);
+        var provider = new IgniteQueryProvider(IgniteQueryParser.Instance, 
executor, _recordView.Table.Name);
+
+        return new IgniteQueryable<KeyValuePair<TK, TV>>(provider);
     }
 
     private static KvPair<TK, TV> ToKv(KeyValuePair<TK, TV> x)
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/RecordView.cs 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/RecordView.cs
index 5de660630c..9b8ca9ed9d 100644
--- a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/RecordView.cs
+++ b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/RecordView.cs
@@ -66,6 +66,16 @@ namespace Apache.Ignite.Internal.Table
         /// </summary>
         public RecordSerializer<T> RecordSerializer => _ser;
 
+        /// <summary>
+        /// Gets the table.
+        /// </summary>
+        public Table Table => _table;
+
+        /// <summary>
+        /// Gets the SQL.
+        /// </summary>
+        public Sql Sql => _sql;
+
         /// <inheritdoc/>
         public async Task<Option<T>> GetAsync(ITransaction? transaction, T key)
         {
@@ -293,6 +303,14 @@ namespace Apache.Ignite.Internal.Table
             var executor = new IgniteQueryExecutor(_sql, transaction, options);
             var provider = new IgniteQueryProvider(IgniteQueryParser.Instance, 
executor, _table.Name);
 
+            if (typeof(T).IsKeyValuePair())
+            {
+                throw new NotSupportedException(
+                    $"Can't use {typeof(KeyValuePair<,>)} for LINQ queries: " +
+                    $"it is reserved for 
{typeof(IKeyValueView<,>)}.{nameof(IKeyValueView<int, int>.AsQueryable)}. " +
+                    "Use a custom type instead.");
+            }
+
             return new IgniteQueryable<T>(provider);
         }
 
diff --git 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/ReflectionUtils.cs
 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/ReflectionUtils.cs
index 917b18990c..8815baf650 100644
--- 
a/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/ReflectionUtils.cs
+++ 
b/modules/platforms/dotnet/Apache.Ignite/Internal/Table/Serialization/ReflectionUtils.cs
@@ -57,9 +57,34 @@ namespace Apache.Ignite.Internal.Table.Serialization
         /// Gets column names for all fields in the specified type.
         /// </summary>
         /// <param name="type">Type.</param>
-        /// <returns>Column names.</returns>
+        /// <returns>Columns.</returns>
         public static ICollection<ColumnInfo> GetColumns(this Type type) => 
GetFieldsByColumnName(type).Values;
 
+        /// <summary>
+        /// Gets a pair of types for <see cref="KeyValuePair{TKey,TValue}"/>.
+        /// </summary>
+        /// <param name="type">Type.</param>
+        /// <returns>Resulting pair, or null when specified type is not <see 
cref="KeyValuePair{TKey,TValue}"/>.</returns>
+        public static (Type KeyType, Type ValType)? GetKeyValuePairTypes(this 
Type type)
+        {
+            if (!type.IsKeyValuePair())
+            {
+                return null;
+            }
+
+            var types = type.GetGenericArguments();
+
+            return (types[0], types[1]);
+        }
+
+        /// <summary>
+        /// Gets a value indicating whether the type is <see 
cref="KeyValuePair{TKey,TValue}"/>.
+        /// </summary>
+        /// <param name="type">Type.</param>
+        /// <returns>Whether the provided type is a <see 
cref="KeyValuePair{TKey,TValue}"/>.</returns>
+        public static bool IsKeyValuePair(this Type? type) =>
+            type is { IsGenericType: true } && type.GetGenericTypeDefinition() 
== typeof(KeyValuePair<,>);
+
         /// <summary>
         /// Gets a map of fields by column name.
         /// </summary>


Reply via email to