From 966929bfb626e9859ffc7603a99e03534f9583ba Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Mon, 6 Jan 2020 14:46:28 +0100 Subject: [PATCH] Translate List operations to PG array * Match our List translation capabilities to CLR array. * Improve some mapping and inference aspects. Closes #395 --- .../NpgsqlNodaTimeTypeMappingSourcePlugin.cs | 19 +- src/EFCore.PG/Extensions/TypeExtensions.cs | 15 + ...Translator.cs => NpgsqlArrayTranslator.cs} | 40 +- .../NpgsqlMemberTranslatorProvider.cs | 1 + .../NpgsqlMethodCallTranslatorProvider.cs | 2 +- .../Internal/ArrayIndexExpression.cs | 4 +- .../Internal/NpgsqlSqlExpressionFactory.cs | 20 +- .../NpgsqlSqlTranslatingExpressionVisitor.cs | 11 +- .../Mapping/NpgsqlArrayArrayTypeMapping.cs | 207 ++++++++++ ...pping.cs => NpgsqlArrayListTypeMapping.cs} | 56 +-- .../Mapping/NpgsqlArrayTypeMapping.cs | 210 +--------- .../Internal/NpgsqlTypeMappingSource.cs | 20 +- ...rayQueryTest.cs => ArrayArrayQueryTest.cs} | 113 +++--- .../Query/ArrayListQueryTest.cs | 367 ++++++++++++++++++ 14 files changed, 768 insertions(+), 317 deletions(-) create mode 100644 src/EFCore.PG/Extensions/TypeExtensions.cs rename src/EFCore.PG/Query/ExpressionTranslators/Internal/{NpgsqlArrayMethodTranslator.cs => NpgsqlArrayTranslator.cs} (74%) create mode 100644 src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayArrayTypeMapping.cs rename src/EFCore.PG/Storage/Internal/Mapping/{NpgsqlListTypeMapping.cs => NpgsqlArrayListTypeMapping.cs} (82%) rename test/EFCore.PG.FunctionalTests/Query/{ArrayQueryTest.cs => ArrayArrayQueryTest.cs} (83%) create mode 100644 test/EFCore.PG.FunctionalTests/Query/ArrayListQueryTest.cs diff --git a/src/EFCore.PG.NodaTime/Storage/Internal/NpgsqlNodaTimeTypeMappingSourcePlugin.cs b/src/EFCore.PG.NodaTime/Storage/Internal/NpgsqlNodaTimeTypeMappingSourcePlugin.cs index b6d135ce65..1999e9ab0a 100644 --- a/src/EFCore.PG.NodaTime/Storage/Internal/NpgsqlNodaTimeTypeMappingSourcePlugin.cs +++ b/src/EFCore.PG.NodaTime/Storage/Internal/NpgsqlNodaTimeTypeMappingSourcePlugin.cs @@ -155,9 +155,20 @@ RelationalTypeMapping FindArrayMapping(in RelationalTypeMappingInfo mappingInfo) var elementStoreType = storeType.Substring(0, storeType.Length - 2); var elementMapping = FindExistingMapping(new RelationalTypeMappingInfo(elementStoreType, elementStoreType, mappingInfo.IsUnicode, mappingInfo.Size, mappingInfo.Precision, mappingInfo.Scale)); + if (elementMapping != null) - return StoreTypeMappings.GetOrAdd(storeType, - new RelationalTypeMapping[] { new NpgsqlArrayTypeMapping(storeType, elementMapping) })[0]; + { + var added = StoreTypeMappings.TryAdd(storeType, + new RelationalTypeMapping[] + { + new NpgsqlArrayArrayTypeMapping(storeType, elementMapping), + new NpgsqlArrayListTypeMapping(storeType, elementMapping) + }); + Debug.Assert(added); + var mapping = FindExistingMapping(mappingInfo); + Debug.Assert(mapping != null); + return mapping; + } } var clrType = mappingInfo.ClrType; @@ -178,7 +189,7 @@ RelationalTypeMapping FindArrayMapping(in RelationalTypeMappingInfo mappingInfo) if (elementMapping is NpgsqlArrayTypeMapping) return null; - return ClrTypeMappings.GetOrAdd(clrType, new NpgsqlArrayTypeMapping(elementMapping, clrType)); + return ClrTypeMappings.GetOrAdd(clrType, new NpgsqlArrayArrayTypeMapping(elementMapping, clrType)); } if (clrType.IsGenericType && clrType.GetGenericTypeDefinition() == typeof(List<>)) @@ -194,7 +205,7 @@ RelationalTypeMapping FindArrayMapping(in RelationalTypeMappingInfo mappingInfo) if (elementMapping is NpgsqlArrayTypeMapping) return null; - return ClrTypeMappings.GetOrAdd(clrType, new NpgsqlListTypeMapping(elementMapping, clrType)); + return ClrTypeMappings.GetOrAdd(clrType, new NpgsqlArrayListTypeMapping(elementMapping, clrType)); } return null; diff --git a/src/EFCore.PG/Extensions/TypeExtensions.cs b/src/EFCore.PG/Extensions/TypeExtensions.cs new file mode 100644 index 0000000000..c02bf4d81e --- /dev/null +++ b/src/EFCore.PG/Extensions/TypeExtensions.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +// ReSharper disable once CheckNamespace +namespace Npgsql.EntityFrameworkCore.PostgreSQL +{ + internal static class TypeExtensions + { + internal static bool IsGenericList(this Type type) + => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>); + + internal static bool IsArrayOrGenericList(this Type type) + => type.IsArray || type.IsGenericList(); + } +} diff --git a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlArrayMethodTranslator.cs b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlArrayTranslator.cs similarity index 74% rename from src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlArrayMethodTranslator.cs rename to src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlArrayTranslator.cs index edab868c2e..1971673c45 100644 --- a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlArrayMethodTranslator.cs +++ b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlArrayTranslator.cs @@ -13,12 +13,12 @@ namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query.ExpressionTranslators.Internal { /// - /// Translates functions on arrays into their corresponding PostgreSQL operations. + /// Translates method and property calls on arrays/lists into their corresponding PostgreSQL operations. /// /// /// https://www.postgresql.org/docs/current/static/functions-array.html /// - public class NpgsqlArrayMethodTranslator : IMethodCallTranslator + public class NpgsqlArrayTranslator : IMethodCallTranslator, IMemberTranslator { [NotNull] static readonly MethodInfo SequenceEqual = typeof(Enumerable).GetTypeInfo().GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) @@ -38,7 +38,7 @@ public class NpgsqlArrayMethodTranslator : IMethodCallTranslator [NotNull] readonly NpgsqlJsonPocoTranslator _jsonPocoTranslator; - public NpgsqlArrayMethodTranslator(NpgsqlSqlExpressionFactory sqlExpressionFactory, NpgsqlJsonPocoTranslator jsonPocoTranslator) + public NpgsqlArrayTranslator(NpgsqlSqlExpressionFactory sqlExpressionFactory, NpgsqlJsonPocoTranslator jsonPocoTranslator) { _sqlExpressionFactory = sqlExpressionFactory; _jsonPocoTranslator = jsonPocoTranslator; @@ -47,7 +47,17 @@ public NpgsqlArrayMethodTranslator(NpgsqlSqlExpressionFactory sqlExpressionFacto [CanBeNull] public SqlExpression Translate(SqlExpression instance, MethodInfo method, IReadOnlyList arguments) { - // TODO: Fully support List<> + if (instance != null && instance.Type.IsGenericList()) + { + if (method.Name == "get_Item" && arguments.Count == 1) + { + return + // Try translating indexing inside json column + _jsonPocoTranslator.TranslateMemberAccess(instance, arguments[0], method.ReturnType) ?? + // Other types should be subscriptable - but PostgreSQL arrays are 1-based, so adjust the index. + _sqlExpressionFactory.ArrayIndex(instance, GenerateOneBasedIndexExpression(arguments[0])); + } + } if (arguments.Count == 0) return null; @@ -56,7 +66,7 @@ public SqlExpression Translate(SqlExpression instance, MethodInfo method, IReadO var operandElementType = operand.Type.IsArray ? operand.Type.GetElementType() - : operand.Type.IsGenericType && operand.Type.GetGenericTypeDefinition() == typeof(List<>) + : operand.Type.IsGenericList() ? operand.Type.GetGenericArguments()[0] : null; @@ -122,5 +132,25 @@ public SqlExpression Translate(SqlExpression instance, MethodInfo method, IReadO return null; } + + public SqlExpression Translate(SqlExpression instance, MemberInfo member, Type returnType) + { + if (instance != null && instance.Type.IsGenericList() && member.Name == nameof(List.Count)) + { + return _jsonPocoTranslator.TranslateArrayLength(instance) ?? + _sqlExpressionFactory.Function("cardinality", new[] { instance }, typeof(int?)); + } + + return null; + } + + /// + /// PostgreSQL array indexing is 1-based. If the index happens to be a constant, + /// just increment it. Otherwise, append a +1 in the SQL. + /// + SqlExpression GenerateOneBasedIndexExpression([NotNull] SqlExpression expression) + => expression is SqlConstantExpression constant + ? _sqlExpressionFactory.Constant(Convert.ToInt32(constant.Value) + 1, constant.TypeMapping) + : (SqlExpression)_sqlExpressionFactory.Add(expression, _sqlExpressionFactory.Constant(1)); } } diff --git a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMemberTranslatorProvider.cs b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMemberTranslatorProvider.cs index c2c8fd0bcd..7a61bf8de6 100644 --- a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMemberTranslatorProvider.cs +++ b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMemberTranslatorProvider.cs @@ -23,6 +23,7 @@ public NpgsqlMemberTranslatorProvider( AddTranslators( new IMemberTranslator[] { + new NpgsqlArrayTranslator(npgsqlSqlExpressionFactory, JsonPocoTranslator), new NpgsqlStringMemberTranslator(npgsqlSqlExpressionFactory), new NpgsqlDateTimeMemberTranslator(npgsqlSqlExpressionFactory), new NpgsqlRangeTranslator(npgsqlSqlExpressionFactory), diff --git a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMethodCallTranslatorProvider.cs b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMethodCallTranslatorProvider.cs index 9f13dc75a1..7bd71c3336 100644 --- a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMethodCallTranslatorProvider.cs +++ b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMethodCallTranslatorProvider.cs @@ -19,7 +19,7 @@ public NpgsqlMethodCallTranslatorProvider( AddTranslators(new IMethodCallTranslator[] { - new NpgsqlArrayMethodTranslator(npgsqlSqlExpressionFactory, jsonTranslator), + new NpgsqlArrayTranslator(npgsqlSqlExpressionFactory, jsonTranslator), new NpgsqlConvertTranslator(npgsqlSqlExpressionFactory), new NpgsqlDateTimeMethodTranslator(npgsqlSqlExpressionFactory, npgsqlTypeMappingSource), new NpgsqlNewGuidTranslator(npgsqlSqlExpressionFactory), diff --git a/src/EFCore.PG/Query/Expressions/Internal/ArrayIndexExpression.cs b/src/EFCore.PG/Query/Expressions/Internal/ArrayIndexExpression.cs index 4580b42c3c..ff5b263f9c 100644 --- a/src/EFCore.PG/Query/Expressions/Internal/ArrayIndexExpression.cs +++ b/src/EFCore.PG/Query/Expressions/Internal/ArrayIndexExpression.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq.Expressions; using JetBrains.Annotations; using Microsoft.EntityFrameworkCore.Query; @@ -28,8 +29,7 @@ public ArrayIndexExpression( Check.NotNull(array, nameof(array)); Check.NotNull(index, nameof(index)); - // TODO: Support also List<> - if (!array.Type.IsArray) + if (!array.Type.IsArray && !array.Type.IsGenericList()) throw new ArgumentException("Array expression must of an array type", nameof(array)); if (index.Type != typeof(int)) throw new ArgumentException("Index expression must of type int", nameof(index)); diff --git a/src/EFCore.PG/Query/Internal/NpgsqlSqlExpressionFactory.cs b/src/EFCore.PG/Query/Internal/NpgsqlSqlExpressionFactory.cs index 93cd06e088..9567eb2120 100644 --- a/src/EFCore.PG/Query/Internal/NpgsqlSqlExpressionFactory.cs +++ b/src/EFCore.PG/Query/Internal/NpgsqlSqlExpressionFactory.cs @@ -42,11 +42,14 @@ public ArrayIndexExpression ArrayIndex( SqlExpression index, RelationalTypeMapping typeMapping = null) { - // TODO: Support List<> - if (!array.Type.IsArray) - throw new ArgumentException("Array expression must of an array type", nameof(array)); + Type elementType; + if (array.Type.IsArray) + elementType = array.Type.GetElementType(); + else if (array.Type.IsGenericList()) + elementType = array.Type.GetGenericArguments()[0]; + else + throw new ArgumentException("Array expression must be of an array or List<> type", nameof(array)); - var elementType = array.Type.GetElementType(); return (ArrayIndexExpression)ApplyTypeMapping(new ArrayIndexExpression(array, index, elementType, null), typeMapping); } @@ -105,7 +108,7 @@ public override SqlExpression ApplyTypeMapping(SqlExpression sqlExpression, Rela // PostgreSQL-specific expression types RegexMatchExpression e => ApplyTypeMappingOnRegexMatch(e), ArrayAnyAllExpression e => ApplyTypeMappingOnArrayAnyAll(e), - ArrayIndexExpression e => ApplyTypeMappingOnArrayIndex(e), + ArrayIndexExpression e => ApplyTypeMappingOnArrayIndex(e, typeMapping), ILikeExpression e => ApplyTypeMappingOnILike(e), PgFunctionExpression e => e.ApplyTypeMapping(typeMapping), @@ -194,14 +197,17 @@ SqlExpression ApplyTypeMappingOnArrayAnyAll(ArrayAnyAllExpression arrayAnyAllExp _boolTypeMapping); } - SqlExpression ApplyTypeMappingOnArrayIndex(ArrayIndexExpression arrayIndexExpression) + SqlExpression ApplyTypeMappingOnArrayIndex( + ArrayIndexExpression arrayIndexExpression, RelationalTypeMapping typeMapping) => new ArrayIndexExpression( + // TODO: Infer the array's mapping from the element ApplyDefaultTypeMapping(arrayIndexExpression.Array), ApplyDefaultTypeMapping(arrayIndexExpression.Index), arrayIndexExpression.Type, + // If the array has a type mapping (i.e. column), prefer that just like we prefer column mappings in general arrayIndexExpression.Array.TypeMapping is NpgsqlArrayTypeMapping arrayMapping ? arrayMapping.ElementMapping - : FindMapping(arrayIndexExpression.Type)); + : typeMapping ?? FindMapping(arrayIndexExpression.Type)); SqlExpression ApplyTypeMappingOnILike(ILikeExpression ilikeExpression) { diff --git a/src/EFCore.PG/Query/Internal/NpgsqlSqlTranslatingExpressionVisitor.cs b/src/EFCore.PG/Query/Internal/NpgsqlSqlTranslatingExpressionVisitor.cs index 69b160ebea..18c657d820 100644 --- a/src/EFCore.PG/Query/Internal/NpgsqlSqlTranslatingExpressionVisitor.cs +++ b/src/EFCore.PG/Query/Internal/NpgsqlSqlTranslatingExpressionVisitor.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; using System.Linq; @@ -127,9 +128,11 @@ protected override Expression VisitMethodCall(MethodCallExpression methodCall) if (visited != null) return visited; - // TODO: Handle List<> - if (methodCall.Arguments.Count > 0 && methodCall.Arguments[0].Type.IsArray) + if (methodCall.Arguments.Count > 0 && ( + methodCall.Arguments[0].Type.IsArray || methodCall.Arguments[0].Type.IsGenericList())) + { return VisitArrayMethodCall(methodCall.Method, methodCall.Arguments); + } return null; } @@ -183,7 +186,7 @@ arguments[1] is LambdaExpression wherePredicate && arguments[1] is LambdaExpression wherePredicate && wherePredicate.Body is MethodCallExpression wherePredicateMethodCall && wherePredicateMethodCall.Method.IsClosedFormOf(Contains) && - wherePredicateMethodCall.Arguments[0].Type.IsArray && + wherePredicateMethodCall.Arguments[0].Type.IsArrayOrGenericList() && wherePredicateMethodCall.Arguments[1] is ParameterExpression parameterExpression && parameterExpression == wherePredicate.Parameters[0]) { @@ -207,7 +210,7 @@ wherePredicateMethodCall.Arguments[1] is ParameterExpression parameterExpression arguments[1] is LambdaExpression wherePredicate && wherePredicate.Body is MethodCallExpression wherePredicateMethodCall && wherePredicateMethodCall.Method.IsClosedFormOf(Contains) && - wherePredicateMethodCall.Arguments[0].Type.IsArray && + wherePredicateMethodCall.Arguments[0].Type.IsArrayOrGenericList() && wherePredicateMethodCall.Arguments[1] is ParameterExpression parameterExpression && parameterExpression == wherePredicate.Parameters[0]) { diff --git a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayArrayTypeMapping.cs b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayArrayTypeMapping.cs new file mode 100644 index 0000000000..54b534586f --- /dev/null +++ b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayArrayTypeMapping.cs @@ -0,0 +1,207 @@ +using System; +using System.Text; +using Microsoft.EntityFrameworkCore.Storage; +using System.Diagnostics; +using Microsoft.EntityFrameworkCore.ChangeTracking; + +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.Mapping +{ + /// + /// Maps PostgreSQL arrays to .NET arrays. Only single-dimensional arrays are supported. + /// + /// + /// Note that mapping PostgreSQL arrays to .NET List{T} is also supported via . + /// See: https://www.postgresql.org/docs/current/static/arrays.html + /// + public class NpgsqlArrayArrayTypeMapping : NpgsqlArrayTypeMapping + { + /// + /// Creates the default array mapping (i.e. for the single-dimensional CLR array type) + /// + /// The database type to map. + /// The element type mapping. + public NpgsqlArrayArrayTypeMapping(string storeType, RelationalTypeMapping elementMapping) + : this(storeType, elementMapping, elementMapping.ClrType.MakeArrayType()) {} + + /// + /// Creates the default array mapping (i.e. for the single-dimensional CLR array type) + /// + /// The element type mapping. + /// The array type to map. + public NpgsqlArrayArrayTypeMapping(RelationalTypeMapping elementMapping, Type arrayType) + : this(elementMapping.StoreType + "[]", elementMapping, arrayType) {} + + NpgsqlArrayArrayTypeMapping(string storeType, RelationalTypeMapping elementMapping, Type arrayType) + : this(new RelationalTypeMappingParameters( + new CoreTypeMappingParameters(arrayType, null, CreateComparer(elementMapping, arrayType)), storeType + ), elementMapping) {} + + protected NpgsqlArrayArrayTypeMapping( + RelationalTypeMappingParameters parameters, RelationalTypeMapping elementMapping) + : base(parameters, elementMapping) + { + if (!parameters.CoreParameters.ClrType.IsArray) + throw new ArgumentException("ClrType must be an array", nameof(parameters)); + } + + protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) + => new NpgsqlArrayArrayTypeMapping(parameters, ElementMapping); + + #region Value Comparison + + static ValueComparer CreateComparer(RelationalTypeMapping elementMapping, Type arrayType) + { + Debug.Assert(arrayType.IsArray); + var elementType = arrayType.GetElementType(); + + // We currently don't support mapping multi-dimensional arrays. + if (arrayType.GetArrayRank() != 1) + return null; + + // We use different comparer implementations based on whether we have a non-null element comparer, + // and if not, whether the element is IEquatable + + if (elementMapping.Comparer != null) + return (ValueComparer)Activator.CreateInstance( + typeof(SingleDimComparerWithComparer<>).MakeGenericType(elementType), elementMapping); + + if (typeof(IEquatable<>).MakeGenericType(elementType).IsAssignableFrom(elementType)) + return (ValueComparer)Activator.CreateInstance(typeof(SingleDimComparerWithIEquatable<>).MakeGenericType(elementType)); + + // There's no custom comparer, and the element type doesn't implement IEquatable. We have + // no choice but to use the non-generic Equals method. + return (ValueComparer)Activator.CreateInstance(typeof(SingleDimComparerWithEquals<>).MakeGenericType(elementType)); + } + + class SingleDimComparerWithComparer : ValueComparer + { + public SingleDimComparerWithComparer(RelationalTypeMapping elementMapping) : base( + (a, b) => Compare(a, b, (ValueComparer)elementMapping.Comparer), + o => o.GetHashCode(), // TODO: Need to get hash code of elements... + source => Snapshot(source, (ValueComparer)elementMapping.Comparer)) {} + + public override Type Type => typeof(TElem[]); + + static bool Compare(TElem[] a, TElem[] b, ValueComparer elementComparer) + { + if (a.Length != b.Length) + return false; + + // Note: the following currently boxes every element access because ValueComparer isn't really + // generic (see https://github.com/aspnet/EntityFrameworkCore/issues/11072) + for (var i = 0; i < a.Length; i++) + if (!elementComparer.Equals(a[i], b[i])) + return false; + + return true; + } + + static TElem[] Snapshot(TElem[] source, ValueComparer elementComparer) + { + if (source == null) + return null; + + var snapshot = new TElem[source.Length]; + // Note: the following currently boxes every element access because ValueComparer isn't really + // generic (see https://github.com/aspnet/EntityFrameworkCore/issues/11072) + for (var i = 0; i < source.Length; i++) + snapshot[i] = elementComparer.Snapshot(source[i]); + return snapshot; + } + } + + class SingleDimComparerWithIEquatable : ValueComparer + where TElem : IEquatable + { + public SingleDimComparerWithIEquatable() : base( + (a, b) => Compare(a, b), + o => o.GetHashCode(), // TODO: Need to get hash code of elements... + source => DoSnapshot(source)) {} + + public override Type Type => typeof(TElem[]); + + static bool Compare(TElem[] a, TElem[] b) + { + if (a.Length != b.Length) + return false; + + for (var i = 0; i < a.Length; i++) + { + var elem1 = a[i]; + var elem2 = b[i]; + // Note: the following null checks are elided if TElem is a value type + if (elem1 == null) + { + if (elem2 == null) + continue; + return false; + } + + if (!elem1.Equals(elem2)) + return false; + } + + return true; + } + + static TElem[] DoSnapshot(TElem[] source) + { + if (source == null) + return null; + var snapshot = new TElem[source.Length]; + source.CopyTo(snapshot, 0); + return snapshot; + } + } + + class SingleDimComparerWithEquals : ValueComparer + { + public SingleDimComparerWithEquals() : base( + (a, b) => Compare(a, b), + o => o.GetHashCode(), // TODO: Need to get hash code of elements... + source => DoSnapshot(source)) {} + + public override Type Type => typeof(TElem[]); + + static bool Compare(TElem[] a, TElem[] b) + { + if (a.Length != b.Length) + return false; + + // Note: the following currently boxes every element access because ValueComparer isn't really + // generic (see https://github.com/aspnet/EntityFrameworkCore/issues/11072) + for (var i = 0; i < a.Length; i++) + { + var elem1 = a[i]; + var elem2 = b[i]; + if (elem1 == null) + { + if (elem2 == null) + continue; + return false; + } + + if (!elem1.Equals(elem2)) + return false; + } + + return true; + } + + static TElem[] DoSnapshot(TElem[] source) + { + if (source == null) + return null; + + var snapshot = new TElem[source.Length]; + // Note: the following currently boxes every element access because ValueComparer isn't really + // generic (see https://github.com/aspnet/EntityFrameworkCore/issues/11072) + for (var i = 0; i < source.Length; i++) + snapshot[i] = source[i]; + return snapshot; + } + } + + #endregion Value Comparison + } +} diff --git a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlListTypeMapping.cs b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayListTypeMapping.cs similarity index 82% rename from src/EFCore.PG/Storage/Internal/Mapping/NpgsqlListTypeMapping.cs rename to src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayListTypeMapping.cs index abb86f9ee5..1ede30662f 100644 --- a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlListTypeMapping.cs +++ b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayListTypeMapping.cs @@ -1,6 +1,5 @@ using System; using System.Collections; -using System.Text; using Microsoft.EntityFrameworkCore.Storage; using System.Collections.Generic; using System.Diagnostics; @@ -12,64 +11,43 @@ namespace Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.Mapping /// Maps PostgreSQL arrays to . /// /// - /// Note that mapping PostgreSQL arrays to .NET arrays is also supported via . + /// Note that mapping PostgreSQL arrays to .NET arrays is also supported via . /// See: https://www.postgresql.org/docs/current/static/arrays.html /// - public class NpgsqlListTypeMapping : RelationalTypeMapping + public class NpgsqlArrayListTypeMapping : NpgsqlArrayTypeMapping { - // ReSharper disable once MemberCanBePrivate.Global /// - /// The relational type mapping used to initialize the list mapping. + /// Creates the default list mapping. /// - public RelationalTypeMapping ElementMapping { get; } + /// The database type to map. + /// The element type mapping. + public NpgsqlArrayListTypeMapping(string storeType, RelationalTypeMapping elementMapping) + : this(storeType, elementMapping, typeof(List<>).MakeGenericType(elementMapping.ClrType)) {} /// /// Creates the default list mapping. /// /// The element type mapping. /// The database type to map. - public NpgsqlListTypeMapping(RelationalTypeMapping elementMapping, Type listType) + public NpgsqlArrayListTypeMapping(RelationalTypeMapping elementMapping, Type listType) : this(elementMapping.StoreType + "[]", elementMapping, listType) {} - /// - NpgsqlListTypeMapping(string storeType, RelationalTypeMapping elementMapping, Type listType) + NpgsqlArrayListTypeMapping(string storeType, RelationalTypeMapping elementMapping, Type listType) : this(new RelationalTypeMappingParameters( new CoreTypeMappingParameters(listType, null, CreateComparer(elementMapping, listType)), storeType ), elementMapping) {} - /// - protected NpgsqlListTypeMapping(RelationalTypeMappingParameters parameters, RelationalTypeMapping elementMapping) - : base(parameters) - => ElementMapping = elementMapping; - - /// - protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) - => new NpgsqlListTypeMapping(parameters, ElementMapping); - - /// - protected override string GenerateNonNullSqlLiteral(object value) + protected NpgsqlArrayListTypeMapping( + RelationalTypeMappingParameters parameters, RelationalTypeMapping elementMapping) + : base(parameters, elementMapping) { - var list = (IList)value; - - if (list.GetType().GenericTypeArguments[0] != ElementMapping.ClrType) - throw new NotSupportedException("Multidimensional array literals aren't supported"); - - var sb = new StringBuilder(); - sb.Append("ARRAY["); - for (var i = 0; i < list.Count; i++) - { - if (i > 0) - sb.Append(','); - - sb.Append(ElementMapping.GenerateSqlLiteral(list[i])); - } - - sb.Append("]::"); - sb.Append(ElementMapping.StoreType); - sb.Append("[]"); - return sb.ToString(); + if (!parameters.CoreParameters.ClrType.IsGenericList()) + throw new ArgumentException("ClrType must be a List<>", nameof(parameters)); } + protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) + => new NpgsqlArrayListTypeMapping(parameters, ElementMapping); + #region Value Comparison // Note that the value comparison code is largely duplicated from NpgsqlArrayTypeMapping. diff --git a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayTypeMapping.cs b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayTypeMapping.cs index cf3b53289a..650ff1377a 100644 --- a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayTypeMapping.cs +++ b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlArrayTypeMapping.cs @@ -1,67 +1,49 @@ using System; +using System.Collections; +using System.Collections.Generic; using System.Text; using Microsoft.EntityFrameworkCore.Storage; -using System.Diagnostics; -using Microsoft.EntityFrameworkCore.ChangeTracking; namespace Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.Mapping { /// - /// Maps PostgreSQL arrays to .NET arrays. Only single-dimensional arrays are supported. + /// Abstract base class for PostgreSQL array mappings (i.e. CLR array and . /// /// - /// Note that mapping PostgreSQL arrays to .NET List{T} is also supported via . /// See: https://www.postgresql.org/docs/current/static/arrays.html /// - public class NpgsqlArrayTypeMapping : RelationalTypeMapping + public abstract class NpgsqlArrayTypeMapping : RelationalTypeMapping { - // ReSharper disable once MemberCanBePrivate.Global /// /// The relational type mapping used to initialize the array mapping. /// public RelationalTypeMapping ElementMapping { get; } - /// - /// Creates the default array mapping (i.e. for the single-dimensional CLR array type) - /// - /// The database type to map. - /// The element type mapping. - public NpgsqlArrayTypeMapping(string storeType, RelationalTypeMapping elementMapping) - : this(storeType, elementMapping, elementMapping.ClrType.MakeArrayType()) {} - - /// - /// Creates the default array mapping (i.e. for the single-dimensional CLR array type) - /// - /// The element type mapping. - /// The array type to map. - public NpgsqlArrayTypeMapping(RelationalTypeMapping elementMapping, Type arrayType) - : this(elementMapping.StoreType + "[]", elementMapping, arrayType) {} - - NpgsqlArrayTypeMapping(string storeType, RelationalTypeMapping elementMapping, Type arrayType) - : this(new RelationalTypeMappingParameters( - new CoreTypeMappingParameters(arrayType, null, CreateComparer(elementMapping, arrayType)), storeType - ), elementMapping) {} - protected NpgsqlArrayTypeMapping(RelationalTypeMappingParameters parameters, RelationalTypeMapping elementMapping) : base(parameters) => ElementMapping = elementMapping; - protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) - => new NpgsqlArrayTypeMapping(parameters, ElementMapping); - + // The array-to-array mapping needs to know how to generate an SQL literal for a List<>, and + // the list-to-array mapping needs to know how to generate an SQL literal for an array. + // This is because in cases such as ctx.SomeListColumn.SequenceEquals(new[] { 1, 2, 3}), the list mapping + // from the left side gets applied to the right side. protected override string GenerateNonNullSqlLiteral(object value) { - var arr = (Array)value; + var type = value.GetType(); - if (arr.Rank != 1) + if (!type.IsArray && !type.IsGenericList()) + throw new ArgumentException("Parameter must be an array or List<>", nameof(value)); + if (value is Array array && array.Rank != 1) throw new NotSupportedException("Multidimensional array literals aren't supported"); + var list = (IList)value; + var sb = new StringBuilder(); sb.Append("ARRAY["); - for (var i = 0; i < arr.Length; i++) + for (var i = 0; i < list.Count; i++) { - sb.Append(ElementMapping.GenerateSqlLiteral(arr.GetValue(i))); - if (i < arr.Length - 1) + sb.Append(ElementMapping.GenerateSqlLiteral(list[i])); + if (i < list.Count - 1) sb.Append(","); } @@ -70,163 +52,5 @@ protected override string GenerateNonNullSqlLiteral(object value) sb.Append("[]"); return sb.ToString(); } - - #region Value Comparison - - static ValueComparer CreateComparer(RelationalTypeMapping elementMapping, Type arrayType) - { - Debug.Assert(arrayType.IsArray); - var elementType = arrayType.GetElementType(); - - // We currently don't support mapping multi-dimensional arrays. - if (arrayType.GetArrayRank() != 1) - return null; - - // We use different comparer implementations based on whether we have a non-null element comparer, - // and if not, whether the element is IEquatable - - if (elementMapping.Comparer != null) - return (ValueComparer)Activator.CreateInstance( - typeof(SingleDimComparerWithComparer<>).MakeGenericType(elementType), elementMapping); - - if (typeof(IEquatable<>).MakeGenericType(elementType).IsAssignableFrom(elementType)) - return (ValueComparer)Activator.CreateInstance(typeof(SingleDimComparerWithIEquatable<>).MakeGenericType(elementType)); - - // There's no custom comparer, and the element type doesn't implement IEquatable. We have - // no choice but to use the non-generic Equals method. - return (ValueComparer)Activator.CreateInstance(typeof(SingleDimComparerWithEquals<>).MakeGenericType(elementType)); - } - - class SingleDimComparerWithComparer : ValueComparer - { - public SingleDimComparerWithComparer(RelationalTypeMapping elementMapping) : base( - (a, b) => Compare(a, b, (ValueComparer)elementMapping.Comparer), - o => o.GetHashCode(), // TODO: Need to get hash code of elements... - source => Snapshot(source, (ValueComparer)elementMapping.Comparer)) {} - - public override Type Type => typeof(TElem[]); - - static bool Compare(TElem[] a, TElem[] b, ValueComparer elementComparer) - { - if (a.Length != b.Length) - return false; - - // Note: the following currently boxes every element access because ValueComparer isn't really - // generic (see https://github.com/aspnet/EntityFrameworkCore/issues/11072) - for (var i = 0; i < a.Length; i++) - if (!elementComparer.Equals(a[i], b[i])) - return false; - - return true; - } - - static TElem[] Snapshot(TElem[] source, ValueComparer elementComparer) - { - if (source == null) - return null; - - var snapshot = new TElem[source.Length]; - // Note: the following currently boxes every element access because ValueComparer isn't really - // generic (see https://github.com/aspnet/EntityFrameworkCore/issues/11072) - for (var i = 0; i < source.Length; i++) - snapshot[i] = elementComparer.Snapshot(source[i]); - return snapshot; - } - } - - class SingleDimComparerWithIEquatable : ValueComparer - where TElem : IEquatable - { - public SingleDimComparerWithIEquatable() : base( - (a, b) => Compare(a, b), - o => o.GetHashCode(), // TODO: Need to get hash code of elements... - source => DoSnapshot(source)) {} - - public override Type Type => typeof(TElem[]); - - static bool Compare(TElem[] a, TElem[] b) - { - if (a.Length != b.Length) - return false; - - for (var i = 0; i < a.Length; i++) - { - var elem1 = a[i]; - var elem2 = b[i]; - // Note: the following null checks are elided if TElem is a value type - if (elem1 == null) - { - if (elem2 == null) - continue; - return false; - } - - if (!elem1.Equals(elem2)) - return false; - } - - return true; - } - - static TElem[] DoSnapshot(TElem[] source) - { - if (source == null) - return null; - var snapshot = new TElem[source.Length]; - for (var i = 0; i < source.Length; i++) - snapshot[i] = source[i]; - return snapshot; - } - } - - class SingleDimComparerWithEquals : ValueComparer - { - public SingleDimComparerWithEquals() : base( - (a, b) => Compare(a, b), - o => o.GetHashCode(), // TODO: Need to get hash code of elements... - source => DoSnapshot(source)) {} - - public override Type Type => typeof(TElem[]); - - static bool Compare(TElem[] a, TElem[] b) - { - if (a.Length != b.Length) - return false; - - // Note: the following currently boxes every element access because ValueComparer isn't really - // generic (see https://github.com/aspnet/EntityFrameworkCore/issues/11072) - for (var i = 0; i < a.Length; i++) - { - var elem1 = a[i]; - var elem2 = b[i]; - if (elem1 == null) - { - if (elem2 == null) - continue; - return false; - } - - if (!elem1.Equals(elem2)) - return false; - } - - return true; - } - - static TElem[] DoSnapshot(TElem[] source) - { - if (source == null) - return null; - - var snapshot = new TElem[source.Length]; - // Note: the following currently boxes every element access because ValueComparer isn't really - // generic (see https://github.com/aspnet/EntityFrameworkCore/issues/11072) - for (var i = 0; i < source.Length; i++) - snapshot[i] = source[i]; - return snapshot; - } - } - - #endregion Value Comparison } } diff --git a/src/EFCore.PG/Storage/Internal/NpgsqlTypeMappingSource.cs b/src/EFCore.PG/Storage/Internal/NpgsqlTypeMappingSource.cs index 9be22bfcac..4ce8b639f9 100644 --- a/src/EFCore.PG/Storage/Internal/NpgsqlTypeMappingSource.cs +++ b/src/EFCore.PG/Storage/Internal/NpgsqlTypeMappingSource.cs @@ -420,8 +420,18 @@ protected virtual RelationalTypeMapping FindArrayMapping(in RelationalTypeMappin mappingInfo.Scale)); if (elementMapping != null) - return StoreTypeMappings.GetOrAdd(storeType, - new RelationalTypeMapping[] { new NpgsqlArrayTypeMapping(storeType, elementMapping) })[0]; + { + var added = StoreTypeMappings.TryAdd(storeType, + new RelationalTypeMapping[] + { + new NpgsqlArrayArrayTypeMapping(storeType, elementMapping), + new NpgsqlArrayListTypeMapping(storeType, elementMapping) + }); + Debug.Assert(added); + var mapping = FindExistingMapping(mappingInfo); + Debug.Assert(mapping != null); + return mapping; + } } var clrType = mappingInfo.ClrType; @@ -443,10 +453,10 @@ protected virtual RelationalTypeMapping FindArrayMapping(in RelationalTypeMappin if (elementMapping is NpgsqlArrayTypeMapping) return null; - return ClrTypeMappings.GetOrAdd(clrType, new NpgsqlArrayTypeMapping(elementMapping, clrType)); + return ClrTypeMappings.GetOrAdd(clrType, new NpgsqlArrayArrayTypeMapping(elementMapping, clrType)); } - if (clrType.IsGenericType && clrType.GetGenericTypeDefinition() == typeof(List<>)) + if (clrType.IsGenericList()) { var elementType = clrType.GetGenericArguments()[0]; @@ -459,7 +469,7 @@ protected virtual RelationalTypeMapping FindArrayMapping(in RelationalTypeMappin if (elementMapping is NpgsqlArrayTypeMapping) return null; - return ClrTypeMappings.GetOrAdd(clrType, new NpgsqlListTypeMapping(elementMapping, clrType)); + return ClrTypeMappings.GetOrAdd(clrType, new NpgsqlArrayListTypeMapping(elementMapping, clrType)); } return null; diff --git a/test/EFCore.PG.FunctionalTests/Query/ArrayQueryTest.cs b/test/EFCore.PG.FunctionalTests/Query/ArrayArrayQueryTest.cs similarity index 83% rename from test/EFCore.PG.FunctionalTests/Query/ArrayQueryTest.cs rename to test/EFCore.PG.FunctionalTests/Query/ArrayArrayQueryTest.cs index 4dcfa53745..37bf61bbf3 100644 --- a/test/EFCore.PG.FunctionalTests/Query/ArrayQueryTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/ArrayArrayQueryTest.cs @@ -8,11 +8,11 @@ namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query { - public class ArrayQueryTest : IClassFixture + public class ArrayArrayQueryTest : IClassFixture { - ArrayQueryFixture Fixture { get; } + ArrayArrayQueryFixture Fixture { get; } - public ArrayQueryTest(ArrayQueryFixture fixture, ITestOutputHelper testOutputHelper) + public ArrayArrayQueryTest(ArrayArrayQueryFixture fixture, ITestOutputHelper testOutputHelper) { Fixture = fixture; Fixture.TestSqlLoggerFactory.Clear(); @@ -28,7 +28,6 @@ public void Roundtrip() var x = ctx.SomeEntities.Single(e => e.Id == 1); Assert.Equal(new[] { 3, 4 }, x.SomeArray); - Assert.Equal(new List { 3, 4 }, x.SomeList); } #endregion @@ -43,7 +42,7 @@ public void Index_with_constant() Assert.Single(actual); AssertSql( - @"SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeList"", s.""SomeMatrix"", s.""SomeText"" + @"SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeMatrix"", s.""SomeText"" FROM ""SomeEntities"" AS s WHERE s.""SomeArray""[1] = 3"); } @@ -60,37 +59,11 @@ public void Index_with_non_constant() AssertSql( @"@__x_0='0' -SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeList"", s.""SomeMatrix"", s.""SomeText"" +SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeMatrix"", s.""SomeText"" FROM ""SomeEntities"" AS s WHERE s.""SomeArray""[@__x_0 + 1] = 3"); } - [Fact] - public void Index_bytea_with_constant() - { - using var ctx = CreateContext(); - var actual = ctx.SomeEntities.Where(e => e.SomeBytea[0] == 3).ToList(); - - Assert.Single(actual); - AssertSql( - @"SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeList"", s.""SomeMatrix"", s.""SomeText"" -FROM ""SomeEntities"" AS s -WHERE get_byte(s.""SomeBytea"", 0) = 3"); - } - - [Fact(Skip = "Disabled since EF Core 3.0")] - public void Index_text_with_constant() - { - using var ctx = CreateContext(); - var actual = ctx.SomeEntities.Where(e => e.SomeText[0] == 'f').ToList(); - - Assert.Single(actual); - AssertSql( - @"SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeList"", s.""SomeMatrix"", s.""SomeText"" -FROM ""SomeEntities"" AS s -WHERE (get_byte(s.""SomeBytea"", 0) = 3) AND get_byte(s.""SomeBytea"", 0) IS NOT NULL"); - } - #endregion #region Equality @@ -106,7 +79,7 @@ public void SequenceEqual_with_parameter() AssertSql( @"@__arr_0='System.Int32[]' (DbType = Object) -SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeList"", s.""SomeMatrix"", s.""SomeText"" +SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeMatrix"", s.""SomeText"" FROM ""SomeEntities"" AS s WHERE s.""SomeArray"" = @__arr_0 LIMIT 2"); @@ -120,7 +93,7 @@ public void SequenceEqual_with_array_literal() Assert.Equal(new[] { 3, 4 }, x.SomeArray); AssertSql( - @"SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeList"", s.""SomeMatrix"", s.""SomeText"" + @"SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeMatrix"", s.""SomeText"" FROM ""SomeEntities"" AS s WHERE s.""SomeArray"" = ARRAY[3,4]::integer[] LIMIT 2"); @@ -138,7 +111,7 @@ public void Contains_with_literal() Assert.Equal(new[] { 3, 4 }, x.SomeArray); AssertSql( - @"SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeList"", s.""SomeMatrix"", s.""SomeText"" + @"SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeMatrix"", s.""SomeText"" FROM ""SomeEntities"" AS s WHERE 3 = ANY (s.""SomeArray"") LIMIT 2"); @@ -156,7 +129,7 @@ public void Contains_with_parameter() AssertSql( @"@__p_0='3' -SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeList"", s.""SomeMatrix"", s.""SomeText"" +SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeMatrix"", s.""SomeText"" FROM ""SomeEntities"" AS s WHERE @__p_0 = ANY (s.""SomeArray"") LIMIT 2"); @@ -170,7 +143,7 @@ public void Contains_with_column() Assert.Equal(new[] { 3, 4 }, x.SomeArray); AssertSql( - @"SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeList"", s.""SomeMatrix"", s.""SomeText"" + @"SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeMatrix"", s.""SomeText"" FROM ""SomeEntities"" AS s WHERE s.""Id"" + 2 = ANY (s.""SomeArray"") LIMIT 2"); @@ -188,7 +161,7 @@ public void Length() Assert.Equal(new[] { 3, 4 }, x.SomeArray); AssertSql( - @"SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeList"", s.""SomeMatrix"", s.""SomeText"" + @"SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeMatrix"", s.""SomeText"" FROM ""SomeEntities"" AS s WHERE cardinality(s.""SomeArray"") = 2 LIMIT 2"); @@ -202,7 +175,7 @@ public void Length_on_EF_Property() Assert.Equal(new[] { 3, 4 }, x.SomeArray); AssertSql( - @"SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeList"", s.""SomeMatrix"", s.""SomeText"" + @"SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeMatrix"", s.""SomeText"" FROM ""SomeEntities"" AS s WHERE cardinality(s.""SomeArray"") = 2 LIMIT 2"); @@ -243,7 +216,7 @@ public void Any_like() .ToList(); AssertSql( - @"SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeList"", s.""SomeMatrix"", s.""SomeText"" + @"SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeMatrix"", s.""SomeText"" FROM ""SomeEntities"" AS s WHERE s.""SomeText"" LIKE ANY (ARRAY['a%','b%','c%']::text[])"); } @@ -257,7 +230,7 @@ public void Any_ilike() .ToList(); AssertSql( - @"SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeList"", s.""SomeMatrix"", s.""SomeText"" + @"SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeMatrix"", s.""SomeText"" FROM ""SomeEntities"" AS s WHERE s.""SomeText"" ILIKE ANY (ARRAY['a%','b%','c%']::text[])"); } @@ -274,7 +247,6 @@ public void Any_like_anonymous() x => new { Array = x.SomeArray, - List = x.SomeList, Text = x.SomeText }); @@ -283,7 +255,7 @@ public void Any_like_anonymous() AssertSql( @"@__patterns_0='System.String[]' (DbType = Object) -SELECT s.""SomeArray"" AS ""Array"", s.""SomeList"" AS ""List"", s.""SomeText"" AS ""Text"" +SELECT s.""SomeArray"" AS ""Array"", s.""SomeText"" AS ""Text"" FROM ""SomeEntities"" AS s WHERE s.""SomeText"" LIKE ANY (@__patterns_0)"); } @@ -304,10 +276,10 @@ public void Any_Contains() Assert.Empty(results); AssertSql( - @"SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeList"", s.""SomeMatrix"", s.""SomeText"" + @"SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeMatrix"", s.""SomeText"" FROM ""SomeEntities"" AS s WHERE (ARRAY[2,3]::integer[] && s.""SomeArray"")", - @"SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeList"", s.""SomeMatrix"", s.""SomeText"" + @"SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeMatrix"", s.""SomeText"" FROM ""SomeEntities"" AS s WHERE (ARRAY[1,2]::integer[] && s.""SomeArray"")"); } @@ -328,20 +300,50 @@ public void All_Contains() Assert.Empty(results); AssertSql( - @"SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeList"", s.""SomeMatrix"", s.""SomeText"" + @"SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeMatrix"", s.""SomeText"" FROM ""SomeEntities"" AS s WHERE (ARRAY[5,6]::integer[] <@ s.""SomeArray"")", // - @"SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeList"", s.""SomeMatrix"", s.""SomeText"" + @"SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeMatrix"", s.""SomeText"" FROM ""SomeEntities"" AS s WHERE (ARRAY[4,5,6]::integer[] <@ s.""SomeArray"")"); } #endregion + #region bytea + + [Fact] + public void Index_bytea_with_constant() + { + using var ctx = CreateContext(); + var actual = ctx.SomeEntities.Where(e => e.SomeBytea[0] == 3).ToList(); + + Assert.Single(actual); + AssertSql( + @"SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeMatrix"", s.""SomeText"" +FROM ""SomeEntities"" AS s +WHERE get_byte(s.""SomeBytea"", 0) = 3"); + } + + [Fact(Skip = "Disabled since EF Core 3.0")] + public void Index_text_with_constant() + { + using var ctx = CreateContext(); + var actual = ctx.SomeEntities.Where(e => e.SomeText[0] == 'f').ToList(); + + Assert.Single(actual); + AssertSql( + @"SELECT s.""Id"", s.""SomeArray"", s.""SomeBytea"", s.""SomeMatrix"", s.""SomeText"" +FROM ""SomeEntities"" AS s +WHERE (get_byte(s.""SomeBytea"", 0) = 3) AND get_byte(s.""SomeBytea"", 0) IS NOT NULL"); + } + + #endregion + #region Support - protected ArrayQueryContext CreateContext() => Fixture.CreateContext(); + protected ArrayArrayQueryContext CreateContext() => Fixture.CreateContext(); void AssertSql(params string[] expected) => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); @@ -349,13 +351,13 @@ void AssertSql(params string[] expected) void AssertDoesNotContainInSql(string expected) => Assert.DoesNotContain(expected, Fixture.TestSqlLoggerFactory.Sql); - public class ArrayQueryContext : PoolableDbContext + public class ArrayArrayQueryContext : PoolableDbContext { public DbSet SomeEntities { get; set; } - public ArrayQueryContext(DbContextOptions options) : base(options) {} + public ArrayArrayQueryContext(DbContextOptions options) : base(options) {} - public static void Seed(ArrayQueryContext context) + public static void Seed(ArrayArrayQueryContext context) { context.SomeEntities.AddRange( new SomeArrayEntity @@ -364,7 +366,6 @@ public static void Seed(ArrayQueryContext context) SomeArray = new[] { 3, 4 }, SomeBytea = new byte[] { 3, 4 }, SomeMatrix = new[,] { { 5, 6 }, { 7, 8 } }, - SomeList = new List { 3, 4 }, SomeText = "foo" }, new SomeArrayEntity @@ -373,7 +374,6 @@ public static void Seed(ArrayQueryContext context) SomeArray = new[] { 5, 6, 7 }, SomeBytea = new byte[] { 5, 6, 7 }, SomeMatrix = new[,] { { 10, 11 }, { 12, 13 } }, - SomeList = new List { 3, 4 }, SomeText = "bar" }); context.SaveChanges(); @@ -385,17 +385,16 @@ public class SomeArrayEntity public int Id { get; set; } public int[] SomeArray { get; set; } public int[,] SomeMatrix { get; set; } - public List SomeList { get; set; } public byte[] SomeBytea { get; set; } public string SomeText { get; set; } } - public class ArrayQueryFixture : SharedStoreFixtureBase + public class ArrayArrayQueryFixture : SharedStoreFixtureBase { - protected override string StoreName => "ArrayQueryTest"; + protected override string StoreName => "ArrayArrayQueryTest"; protected override ITestStoreFactory TestStoreFactory => NpgsqlTestStoreFactory.Instance; public TestSqlLoggerFactory TestSqlLoggerFactory => (TestSqlLoggerFactory)ListLoggerFactory; - protected override void Seed(ArrayQueryContext context) => ArrayQueryContext.Seed(context); + protected override void Seed(ArrayArrayQueryContext context) => ArrayArrayQueryContext.Seed(context); } #endregion diff --git a/test/EFCore.PG.FunctionalTests/Query/ArrayListQueryTest.cs b/test/EFCore.PG.FunctionalTests/Query/ArrayListQueryTest.cs new file mode 100644 index 0000000000..ed24da9552 --- /dev/null +++ b/test/EFCore.PG.FunctionalTests/Query/ArrayListQueryTest.cs @@ -0,0 +1,367 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.TestUtilities; +using Npgsql.EntityFrameworkCore.PostgreSQL.TestUtilities; +using Xunit; +using Xunit.Abstractions; + +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query +{ + public class ArrayListQueryTest : IClassFixture + { + ArrayListQueryFixture Fixture { get; } + + public ArrayListQueryTest(ArrayListQueryFixture fixture, ITestOutputHelper testOutputHelper) + { + Fixture = fixture; + Fixture.TestSqlLoggerFactory.Clear(); + Fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper); + } + + #region Roundtrip + + [Fact] + public void Roundtrip() + { + using var ctx = CreateContext(); + var x = ctx.SomeEntities.Single(e => e.Id == 1); + + Assert.Equal(new List { 3, 4 }, x.SomeList); + } + + #endregion + + #region Indexers + + [Fact] + public void Index_with_constant() + { + using var ctx = CreateContext(); + var actual = ctx.SomeEntities.Where(e => e.SomeList[0] == 3).ToList(); + + Assert.Single(actual); + AssertSql( + @"SELECT s.""Id"", s.""SomeList"", s.""SomeText"" +FROM ""SomeEntities"" AS s +WHERE s.""SomeList""[1] = 3"); + } + + [Fact] + public void Index_with_non_constant() + { + using var ctx = CreateContext(); + // ReSharper disable once ConvertToConstant.Local + var x = 0; + var actual = ctx.SomeEntities.Where(e => e.SomeList[x] == 3).ToList(); + + Assert.Single(actual); + AssertSql( + @"@__x_0='0' + +SELECT s.""Id"", s.""SomeList"", s.""SomeText"" +FROM ""SomeEntities"" AS s +WHERE s.""SomeList""[@__x_0 + 1] = 3"); + } + + #endregion + + #region Equality + + [Fact] + public void SequenceEqual_with_parameter() + { + using var ctx = CreateContext(); + var arr = new[] { 3, 4 }; + var x = ctx.SomeEntities.Single(e => e.SomeList.SequenceEqual(arr)); + + Assert.Equal(new[] { 3, 4 }, x.SomeList); + AssertSql( + @"@__arr_0='System.Int32[]' (DbType = Object) + +SELECT s.""Id"", s.""SomeList"", s.""SomeText"" +FROM ""SomeEntities"" AS s +WHERE s.""SomeList"" = @__arr_0 +LIMIT 2"); + } + + [Fact] + public void SequenceEqual_with_array_literal() + { + using var ctx = CreateContext(); + var x = ctx.SomeEntities.Single(e => e.SomeList.SequenceEqual(new[] { 3, 4 })); + + Assert.Equal(new[] { 3, 4 }, x.SomeList); + AssertSql( + @"SELECT s.""Id"", s.""SomeList"", s.""SomeText"" +FROM ""SomeEntities"" AS s +WHERE s.""SomeList"" = ARRAY[3,4]::integer[] +LIMIT 2"); + } + + #endregion + + #region Containment + + [Fact] + public void Contains_with_literal() + { + using var ctx = CreateContext(); + var x = ctx.SomeEntities.Single(e => e.SomeList.Contains(3)); + + Assert.Equal(new[] { 3, 4 }, x.SomeList); + AssertSql( + @"SELECT s.""Id"", s.""SomeList"", s.""SomeText"" +FROM ""SomeEntities"" AS s +WHERE 3 = ANY (s.""SomeList"") +LIMIT 2"); + } + + [Fact] + public void Contains_with_parameter() + { + using var ctx = CreateContext(); + // ReSharper disable once ConvertToConstant.Local + var p = 3; + var x = ctx.SomeEntities.Single(e => e.SomeList.Contains(p)); + + Assert.Equal(new[] { 3, 4 }, x.SomeList); + AssertSql( + @"@__p_0='3' + +SELECT s.""Id"", s.""SomeList"", s.""SomeText"" +FROM ""SomeEntities"" AS s +WHERE @__p_0 = ANY (s.""SomeList"") +LIMIT 2"); + } + + [Fact] + public void Contains_with_column() + { + using var ctx = CreateContext(); + var x = ctx.SomeEntities.Single(e => e.SomeList.Contains(e.Id + 2)); + + Assert.Equal(new[] { 3, 4 }, x.SomeList); + AssertSql( + @"SELECT s.""Id"", s.""SomeList"", s.""SomeText"" +FROM ""SomeEntities"" AS s +WHERE s.""Id"" + 2 = ANY (s.""SomeList"") +LIMIT 2"); + } + + #endregion + + #region Count + + [Fact] + public void Count() + { + using var ctx = CreateContext(); + var x = ctx.SomeEntities.Single(e => e.SomeList.Count == 2); + + Assert.Equal(new[] { 3, 4 }, x.SomeList); + AssertSql( + @"SELECT s.""Id"", s.""SomeList"", s.""SomeText"" +FROM ""SomeEntities"" AS s +WHERE cardinality(s.""SomeList"") = 2 +LIMIT 2"); + } + + [Fact] + public void Count_on_EF_Property() + { + using var ctx = CreateContext(); + var x = ctx.SomeEntities.Single(e => EF.Property(e, nameof(SomeListEntity.SomeList)).Length == 2); + + Assert.Equal(new[] { 3, 4 }, x.SomeList); + AssertSql( + @"SELECT s.""Id"", s.""SomeList"", s.""SomeText"" +FROM ""SomeEntities"" AS s +WHERE cardinality(s.""SomeList"") = 2 +LIMIT 2"); + } + + [Fact] + public void Count_on_literal_not_translated() + { + using var ctx = CreateContext(); + var _ = ctx.SomeEntities.Where(e => new List { 1, 2, 3 }.Count == e.Id).ToList(); + + AssertDoesNotContainInSql("cardinality"); + } + + #endregion + + #region AnyAll + + [Fact] + public void Any_no_predicate() + { + using var ctx = CreateContext(); + var count = ctx.SomeEntities.Count(e => e.SomeList.Any()); + + Assert.Equal(2, count); + AssertSql( + @"SELECT COUNT(*)::INT +FROM ""SomeEntities"" AS s +WHERE cardinality(s.""SomeList"") > 0"); + } + + [Fact] + public void Any_like() + { + using var ctx = CreateContext(); + var _ = ctx.SomeEntities + .Where(e => new List { "a%", "b%", "c%" }.Any(p => EF.Functions.Like(e.SomeText, p))) + .ToList(); + + AssertSql( + @"SELECT s.""Id"", s.""SomeList"", s.""SomeText"" +FROM ""SomeEntities"" AS s +WHERE s.""SomeText"" LIKE ANY (ARRAY['a%','b%','c%']::text[])"); + } + + [Fact] + public void Any_ilike() + { + using var ctx = CreateContext(); + var _ = ctx.SomeEntities + .Where(e => new[] { "a%", "b%", "c%" }.Any(p => EF.Functions.ILike(e.SomeText, p))) + .ToList(); + + AssertSql( + @"SELECT s.""Id"", s.""SomeList"", s.""SomeText"" +FROM ""SomeEntities"" AS s +WHERE s.""SomeText"" ILIKE ANY (ARRAY['a%','b%','c%']::text[])"); + } + + [Fact] + public void Any_like_anonymous() + { + using var ctx = CreateContext(); + var patterns = new[] { "a%", "b%", "c%" }; + + var anon = + ctx.SomeEntities + .Select( + x => new + { + List = x.SomeList, + Text = x.SomeText + }); + + var _ = anon.Where(x => patterns.Any(p => EF.Functions.Like(x.Text, p))).ToList(); + + AssertSql( + @"@__patterns_0='System.String[]' (DbType = Object) + +SELECT s.""SomeList"" AS ""List"", s.""SomeText"" AS ""Text"" +FROM ""SomeEntities"" AS s +WHERE s.""SomeText"" LIKE ANY (@__patterns_0)"); + } + + [Fact] + public void Any_Contains() + { + using var ctx = CreateContext(); + + var results = ctx.SomeEntities + .Where(e => new[] { 2, 3 }.Any(p => e.SomeList.Contains(p))) + .ToList(); + Assert.Equal(1, Assert.Single(results).Id); + + results = ctx.SomeEntities + .Where(e => new[] { 1, 2 }.Any(p => e.SomeList.Contains(p))) + .ToList(); + Assert.Empty(results); + + AssertSql( + @"SELECT s.""Id"", s.""SomeList"", s.""SomeText"" +FROM ""SomeEntities"" AS s +WHERE (ARRAY[2,3]::integer[] && s.""SomeList"")", + // + @"SELECT s.""Id"", s.""SomeList"", s.""SomeText"" +FROM ""SomeEntities"" AS s +WHERE (ARRAY[1,2]::integer[] && s.""SomeList"")"); + } + + [Fact] + public void All_Contains() + { + using var ctx = CreateContext(); + + var results = ctx.SomeEntities + .Where(e => new[] { 5, 6 }.All(p => e.SomeList.Contains(p))) + .ToList(); + Assert.Equal(2, Assert.Single(results).Id); + + results = ctx.SomeEntities + .Where(e => new[] { 4, 5, 6 }.All(p => e.SomeList.Contains(p))) + .ToList(); + Assert.Empty(results); + + AssertSql( + @"SELECT s.""Id"", s.""SomeList"", s.""SomeText"" +FROM ""SomeEntities"" AS s +WHERE (ARRAY[5,6]::integer[] <@ s.""SomeList"")", + // + @"SELECT s.""Id"", s.""SomeList"", s.""SomeText"" +FROM ""SomeEntities"" AS s +WHERE (ARRAY[4,5,6]::integer[] <@ s.""SomeList"")"); + } + + #endregion + + #region Support + + protected ArrayListQueryContext CreateContext() => Fixture.CreateContext(); + + void AssertSql(params string[] expected) + => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); + + void AssertDoesNotContainInSql(string expected) + => Assert.DoesNotContain(expected, Fixture.TestSqlLoggerFactory.Sql); + + public class ArrayListQueryContext : PoolableDbContext + { + public DbSet SomeEntities { get; set; } + + public ArrayListQueryContext(DbContextOptions options) : base(options) {} + + public static void Seed(ArrayListQueryContext context) + { + context.SomeEntities.AddRange( + new SomeListEntity + { + Id = 1, + SomeList = new List { 3, 4 }, + SomeText = "foo" + }, + new SomeListEntity + { + Id = 2, + SomeList = new List { 5, 6, 7 }, + SomeText = "bar" + }); + context.SaveChanges(); + } + } + + public class SomeListEntity + { + public int Id { get; set; } + public List SomeList { get; set; } + public string SomeText { get; set; } + } + + public class ArrayListQueryFixture : SharedStoreFixtureBase + { + protected override string StoreName => "ArrayListQueryTest"; + protected override ITestStoreFactory TestStoreFactory => NpgsqlTestStoreFactory.Instance; + public TestSqlLoggerFactory TestSqlLoggerFactory => (TestSqlLoggerFactory)ListLoggerFactory; + protected override void Seed(ArrayListQueryContext context) => ArrayListQueryContext.Seed(context); + } + + #endregion + } +}