diff --git a/src/EFCore.Ydb/CHANGELOG.md b/src/EFCore.Ydb/CHANGELOG.md
index 3b4ec968..4966623c 100644
--- a/src/EFCore.Ydb/CHANGELOG.md
+++ b/src/EFCore.Ydb/CHANGELOG.md
@@ -1,3 +1,4 @@
+- Fixed bug: SqlQuery throws exception when using list parameters ([#540](https://github.com/ydb-platform/ydb-dotnet-sdk/issues/540)).
- Added support for the YDB retry policy (ADO.NET) and new configuration methods in `YdbDbContextOptionsBuilder`:
- `EnableRetryIdempotence()`: enables retries for errors classified as idempotent. You must ensure the operation itself is idempotent.
- `UseRetryPolicy(YdbRetryPolicyConfig retryPolicyConfig)`: configures custom backoff parameters and the maximum number of retry attempts.
diff --git a/src/EFCore.Ydb/src/EntityFrameworkCore.Ydb.csproj b/src/EFCore.Ydb/src/EntityFrameworkCore.Ydb.csproj
index 2065a872..5f056122 100644
--- a/src/EFCore.Ydb/src/EntityFrameworkCore.Ydb.csproj
+++ b/src/EFCore.Ydb/src/EntityFrameworkCore.Ydb.csproj
@@ -14,7 +14,7 @@
-
+
diff --git a/src/EFCore.Ydb/src/Storage/Internal/Mapping/IYdbTypeMapping.cs b/src/EFCore.Ydb/src/Storage/Internal/Mapping/IYdbTypeMapping.cs
new file mode 100644
index 00000000..1aefc55a
--- /dev/null
+++ b/src/EFCore.Ydb/src/Storage/Internal/Mapping/IYdbTypeMapping.cs
@@ -0,0 +1,11 @@
+using Ydb.Sdk.Ado.YdbType;
+
+namespace EntityFrameworkCore.Ydb.Storage.Internal.Mapping;
+
+internal interface IYdbTypeMapping
+{
+ ///
+ /// The database type used by YDB.
+ ///
+ YdbDbType YdbDbType { get; }
+}
diff --git a/src/EFCore.Ydb/src/Storage/Internal/Mapping/YdbBoolTypeMapping.cs b/src/EFCore.Ydb/src/Storage/Internal/Mapping/YdbBoolTypeMapping.cs
index dae16c8c..b0a900bd 100644
--- a/src/EFCore.Ydb/src/Storage/Internal/Mapping/YdbBoolTypeMapping.cs
+++ b/src/EFCore.Ydb/src/Storage/Internal/Mapping/YdbBoolTypeMapping.cs
@@ -15,8 +15,7 @@ private YdbBoolTypeMapping(RelationalTypeMappingParameters parameters)
{
}
- protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters)
- => new YdbBoolTypeMapping(parameters);
+ protected override YdbBoolTypeMapping Clone(RelationalTypeMappingParameters parameters) => new(parameters);
protected override string GenerateNonNullSqlLiteral(object value)
=> (bool)value ? "TRUE" : "FALSE";
diff --git a/src/EFCore.Ydb/src/Storage/Internal/Mapping/YdbBytesTypeMapping.cs b/src/EFCore.Ydb/src/Storage/Internal/Mapping/YdbBytesTypeMapping.cs
index 1d7449ba..6daf133a 100644
--- a/src/EFCore.Ydb/src/Storage/Internal/Mapping/YdbBytesTypeMapping.cs
+++ b/src/EFCore.Ydb/src/Storage/Internal/Mapping/YdbBytesTypeMapping.cs
@@ -8,17 +8,8 @@ public class YdbBytesTypeMapping : RelationalTypeMapping
{
public static YdbBytesTypeMapping Default { get; } = new();
- private YdbBytesTypeMapping() : base(
- new RelationalTypeMappingParameters(
- new CoreTypeMappingParameters(
- typeof(byte[]),
- jsonValueReaderWriter: JsonByteArrayReaderWriter.Instance
- ),
- storeType: "Bytes",
- dbType: System.Data.DbType.Binary,
- unicode: false
- )
- )
+ private YdbBytesTypeMapping() : base("Bytes", typeof(byte[]), System.Data.DbType.Binary,
+ jsonValueReaderWriter: JsonByteArrayReaderWriter.Instance, unicode: false)
{
}
@@ -26,8 +17,7 @@ protected YdbBytesTypeMapping(RelationalTypeMappingParameters parameters) : base
{
}
- protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters)
- => new YdbBytesTypeMapping(parameters);
+ protected override YdbBytesTypeMapping Clone(RelationalTypeMappingParameters parameters) => new(parameters);
protected override string GenerateNonNullSqlLiteral(object value)
{
diff --git a/src/EFCore.Ydb/src/Storage/Internal/Mapping/YdbDateOnlyTypeMapping.cs b/src/EFCore.Ydb/src/Storage/Internal/Mapping/YdbDateOnlyTypeMapping.cs
index 637520d1..5cbb8500 100644
--- a/src/EFCore.Ydb/src/Storage/Internal/Mapping/YdbDateOnlyTypeMapping.cs
+++ b/src/EFCore.Ydb/src/Storage/Internal/Mapping/YdbDateOnlyTypeMapping.cs
@@ -24,8 +24,7 @@ protected YdbDateOnlyTypeMapping(RelationalTypeMappingParameters parameters) : b
{
}
- protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters)
- => new YdbDateOnlyTypeMapping(parameters);
+ protected override YdbDateOnlyTypeMapping Clone(RelationalTypeMappingParameters parameters) => new(parameters);
protected override string GenerateNonNullSqlLiteral(object value)
{
diff --git a/src/EFCore.Ydb/src/Storage/Internal/Mapping/YdbJsonTypeMapping.cs b/src/EFCore.Ydb/src/Storage/Internal/Mapping/YdbJsonTypeMapping.cs
index 4d9df313..4cdc35e6 100644
--- a/src/EFCore.Ydb/src/Storage/Internal/Mapping/YdbJsonTypeMapping.cs
+++ b/src/EFCore.Ydb/src/Storage/Internal/Mapping/YdbJsonTypeMapping.cs
@@ -7,10 +7,12 @@
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Storage;
+using Ydb.Sdk.Ado;
+using Ydb.Sdk.Ado.YdbType;
namespace EntityFrameworkCore.Ydb.Storage.Internal.Mapping;
-public class YdbJsonTypeMapping : JsonTypeMapping
+public class YdbJsonTypeMapping : JsonTypeMapping, IYdbTypeMapping
{
public YdbJsonTypeMapping(string storeType, Type clrType, DbType? dbType) : base(storeType, clrType, dbType)
{
@@ -32,8 +34,7 @@ private static readonly MethodInfo? EncodingGetBytesMethod
private static readonly ConstructorInfo? MemoryStreamConstructor
= typeof(MemoryStream).GetConstructor([typeof(byte[])]);
- protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters)
- => new YdbJsonTypeMapping(parameters);
+ protected override YdbJsonTypeMapping Clone(RelationalTypeMappingParameters parameters) => new(parameters);
public override MethodInfo GetDataReaderMethod() => GetStringMethod;
@@ -72,4 +73,9 @@ public override Expression CustomizeDataReaderExpression(Expression expression)
EncodingGetBytesMethod ?? throw new Exception(),
expression)
);
+
+ public YdbDbType YdbDbType => YdbDbType.Json;
+
+ protected override void ConfigureParameter(DbParameter parameter) =>
+ ((YdbParameter)parameter).YdbDbType = YdbDbType;
}
diff --git a/src/EFCore.Ydb/src/Storage/Internal/Mapping/YdbListTypeMapping.cs b/src/EFCore.Ydb/src/Storage/Internal/Mapping/YdbListTypeMapping.cs
new file mode 100644
index 00000000..08dbb778
--- /dev/null
+++ b/src/EFCore.Ydb/src/Storage/Internal/Mapping/YdbListTypeMapping.cs
@@ -0,0 +1,19 @@
+using System.Collections;
+using System.Data.Common;
+using Microsoft.EntityFrameworkCore.Storage;
+using Ydb.Sdk.Ado;
+using Ydb.Sdk.Ado.YdbType;
+
+namespace EntityFrameworkCore.Ydb.Storage.Internal.Mapping;
+
+internal class YdbListTypeMapping(
+ YdbDbType ydbDbType,
+ string storeTypeElement
+) : RelationalTypeMapping(storeType: $"List<{storeTypeElement}>", typeof(IList))
+{
+ protected override YdbListTypeMapping Clone(RelationalTypeMappingParameters parameters) =>
+ new(ydbDbType, storeTypeElement);
+
+ protected override void ConfigureParameter(DbParameter parameter) =>
+ ((YdbParameter)parameter).YdbDbType = YdbDbType.List | ydbDbType;
+}
diff --git a/src/EFCore.Ydb/src/Storage/Internal/Mapping/YdbTextTypeMapping.cs b/src/EFCore.Ydb/src/Storage/Internal/Mapping/YdbTextTypeMapping.cs
index ffecd91a..ae8a8f84 100644
--- a/src/EFCore.Ydb/src/Storage/Internal/Mapping/YdbTextTypeMapping.cs
+++ b/src/EFCore.Ydb/src/Storage/Internal/Mapping/YdbTextTypeMapping.cs
@@ -3,11 +3,11 @@
namespace EntityFrameworkCore.Ydb.Storage.Internal.Mapping;
-public class YdbTextTypeMapping : RelationalTypeMapping
+public sealed class YdbTextTypeMapping : RelationalTypeMapping
{
public static YdbTextTypeMapping Default { get; } = new("Text");
- public YdbTextTypeMapping(string storeType)
+ private YdbTextTypeMapping(string storeType)
: base(
new RelationalTypeMappingParameters(
new CoreTypeMappingParameters(
@@ -23,7 +23,7 @@ public YdbTextTypeMapping(string storeType)
{
}
- protected YdbTextTypeMapping(RelationalTypeMappingParameters parameters)
+ private YdbTextTypeMapping(RelationalTypeMappingParameters parameters)
: base(parameters)
{
}
@@ -31,7 +31,7 @@ protected YdbTextTypeMapping(RelationalTypeMappingParameters parameters)
protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters)
=> new YdbTextTypeMapping(parameters);
- protected virtual string EscapeSqlLiteral(string literal) => literal.Replace("'", "\\'");
+ private static string EscapeSqlLiteral(string literal) => literal.Replace("'", "\\'");
protected override string GenerateNonNullSqlLiteral(object value) => $"'{EscapeSqlLiteral((string)value)}'u";
}
diff --git a/src/EFCore.Ydb/src/Storage/Internal/YdbTypeMappingSource.cs b/src/EFCore.Ydb/src/Storage/Internal/YdbTypeMappingSource.cs
index fc7d7e49..b01b160e 100644
--- a/src/EFCore.Ydb/src/Storage/Internal/YdbTypeMappingSource.cs
+++ b/src/EFCore.Ydb/src/Storage/Internal/YdbTypeMappingSource.cs
@@ -2,9 +2,11 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Data;
+using System.Linq;
using System.Text.Json;
using EntityFrameworkCore.Ydb.Storage.Internal.Mapping;
using Microsoft.EntityFrameworkCore.Storage;
+using Ydb.Sdk.Ado.YdbType;
using Type = System.Type;
namespace EntityFrameworkCore.Ydb.Storage.Internal;
@@ -15,6 +17,7 @@ RelationalTypeMappingSourceDependencies relationalDependencies
) : RelationalTypeMappingSource(dependencies, relationalDependencies)
{
private static readonly ConcurrentDictionary DecimalCache = new();
+ private static readonly ConcurrentDictionary ListMappings = new();
#region Mappings
@@ -125,19 +128,60 @@ RelationalTypeMappingSourceDependencies relationalDependencies
var clrType = mappingInfo.ClrType;
var storeTypeName = mappingInfo.StoreTypeName;
- if (storeTypeName is null || !StoreTypeMapping.TryGetValue(storeTypeName, out var mappings))
+ if (storeTypeName is not null && StoreTypeMapping.TryGetValue(storeTypeName, out var mappings))
{
- return clrType is null ? null : ClrTypeMapping.GetValueOrDefault(clrType);
- }
+ // We found the user-specified store type. No CLR type was provided - we're probably
+ // scaffolding from an existing database, take the first mapping as the default.
+ if (clrType is null)
+ {
+ return mappings[0];
+ }
- foreach (var m in mappings)
- {
- if (m.ClrType == clrType)
+ // A CLR type was provided - look for a mapping between the store and CLR types. If not found, fail
+ // immediately.
+ foreach (var m in mappings)
{
- return m;
+ if (m.ClrType == clrType)
+ {
+ return m;
+ }
}
}
return clrType is null ? null : ClrTypeMapping.GetValueOrDefault(clrType);
}
+
+ public override RelationalTypeMapping? FindMapping(Type type)
+ {
+ if (type == typeof(byte[]))
+ return base.FindMapping(type);
+
+ var elementType = type.IsArray
+ ? type.GetElementType()
+ : type.GetInterfaces()
+ .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IList<>))?
+ .GetGenericArguments()[0];
+
+ if (elementType == null)
+ return base.FindMapping(type);
+
+ elementType = Nullable.GetUnderlyingType(elementType) ?? elementType;
+
+ var elementTypeMapping = FindMapping(elementType);
+
+ if (elementTypeMapping == null)
+ return base.FindMapping(type);
+
+ var ydbDbType = elementTypeMapping is IYdbTypeMapping ydbTypeMapping
+ ? ydbTypeMapping.YdbDbType
+ : (elementTypeMapping.DbType ?? DbType.Object).ToYdbDbType();
+
+ if (ListMappings.TryGetValue(ydbDbType, out var mapping))
+ return mapping;
+
+ mapping = new YdbListTypeMapping(ydbDbType, elementTypeMapping.StoreType);
+ ListMappings.TryAdd(ydbDbType, mapping);
+
+ return mapping;
+ }
}
diff --git a/src/EFCore.Ydb/test/EntityFrameworkCore.Ydb.FunctionalTests/Query/DecimalParameterizedYdbTest.cs b/src/EFCore.Ydb/test/EntityFrameworkCore.Ydb.FunctionalTests/DecimalParameterizedYdbTest.cs
similarity index 66%
rename from src/EFCore.Ydb/test/EntityFrameworkCore.Ydb.FunctionalTests/Query/DecimalParameterizedYdbTest.cs
rename to src/EFCore.Ydb/test/EntityFrameworkCore.Ydb.FunctionalTests/DecimalParameterizedYdbTest.cs
index a5522380..79399ebf 100644
--- a/src/EFCore.Ydb/test/EntityFrameworkCore.Ydb.FunctionalTests/Query/DecimalParameterizedYdbTest.cs
+++ b/src/EFCore.Ydb/test/EntityFrameworkCore.Ydb.FunctionalTests/DecimalParameterizedYdbTest.cs
@@ -5,7 +5,7 @@
using Microsoft.EntityFrameworkCore.Storage;
using Xunit;
-namespace EntityFrameworkCore.Ydb.FunctionalTests.Query;
+namespace EntityFrameworkCore.Ydb.FunctionalTests;
public class DecimalParameterizedYdbTest
{
@@ -13,7 +13,6 @@ private static DbContextOptions BuildOptions() =>
new DbContextOptionsBuilder()
.UseYdb("Host=localhost;Port=2136")
.EnableServiceProviderCaching(false)
- .LogTo(Console.WriteLine)
.Options;
public static TheoryData SuccessCases => new()
@@ -52,23 +51,17 @@ public async Task Should_RoundtripDecimal_When_ValueFitsPrecisionAndScale(int p,
await using var ctx = NewCtx(p, s);
await testStore.CleanAsync(ctx);
await ctx.Database.EnsureCreatedAsync();
- try
- {
- var e = new ParamItem { Price = value };
- ctx.Add(e);
- await ctx.SaveChangesAsync();
- var got = await ctx.Items.SingleAsync(x => x.Id == e.Id);
- Assert.Equal(value, got.Price);
- var tms = ctx.GetService();
- var et = ctx.Model.FindEntityType(typeof(ParamItem))!;
- var prop = et.FindProperty(nameof(ParamItem.Price))!;
- var mapping = tms.FindMapping(prop)!;
- Assert.Equal($"Decimal({p}, {s})", mapping.StoreType);
- }
- finally
- {
- await ctx.Database.EnsureDeletedAsync();
- }
+
+ var e = new ParamItem { Price = value };
+ ctx.Add(e);
+ await ctx.SaveChangesAsync();
+ var got = await ctx.Items.SingleAsync(x => x.Id == e.Id);
+ Assert.Equal(value, got.Price);
+ var tms = ctx.GetService();
+ var et = ctx.Model.FindEntityType(typeof(ParamItem))!;
+ var prop = et.FindProperty(nameof(ParamItem.Price))!;
+ var mapping = tms.FindMapping(prop)!;
+ Assert.Equal($"Decimal({p}, {s})", mapping.StoreType);
}
[Theory]
@@ -80,15 +73,9 @@ public async Task Should_ThrowOverflow_When_ValueExceedsPrecisionOrScale(int p,
await using var ctx = NewCtx(p, s);
await testStore.CleanAsync(ctx);
await ctx.Database.EnsureCreatedAsync();
- try
- {
- ctx.Add(new ParamItem { Price = value });
- await Assert.ThrowsAsync(() => ctx.SaveChangesAsync());
- }
- finally
- {
- await ctx.Database.EnsureDeletedAsync();
- }
+
+ ctx.Add(new ParamItem { Price = value });
+ await Assert.ThrowsAsync(() => ctx.SaveChangesAsync());
}
[Theory]
@@ -101,25 +88,19 @@ public async Task Should_SumDecimal_When_ValueFitsPrecisionAndScale(int p, int s
await using var ctx = NewCtx(p, s);
await testStore.CleanAsync(ctx);
await ctx.Database.EnsureCreatedAsync();
- try
- {
- for (var i = 0; i < multiplier; i++)
- ctx.Add(new ParamItem { Price = value });
- await ctx.SaveChangesAsync();
- var got = await ctx.Items.Select(x => x.Price).SumAsync();
-
- Assert.Equal(value * multiplier, got);
-
- var tms = ctx.GetService();
- var et = ctx.Model.FindEntityType(typeof(ParamItem))!;
- var prop = et.FindProperty(nameof(ParamItem.Price))!;
- var mapping = tms.FindMapping(prop)!;
- Assert.Equal($"Decimal({p}, {s})", mapping.StoreType);
- }
- finally
- {
- await ctx.Database.EnsureDeletedAsync();
- }
+
+ for (var i = 0; i < multiplier; i++)
+ ctx.Add(new ParamItem { Price = value });
+ await ctx.SaveChangesAsync();
+ var got = await ctx.Items.Select(x => x.Price).SumAsync();
+
+ Assert.Equal(value * multiplier, got);
+
+ var tms = ctx.GetService();
+ var et = ctx.Model.FindEntityType(typeof(ParamItem))!;
+ var prop = et.FindProperty(nameof(ParamItem.Price))!;
+ var mapping = tms.FindMapping(prop)!;
+ Assert.Equal($"Decimal({p}, {s})", mapping.StoreType);
}
public sealed class ParametricDecimalContext(DbContextOptions options, int p, int s)
diff --git a/src/EFCore.Ydb/test/EntityFrameworkCore.Ydb.FunctionalTests/SqlQueryCollectionParameterTests.cs b/src/EFCore.Ydb/test/EntityFrameworkCore.Ydb.FunctionalTests/SqlQueryCollectionParameterTests.cs
new file mode 100644
index 00000000..0fe025e9
--- /dev/null
+++ b/src/EFCore.Ydb/test/EntityFrameworkCore.Ydb.FunctionalTests/SqlQueryCollectionParameterTests.cs
@@ -0,0 +1,142 @@
+using EntityFrameworkCore.Ydb.Extensions;
+using EntityFrameworkCore.Ydb.FunctionalTests.TestUtilities;
+using Microsoft.EntityFrameworkCore;
+using Xunit;
+
+namespace EntityFrameworkCore.Ydb.FunctionalTests;
+
+public class SqlQueryCollectionParameterTests
+{
+ private static readonly DateTime SomeTimestamp = DateTime.Parse("2025-11-02T18:47:14.112353");
+
+ public static IEnumerable