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>